From 69f9bf51e4f7fc04d400ef66a23c4ca7fc67be52 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 18:56:19 +0800 Subject: [PATCH 1/4] ci: reuse verified task results and consolidate website checks --- .github/workflows/check.yml | 66 +++++++--- .github/workflows/release.yml | 14 ++ .github/workflows/website.yml | 39 +++--- docs/maintainers.md | 25 +++- scripts/ci_plan.py | 35 ++++- scripts/ci_plan_test.py | 27 ++-- scripts/ci_reuse.py | 242 ++++++++++++++++++++++++++++++++++ scripts/ci_reuse_test.py | 200 ++++++++++++++++++++++++++++ website/README.md | 2 +- 9 files changed, 583 insertions(+), 67 deletions(-) create mode 100644 scripts/ci_reuse.py create mode 100644 scripts/ci_reuse_test.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 182bf7f7..01734341 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,21 +11,38 @@ on: description: Retain native installers for a release build type: boolean default: false + force: + description: Execute checks even when matching success evidence exists + type: boolean + default: false + outputs: + native-run-id: + description: Run containing the verified native installers + value: ${{ jobs.check.outputs.native-run-id }} + workflow_dispatch: + inputs: + force: + description: Execute the full gate without reusing results + type: boolean + default: true push: branches: [main] pull_request: permissions: contents: read + actions: read + pages: read concurrency: group: core-check-${{ github.workflow }}-${{ inputs.ref && github.run_id || github.ref }} - cancel-in-progress: true + # Finish main runs so their evidence and Pages deployment remain usable. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: plan: runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 5 + timeout-minutes: 15 outputs: plan: ${{ steps.select.outputs.plan }} jobs: ${{ steps.select.outputs.jobs }} @@ -34,11 +51,17 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} - fetch-depth: 2 + fetch-depth: 0 + persist-credentials: false - name: Select checks from the integrated change id: select env: REQUESTED_REF: ${{ inputs.ref }} + GH_TOKEN: ${{ github.token }} + CI_REUSE: 'true' + CI_FORCE: ${{ inputs.force || false }} + CI_NATIVE_ARTIFACTS: ${{ inputs.native-artifacts || false }} + OAC_USE_GITHUB_RUNNERS: ${{ vars.OAC_USE_GITHUB_RUNNERS }} run: python3 scripts/ci_plan.py plan hygiene: @@ -270,22 +293,18 @@ jobs: website: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'website') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - uses: ./.github/actions/node - with: - lockfiles: | - website/pnpm-lock.yaml - - name: Build and test the website - run: make check-website + uses: ./.github/workflows/website.yml + with: + ref: ${{ inputs.ref || github.sha }} + publish: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !inputs.ref }} + permissions: + contents: read + pages: write + id-token: write web-acceptance: needs: [plan, web] - if: needs.web.result == 'success' && needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web-acceptance') + if: always() && !cancelled() && needs.plan.result == 'success' && (needs.web.result == 'success' || fromJSON(needs.plan.outputs.plan).reused.web != null) && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web-acceptance') strategy: fail-fast: false matrix: @@ -333,7 +352,7 @@ jobs: uses: ./.github/workflows/native.yml with: ref: ${{ inputs.ref || github.sha }} - upload-artifacts: ${{ inputs.native-artifacts || false }} + upload-artifacts: ${{ inputs.native-artifacts || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} lint: needs: plan @@ -344,6 +363,8 @@ jobs: # Always report the required check, even when planning or a dependency fails. check: + outputs: + native-run-id: ${{ steps.gate.outputs.native-run-id }} if: always() needs: [plan, hygiene, distribution, compose, backend, harness, example, web, web-acceptance, website, api, native, lint] runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} @@ -353,7 +374,18 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - name: Require every selected check to succeed + id: gate env: + GH_TOKEN: ${{ github.token }} + CI_NATIVE_ARTIFACTS: ${{ inputs.native-artifacts || false }} + OAC_USE_GITHUB_RUNNERS: ${{ vars.OAC_USE_GITHUB_RUNNERS }} PLAN: ${{ needs.plan.outputs.plan }} RESULTS: ${{ toJSON(needs) }} run: python3 scripts/ci_plan.py gate + - name: Retain verified check evidence + uses: actions/upload-artifact@v6 + with: + name: ci-evidence-${{ github.run_attempt }} + path: ${{ runner.temp }}/ci-evidence/evidence.json + retention-days: 2 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e67a7367..738191ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,10 @@ on: description: Full source commit SHA to build required: true type: string + force-checks: + description: Execute the full gate without reusing successful checks + default: false + type: boolean offline: description: Also create the full offline archive default: true @@ -20,6 +24,8 @@ on: permissions: contents: read + actions: read + pages: read concurrency: group: core-release-${{ github.event_name == 'push' && github.ref || inputs.ref }} @@ -27,10 +33,16 @@ concurrency: jobs: check: + permissions: + contents: read + actions: read + pages: write + id-token: write uses: ./.github/workflows/check.yml with: ref: ${{ inputs.ref || github.sha }} native-artifacts: true + force: ${{ inputs.force-checks || false }} build: needs: check @@ -109,6 +121,8 @@ jobs: bash scripts/prepare-release-runtimes.sh - uses: actions/download-artifact@v6 with: + github-token: ${{ github.token }} + run-id: ${{ needs.check.outputs.native-run-id }} pattern: oac-native-installer-* merge-multiple: true path: ${{ runner.temp }}/native-artifacts diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 0cd20251..3c24c1a6 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -1,34 +1,23 @@ -# Publishes the website (landing page and documentation) to GitHub Pages from -# main, and builds it for documentation-only pull requests. The website reads the -# existing docs/, contracts/ and docs.json in place; see website/README.md. +# Builds and tests the website once for core-check; main also deploys that artifact. name: website on: - push: - branches: [main] - paths: - - website/** - - docs/** - - contracts/** - - docs.json - - .github/actions/node/** - - .github/workflows/website.yml - # Changes under website/ are checked by core-check; documentation-only - # changes build the site here so broken links surface before merge. - pull_request: - paths: - - docs/** - - contracts/** - - docs.json - workflow_dispatch: + workflow_call: + inputs: + ref: + type: string + required: true + publish: + type: boolean + default: false permissions: contents: read concurrency: - group: website-${{ github.ref }} + group: website-${{ github.run_id }} # Never cancel a run that may be deploying main. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false jobs: build: @@ -39,10 +28,12 @@ jobs: # configure-pages reads the site's base path. pages: read env: - PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} + PUBLISH: ${{ inputs.publish }} steps: - uses: actions/checkout@v7 with: + ref: ${{ inputs.ref }} + persist-credentials: false # Full history gives each page its last-updated date. fetch-depth: 0 - uses: ./.github/actions/node @@ -64,7 +55,7 @@ jobs: deploy: needs: build - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: inputs.publish runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: diff --git a/docs/maintainers.md b/docs/maintainers.md index abd59f65..c00269d7 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -124,7 +124,7 @@ git push origin v1.2.3 Tags use `vMAJOR.MINOR.PATCH`, optionally with a prerelease suffix such as `-rc.1` and build metadata such as `+build.1`. A prerelease suffix creates a GitHub prerelease. Pushing the tag is the release decision. Automated checks establish build and test results, not real-model qualification: assess live execution evidence before you push the tag. Model credentials and private certificate authorities never enter CI or release inputs, including acceptance images that contain them. -The workflow runs `check` on the tagged commit, including the full local gate, official-client and image acceptance, and the native matrix with its packaging artifacts enabled. After checks succeed, one `build` job on GitHub-hosted `ubuntu-22.04` prepares the pinned Runtime inputs, reuses the native installers, builds the distribution and publishes directly from its local files. This combined job has `contents: write` and `packages: write`; checkout does not persist credentials. It retains an uncompressed Actions artifact before publication for recovery, without downloading that artifact again during normal publication. +The workflow requires the full check plan on the tagged commit, including official-client and image acceptance and native packaging. It waits up to ten minutes for an existing main check of that exact commit, verifies reusable main evidence, and executes the remaining checks according to [result reuse](#check-result-reuse). After checks succeed, one `build` job on the [release runner](#ci-runners-and-free-allowance) prepares the pinned Runtime inputs, downloads native installers from the verified source run, builds the distribution and publishes directly from its local files. This combined job has `contents: write` and `packages: write`; checkout does not persist credentials. It retains an uncompressed Actions artifact before publication for recovery, without downloading that artifact again during normal publication. Distribution and Runtime archives use `pigz` level 6 with at most four compression workers and no filename or timestamp in the gzip header. The publisher verifies archive and native installer checksums, resolves the repository identity, refuses an existing Release or draft for the tag and creates one draft with a fixed ID. Up to four assets upload concurrently, largest first, without retries. After confirming the complete remote inventory, the publisher validates all image archives and existing registry tags before pushing up to four images concurrently. Each image config and registry manifest is verified; any error leaves the Release unpublished. In-flight transfers finish before a failed operation returns. The publisher rechecks the version tag before publishing the draft by its ID. @@ -138,7 +138,7 @@ GHCR and GitHub Releases do not share a transaction. A failed release may leave `install.sh` resolves the latest stable release once, or the release named by `--version`, verifies the control archive and runs that bundle's installer; the [installation guide](./getting-started/install.md#install) covers its use. -Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older cache only seeds downloads and compilation; every check still runs. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. +Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older dependency cache only seeds downloads and compilation; check-result reuse requires separate verified evidence. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. Never move a release tag or overwrite published assets. If publication fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft only after inspection, download the original `core-release-` Actions artifact with `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY`, and use a checkout of that exact source revision to run `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`. Set `GH_REPO`, `GH_TOKEN`, `RELEASE_REVISION`, `RELEASE_TAG` and `RELEASE_MODE` to the original publication inputs and sign Docker into GHCR for version publication. The script revalidates the assets and refuses existing releases. Do not rerun the combined build job or recreate the tag to recover a failed upload. @@ -158,7 +158,7 @@ With `draft_release=true` the result is an unpublished `build-` draft Every PR and main push runs `core-check` and reports the required status `check`. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. -The planner compares the PR event's tested merge commit with its verified first parent. Main pushes use the verified event `before`/`after` range with the same selection rules. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, empty diffs, planner/orchestration changes and shared build inputs select the full gate. Release and explicit-ref calls always select every group. +The planner compares the PR event's tested merge commit with its verified first parent. Main pushes use the verified event `before`/`after` range with the same selection rules. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls select every group. Selected groups are then partitioned into execution and verified reuse. | Group | Checks and consumers | | --- | --- | @@ -169,12 +169,13 @@ The planner compares the PR event's tested merge commit with its verified first | `harness` | Claude SDK tests and packaging, MiniMax companion scripts | | `example` | Optional application typecheck, tests, build and isolated browser acceptance | | `web` | TypeScript, Web/client tests and Web build | +| `website` | Website build, published documentation links and output checks; main deploys the same artifact to Pages | | `web-acceptance` | Full Web browser suite in four isolated shards after Web unit/build success; each keeps one worker | | `api` | Reusable official-client acceptance against standalone commands and migrations; image acceptance when image/build/helper inputs change, and in every full gate | | `native` | Reusable Linux, macOS and Windows builds, filesystem/process/Harness checks and native installation; all three platforms can run concurrently | | `lint` | Reusable actionlint check, including local composite actions | -Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. +Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. `.github/actionlint.yaml` selects lint. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. This checks the declared release images and generic Compose behavior; it does not run a Dokploy/Coolify instance or execute a model. @@ -182,9 +183,21 @@ Go module and workspace inputs select backend, API (including the container), na Ordinary Markdown and documentation-site configuration run hygiene only, including documentation inside source directories. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. -The final `check` runs even when planning or a dependency fails. It requires a successful, valid plan, every selected job to be successful, and every unselected job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API/native reusable workflows are direct dependencies of this gate. A newer run on the same PR cancels its predecessor. Release checks run at their requested immutable ref; native packaging executes once inside those checks, and the distribution build waits for them. +The final `check` runs even when planning or a dependency fails. It requires a valid plan, every executing job to succeed, every reused job to have independently verified evidence, and every other job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API, native and website reusable workflows are direct dependencies. A newer run on the same PR cancels its predecessor. Main runs finish before the latest pending main run starts, preserving successful evidence and Pages deployment. Release checks run at their requested immutable ref; the distribution build waits for the gate and consumes its native artifact source. -Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. +### Check result reuse + +`scripts/ci_reuse.py` fingerprints each task's tracked inputs using the planner's input-to-check map, including file paths, modes and Git object IDs. Task keys include the GitHub/Blacksmith runner switch and API container-check mode. Shared build files, toolchain declarations and CI implementation changes invalidate their consumers. Documentation and site inputs select the website through this same map; `website.yml` has no separate push or PR trigger. A documentation follow-up can therefore reuse unchanged backend checks while still building the site. + +The planner examines up to 20 recent `core-check` runs. Evidence must come from a completed successful run in this repository and be less than 24 hours old. It verifies the artifact's run, attempt, repository, tested commit, producer workflow and planner code, then recomputes input fingerprints from Git. PRs may reuse main evidence or evidence from their own repository branch; fork PR evidence is not consumed. A main push may promote PR evidence only when the complete tested merge tree equals its own tree. Release callers accept main evidence only. Reuse retains the original verification timestamp, so repeated promotions never extend its lifetime. Hygiene, Compose startup and website checks always execute when selected because they check repository integrity, external state or Git history and Pages settings. + +The final gate revalidates each reuse source and writes `ci-evidence-` with executed and verified results. The plan summary lists executed tasks and source run IDs for reused tasks. Evidence artifacts are retained for two days; native archives are retained for seven. Missing, malformed, expired or inaccessible evidence makes planning execute the affected checks. Evidence becoming invalid after planning fails the gate. Dependency caches are never accepted as successful check evidence. + +Native test results may be reused across equal task inputs in PRs. Main and release packaging additionally require installers from a successful main run at the exact source commit, with all three platform artifacts still available; versioned native binaries are never substituted from another commit. Main retains these installers when native checks execute. Website build and deployment stay together in the reusable website workflow so deployment consumes its own build with the correct Pages base path and Git history. + +Use **Actions → core-check → Run workflow** with `force=true` to execute the full gate without result reuse. Manual releases expose `force-checks` for the same purpose. Mutable runner images, package registries and other external inputs are bounded by the evidence lifetime rather than assumed immutable. The planner retains conservative full selection for changes to shared orchestration and build inputs. + +Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded for main checks, explicit manual packaging and releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the existing module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The three native platforms run independently; parallel execution reduces elapsed time without reducing total machine time. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 600b27e7..cd754869 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -19,9 +19,12 @@ ".github/workflows/native.yml": ("native", "lint"), ".github/workflows/actionlint.yml": ("lint",), ".github/workflows/ci-review.yml": ("lint",), - ".github/workflows/website.yml": ("lint",), + ".github/workflows/website.yml": ("website", "lint"), + ".github/actionlint.yaml": ("lint",), ".github/actions/node/action.yml": (*NODE_JOBS, "lint"), "scripts/ci_plan.py": JOBS, + "scripts/ci_reuse.py": JOBS, + "scripts/ci_reuse_test.py": ("hygiene",), "scripts/ci_plan_test.py": ("hygiene",), "scripts/ci_metrics.py": ("hygiene",), "scripts/ci_metrics_test.py": ("hygiene",), @@ -39,6 +42,7 @@ "packages/agents-client/package.json": ("web", "web-acceptance", "example"), "packages/claude-sdk-adapter/pnpm-lock.yaml": ("harness", "native", "distribution"), "packages/claude-sdk-adapter/package.json": ("harness", "native", "distribution"), + "docs.json": ("website",), "tsconfig.base.json": ("web", "web-acceptance", "example"), } # Each rule requires BOTH a path prefix and a file suffix. Rules accumulate @@ -51,6 +55,7 @@ (("apps/web/",), WEB, ("web", "web-acceptance")), (("services/web/",), (*GO, "Dockerfile"), ("distribution", "web", "web-acceptance")), (("example/",), WEB, ("example",)), + (("docs/", "contracts/"), (".md", ".svg", ".png", ".jpg", ".jpeg", ".webp"), ("website",)), (("website/",), (*WEB, ".vue", ".md"), ("website",)), (("services/core/",), CORE, ("backend", "api")), (("services/core/internal/nativeinstaller/",), GO, ("native", "distribution")), @@ -112,7 +117,7 @@ def full(reason): def select(paths): if not paths: - return full("Empty diff; run the full gate") + return {"version": 1, "jobs": ["hygiene"], "image": False, "reasons": ["Verified empty diff"]} jobs = {"hygiene"} image = False reasons = [] @@ -199,6 +204,13 @@ def validate_plan(plan): raise ValueError("Invalid selected jobs") if plan["image"] and "api" not in selected: raise ValueError("Image checks require API acceptance") + if "execute" in plan or "reused" in plan: + execute, reused = plan.get("execute"), plan.get("reused") + if (not isinstance(execute, list) or any(not isinstance(j, str) for j in execute) + or len(execute) != len(set(execute)) or not isinstance(reused, dict) + or set(execute) & set(reused) or set(execute) | set(reused) != set(selected) + or "hygiene" not in execute): + raise ValueError("Invalid execution/reuse partition") return set(selected) @@ -206,7 +218,8 @@ def check_results(plan, needs): selected = validate_plan(plan) if set(needs) != set(JOBS) | {"plan"} or needs["plan"].get("result") != "success": raise ValueError("Missing jobs or unsuccessful plan") - failed = [job for job in JOBS if needs[job].get("result") != ("success" if job in selected else "skipped")] + execute = set(plan.get("execute", selected)) + failed = [job for job in JOBS if needs[job].get("result") != ("success" if job in execute else "skipped")] if failed: raise ValueError("Check results do not match the plan: " + ", ".join(failed)) @@ -220,8 +233,14 @@ def main(): sub.add_parser("gate") args = parser.parse_args() if args.command == "gate": - check_results(json.loads(os.environ["PLAN"]), json.loads(os.environ["RESULTS"])) - print("All checks selected by the plan passed.") + plan = json.loads(os.environ["PLAN"]) + check_results(plan, json.loads(os.environ["RESULTS"])) + if "execute" in plan: + from ci_reuse import Evidence + evidence = Evidence() + evidence.verify(plan) + evidence.record(plan) + print("All selected checks passed or have verified successful evidence.") return if args.base: plan = select(changed_paths(args.base, args.head)) @@ -231,11 +250,15 @@ def main(): except (OSError, ValueError, KeyError): event = {} plan = event_plan(os.environ.get("GITHUB_EVENT_NAME"), event, os.environ.get("REQUESTED_REF", "")) + if os.environ.get("CI_REUSE") == "true" and not args.base: + from ci_reuse import Evidence + plan = Evidence().plan(plan) + validate_plan(plan) print(json.dumps(plan, indent=2)) if output := os.environ.get("GITHUB_OUTPUT"): with open(output, "a") as f: f.write("plan=" + json.dumps(plan, separators=(",", ":")) + "\n") - f.write("jobs=" + json.dumps(plan["jobs"]) + "\n") + f.write("jobs=" + json.dumps(plan.get("execute", plan["jobs"])) + "\n") f.write("image=" + json.dumps(plan["image"]) + "\n") if summary := os.environ.get("GITHUB_STEP_SUMMARY"): with open(summary, "a") as f: diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index acf11146..50e89d08 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -15,8 +15,9 @@ def jobs(self, *paths): return set(ci.select(paths)["jobs"]) def test_documents_only_need_repository_integrity(self): - for path in ("docs/maintainers.md", "README.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg"): - self.assertEqual(self.jobs(path), {"hygiene"}) + for path in ("docs/maintainers.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg"): + self.assertEqual(self.jobs(path), {"hygiene", "website"}) + self.assertEqual(self.jobs("README.md"), {"hygiene"}) def test_installer_does_not_download_a_browser_or_run_database_tests(self): self.assertEqual(self.jobs("deploy/install/install.py", "scripts/install-release.test.py"), {"hygiene", "distribution"}) @@ -76,7 +77,7 @@ def test_core_fixtures_retain_client_and_installer_consumers(self): self.assertTrue({"backend", "api", "distribution"} <= self.jobs(path)) def test_shared_inputs_planner_and_empty_diffs_are_full(self): - for paths in ([], ["Makefile"], [".github/workflows/new.yml"], + for paths in (["Makefile"], [".github/workflows/new.yml"], [".github/actions/new/action.yml"], [".github/workflows/check.yml"], [".github/workflows/release.yml"], ["scripts/ci_plan.py"], ["../outside"], ["/outside"]): self.assertEqual(set(ci.select(paths)["jobs"]), set(ci.JOBS)) self.assertTrue(ci.select(paths)["image"]) @@ -85,7 +86,7 @@ def test_workflow_changes_select_only_their_consumers(self): for workflow, selected in { "ci-review": {"hygiene", "lint"}, "actionlint": {"hygiene", "lint"}, - "website": {"hygiene", "lint"}, + "website": {"hygiene", "website", "lint"}, "native": {"hygiene", "native", "lint"}, "api-acceptance": {"hygiene", "api", "lint"}, }.items(): @@ -100,7 +101,7 @@ def test_node_action_selects_all_direct_consumers_and_lint(self): consumers = {name for name, body in re.findall( r"^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:|\Z)", workflow, re.M | re.S) if "uses: ./.github/actions/node" in body} - for name, filename in (("api", "api-acceptance"), ("native", "native")): + for name, filename in (("api", "api-acceptance"), ("native", "native"), ("website", "website")): if "uses: ./.github/actions/node" in (root / f".github/workflows/{filename}.yml").read_text(): consumers.add(name) self.assertEqual(self.jobs(".github/actions/node/action.yml"), consumers | {"hygiene", "lint"}) @@ -158,13 +159,13 @@ def test_every_job_has_a_plan_condition(self): def test_mixed_changes_accumulate(self): self.assertEqual(self.jobs("docs/maintainers.md", "deploy/install/install.py", "apps/web/src/app.tsx"), - {"hygiene", "distribution", "web", "web-acceptance"}) + {"hygiene", "distribution", "web", "web-acceptance", "website"}) def test_installer_pr_300_replay(self): self.assertEqual(self.jobs( "deploy/install-release.sh", "deploy/install/README.md", "deploy/install/install.py", "deploy/install/install_display.py", "deploy/install/test_install.py", "deploy/install/test_install_output.py", - "docs/getting-started/install.md", "scripts/install-release.test.py"), {"hygiene", "distribution"}) + "docs/getting-started/install.md", "scripts/install-release.test.py"), {"hygiene", "distribution", "website"}) def test_workflow_graph_cannot_silently_omit_or_add_a_gate_dependency(self): workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/check.yml").read_text().split("jobs:\n", 1)[1] @@ -243,7 +244,7 @@ def git(*args): paths = ci.changed_paths(base, "HEAD") self.assertEqual(set(paths), {"docs/old.md", "services/core/deleted.go", "apps/web/renamed\nwith space.ts"}) plan = ci.event_plan("pull_request", {"pull_request": {"base": {"sha": base}, "head": {"sha": head}}}) - self.assertEqual(set(plan["jobs"]), {"hygiene", "backend", "api", "web", "web-acceptance"}) + self.assertEqual(set(plan["jobs"]), {"hygiene", "backend", "api", "web", "web-acceptance", "website"}) (clone / "old.md").write_text("untracked content cannot change the diff\n") self.assertEqual(paths, ci.changed_paths(base, "HEAD")) finally: @@ -311,14 +312,14 @@ def plan(self, paths, event=None, ref=""): def test_doc_site_configuration_and_docs_only_push_skip_product_checks(self): paths = ["docs.json", ".mintignore", "docs/getting-started/index.md", "README.md"] - self.assertEqual(set(ci.select(paths)["jobs"]), {"hygiene"}) + self.assertEqual(set(ci.select(paths)["jobs"]), {"hygiene", "website"}) plan, diff = self.plan(paths) - self.assertEqual(set(plan["jobs"]), {"hygiene"}) + self.assertEqual(set(plan["jobs"]), {"hygiene", "website"}) diff.assert_called_once_with(self.before, self.after) def test_generated_documentation_keeps_freshness_checks(self): plan, _ = self.plan(["docs/configuration.md", "contracts/agents-api/harness-catalog.md"]) - self.assertEqual(set(plan["jobs"]), {"hygiene", "distribution"}) + self.assertEqual(set(plan["jobs"]), {"hygiene", "distribution", "website"}) def test_main_pushes_use_the_same_directory_suffix_rules_as_prs(self): for paths in (["README.md", "services/core/cmd/server/main.go"], ["new.md"], [], @@ -353,14 +354,14 @@ def git(*args): try: os.chdir(repo) event = self.event | {"before": before, "after": docs_head} - self.assertEqual(ci.event_plan("push", event)["jobs"], ["hygiene"]) + self.assertEqual(ci.event_plan("push", event)["jobs"], ["hygiene", "website"]) (repo / "services/core").mkdir(parents=True) (repo / "services/core/code.go").write_text("package example\n") git("add", "."); git("commit", "-m", "code") (repo / "README.md").write_text("updated\n") git("add", "."); git("commit", "-m", "docs again") event["after"] = git("rev-parse", "HEAD") - self.assertEqual(set(ci.event_plan("push", event)["jobs"]), {"hygiene", "backend", "api"}) + self.assertEqual(set(ci.event_plan("push", event)["jobs"]), {"hygiene", "website", "backend", "api"}) clone = Path(tmp) / "shallow" subprocess.run(["git", "clone", "--depth=2", repo.as_uri(), str(clone)], check=True, capture_output=True) os.chdir(clone) diff --git a/scripts/ci_reuse.py b/scripts/ci_reuse.py new file mode 100644 index 00000000..8756ec7d --- /dev/null +++ b/scripts/ci_reuse.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Reuse successful Actions checks with verified source and task inputs.""" + +from datetime import datetime, timedelta, timezone +import hashlib +import io +import json +import os +from pathlib import Path +import re +import subprocess +import time +import zipfile + +import ci_plan as ci + +VERSION = 1 +MAX_AGE = timedelta(hours=24) +LIMIT = 20 +# These jobs inspect external state or Git history and always run when selected. +FRESH = {"hygiene", "compose", "website"} +CONTROL_FILES = ("scripts/ci_plan.py", "scripts/ci_reuse.py") +NATIVE_ARTIFACTS = {f"oac-native-installer-{platform}" for platform in ("Linux-X64", "macOS-ARM64", "Windows-X64")} + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def tree(revision): + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("Expected an immutable commit") + try: + raw = ci.git("ls-tree", "-rz", "--full-tree", revision) + except subprocess.CalledProcessError: + ci.git("fetch", "--no-tags", "--depth=2", "origin", revision) + raw = ci.git("ls-tree", "-rz", "--full-tree", revision) + return dict(entry.split("\t", 1)[::-1] for entry in raw.decode().split("\0") if entry) + + +def controls(files): + return digest({path: value for path, value in files.items() + if path in CONTROL_FILES or path.startswith((".github/workflows/", ".github/actions/"))}) + + +def fingerprints(files, runner_mode, image): + inputs = {job: {} for job in ci.JOBS} + for path, value in files.items(): + for job in ci.select([path])["jobs"]: + inputs[job][path] = value + return {job: digest({"version": VERSION, "files": values, "runner": runner_mode, + "image": image if job == "api" else False}) for job, values in inputs.items()} + + +def now(): + return datetime.now(timezone.utc) + + +def fresh(timestamp): + age = now() - datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + return timedelta(0) <= age < MAX_AGE + + +def api(path, binary=False): + result = subprocess.run(["gh", "api", path], check=True, capture_output=True, timeout=45) + return result.stdout if binary else json.loads(result.stdout) + + +class Evidence: + def __init__(self): + self.repository = os.environ["GITHUB_REPOSITORY"] + self.prefix = f"repos/{self.repository}/actions" + self.revision = ci.git("rev-parse", "HEAD").decode().strip() + self.files = tree(self.revision) + self.control = controls(self.files) + self.mode = os.environ.get("OAC_USE_GITHUB_RUNNERS", "") == "true" + self.run_id = int(os.environ["GITHUB_RUN_ID"]) + self.attempt = int(os.environ["GITHUB_RUN_ATTEMPT"]) + self.release = os.environ.get("CI_NATIVE_ARTIFACTS") == "true" + self.event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + self.event_name = os.environ["GITHUB_EVENT_NAME"] + self.artifacts_required = self.release or (self.event_name == "push" and self.event.get("ref") == "refs/heads/main") + self.cache = {} + + def eligible(self, run): + if (run["id"] == self.run_id or run.get("path") != ".github/workflows/check.yml" + or run.get("head_repository", {}).get("full_name") != self.repository): + return False + if run.get("event") == "push" and run.get("head_branch") == "main": + return True + # PR evidence stays within the same repository PR. Main may promote an + # identical tested tree, but release callers only read main evidence. + if self.release or run.get("event") != "pull_request": + return False + if self.event_name == "pull_request": + pr = self.event.get("pull_request", {}) + return (pr.get("head", {}).get("repo", {}).get("full_name") == self.repository + and run.get("head_branch") == pr.get("head", {}).get("ref")) + return self.event_name == "push" and self.event.get("ref") == "refs/heads/main" + + def artifacts(self, run_id): + return api(f"{self.prefix}/runs/{run_id}/artifacts?per_page=100")["artifacts"] + + def receipt(self, run): + if not self.eligible(run) or run.get("conclusion") != "success" or run.get("status") != "completed": + raise ValueError("Run is not an eligible success") + if not fresh(run["created_at"]): + raise ValueError("Run is too old") + attempt = run["run_attempt"] + name = f"ci-evidence-{attempt}" + matches = [a for a in self.artifacts(run["id"]) if a["name"] == name and not a["expired"]] + if len(matches) != 1 or matches[0]["size_in_bytes"] > 100_000: + raise ValueError("Missing or invalid evidence artifact") + raw = api(f"{self.prefix}/artifacts/{matches[0]['id']}/zip", binary=True) + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + info = archive.getinfo("evidence.json") + if info.file_size > 100_000: + raise ValueError("Oversized evidence") + receipt = json.loads(archive.read(info)) + if (receipt["version"] != VERSION or receipt["run_id"] != run["id"] + or receipt["attempt"] != attempt or receipt["repository"] != self.repository + or receipt["runner"] != self.mode or type(receipt["image"]) is not bool): + raise ValueError("Evidence identity mismatch") + revision = receipt["revision"] + files = tree(revision) + # Verify the producer implementation, not a fingerprint claimed in an + # artifact. PR workflow code must also match at the event's head SHA. + if controls(files) != self.control or controls(tree(run["head_sha"])) != self.control: + raise ValueError("Evidence producer changed") + if run["event"] == "push": + if revision != run["head_sha"]: + raise ValueError("Push evidence has another checkout") + else: + parents = ci.git("show", "-s", "--format=%P", revision).decode().split() + if len(parents) != 2 or parents[1] != run["head_sha"]: + raise ValueError("PR evidence is not the tested merge") + if self.event_name == "push" and files != self.files: + raise ValueError("Main can only promote the identical PR tree") + expected = fingerprints(files, self.mode, receipt["image"]) + for job, item in receipt["passed"].items(): + if job not in expected or item["key"] != expected[job] or not fresh(item["verified_at"]): + raise ValueError("Invalid or expired check evidence") + return receipt + + def load(self, run_id): + if type(run_id) is not int or run_id <= 0: + raise ValueError("Invalid source run ID") + if run_id not in self.cache: + run = api(f"{self.prefix}/runs/{run_id}") + self.cache[run_id] = self.receipt(run) + return self.cache[run_id] + + def native_available(self, receipt): + if receipt["revision"] != self.revision: + return False + run_id = receipt.get("native_run_id") + if not run_id: + return False + run = api(f"{self.prefix}/runs/{run_id}") + if (run.get("event") != "push" or run.get("head_branch") != "main" + or run.get("head_sha") != self.revision or run.get("conclusion") != "success" + or run.get("path") != ".github/workflows/check.yml" + or run.get("head_repository", {}).get("full_name") != self.repository): + return False + names = {a["name"] for a in self.artifacts(run_id) if not a["expired"]} + return NATIVE_ARTIFACTS <= names + + def matching(self, receipt, job, key): + item = receipt["passed"].get(job) + return (job not in FRESH and item is not None and item["key"] == key + and fresh(item["verified_at"]) + and (job != "native" or not self.artifacts_required or self.native_available(receipt))) + + def candidates(self): + runs = api(f"{self.prefix}/workflows/check.yml/runs?per_page={LIMIT}")["workflow_runs"] + # A release waits for an existing main run of its exact source, bounded + # to ten minutes. Failure/cancellation falls back to executing checks. + pending = [r for r in runs if self.release and self.eligible(r) + and r["head_sha"] == self.revision and r["status"] != "completed"] + if pending: + run = pending[0] + deadline = time.monotonic() + 600 + while run["status"] != "completed" and time.monotonic() < deadline: + print(f"Waiting for main checks: {run['html_url']}", flush=True) + time.sleep(20) + run = api(f"{self.prefix}/runs/{run['id']}") + runs = [run] + [r for r in runs if r["id"] != run["id"]] + return runs + + def plan(self, plan): + keys = fingerprints(self.files, self.mode, plan["image"]) + result = dict(plan, execute=list(plan["jobs"]), reused={}, keys=keys) + if os.environ.get("CI_FORCE") == "true": + return result + try: + runs = self.candidates() + except (subprocess.SubprocessError, ValueError, KeyError): + print("Evidence lookup unavailable; executing selected checks.") + return result + for run in runs: + if not self.eligible(run) or run.get("conclusion") != "success": + continue + try: + receipt = self.receipt(run) + for job in list(result["execute"]): + if self.matching(receipt, job, keys[job]): + result["execute"].remove(job) + result["reused"][job] = {"run_id": run["id"], "key": keys[job]} + print(f"Reuse {job}: {run['html_url']}") + if set(result["execute"]) <= FRESH: + break + except (subprocess.SubprocessError, ValueError, KeyError, TypeError, zipfile.BadZipFile): + continue + return result + + def verify(self, plan): + if plan["keys"] != fingerprints(self.files, self.mode, plan["image"]): + raise ValueError("Plan inputs changed") + for job, source in plan["reused"].items(): + if source["key"] != plan["keys"][job] or not self.matching(self.load(source["run_id"]), job, source["key"]): + raise ValueError(f"Cannot verify reused check: {job}") + + def record(self, plan): + passed = {job: {"key": plan["keys"][job], "verified_at": now().isoformat()} + for job in plan["execute"]} + for job, source in plan["reused"].items(): + passed[job] = self.load(source["run_id"])["passed"][job] + native_run = None + if "native" in plan["execute"] and (self.release or self.event_name == "push" and self.event.get("ref") == "refs/heads/main"): + native_run = self.run_id + elif "native" in plan["reused"]: + receipt = self.load(plan["reused"]["native"]["run_id"]) + if receipt["revision"] == self.revision: + native_run = receipt.get("native_run_id") + receipt = {"version": VERSION, "repository": self.repository, "run_id": self.run_id, + "attempt": self.attempt, "revision": self.revision, "runner": self.mode, + "image": plan["image"], "passed": passed, "native_run_id": native_run} + directory = Path(os.environ["RUNNER_TEMP"]) / "ci-evidence" + directory.mkdir(exist_ok=True) + (directory / "evidence.json").write_text(json.dumps(receipt)) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"native-run-id={native_run or ''}\n") diff --git a/scripts/ci_reuse_test.py b/scripts/ci_reuse_test.py new file mode 100644 index 00000000..425b05f7 --- /dev/null +++ b/scripts/ci_reuse_test.py @@ -0,0 +1,200 @@ +"""Input invalidation, provenance, gate and workflow regression tests.""" +from datetime import timedelta +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import ci_plan as ci +import ci_reuse as reuse + + +class FingerprintTests(unittest.TestCase): + def setUp(self): + self.files = {"services/core/main.go": "100644 blob code", "docs/guide.md": "100644 blob docs", + "apps/web/src/app.tsx": "100644 blob web", "Makefile": "100644 blob make", + "scripts/ci_reuse.py": "100644 blob reuse", ".github/workflows/check.yml": "100644 blob workflow"} + + def keys(self, files=None, mode=False, image=False): + return reuse.fingerprints(self.files if files is None else files, mode, image) + + def test_docs_followup_keeps_code_evidence(self): + changed = self.files | {"docs/guide.md": "100644 blob new-docs"} + a, b = self.keys(), self.keys(changed) + self.assertNotEqual(a["hygiene"], b["hygiene"]) + self.assertNotEqual(a["website"], b["website"]) + for job in ("backend", "api", "web", "web-acceptance", "native"): + self.assertEqual(a[job], b[job], job) + + def test_source_addition_deletion_mode_and_shared_inputs_invalidate(self): + for change in ({"services/core/main.go": "100644 blob new-code"}, + {"services/core/main.go": "100755 blob code"}, + {"services/core/new.go": "100644 blob code"}): + self.assertNotEqual(self.keys()["backend"], self.keys(self.files | change)["backend"]) + self.assertNotEqual(self.keys()["backend"], self.keys({k:v for k,v in self.files.items() if k != "services/core/main.go"})["backend"]) + for path in ("Makefile", "scripts/ci_reuse.py", ".github/workflows/check.yml"): + self.assertTrue(all(self.keys()[j] != self.keys(self.files | {path: "new"})[j] for j in ci.JOBS)) + + def test_runner_and_image_modes_are_part_of_evidence(self): + self.assertTrue(all(self.keys()[j] != self.keys(mode=True)[j] for j in ci.JOBS)) + self.assertNotEqual(self.keys()["api"], self.keys(image=True)["api"]) + self.assertEqual(self.keys()["backend"], self.keys(image=True)["backend"]) + + def test_unrelated_node_module_does_not_invalidate_backend(self): + self.assertEqual(self.keys()["backend"], self.keys(self.files | {"apps/web/pnpm-lock.yaml": "new"})["backend"]) + + def test_empty_diff_is_not_full_and_lint_config_is_checked(self): + self.assertEqual(ci.select([])["jobs"], ["hygiene"]) + self.assertEqual(set(ci.select([".github/actionlint.yaml"])["jobs"]), {"hygiene", "lint"}) + + +class EvidenceTests(unittest.TestCase): + def setUp(self): + self.e = object.__new__(reuse.Evidence) + self.e.repository = "owner/repo" + self.e.prefix = "repos/owner/repo/actions" + self.e.revision = "a" * 40 + self.e.files = {"scripts/ci_reuse.py": "reuse", ".github/workflows/check.yml": "workflow", "services/core/code.go": "code"} + self.e.control = reuse.controls(self.e.files) + self.e.run_id = 99 + self.e.attempt = 1 + self.e.mode = False + self.e.release = False + self.e.artifacts_required = False + self.e.event_name = "pull_request" + self.e.event = {"pull_request": {"head": {"ref": "feature", "repo": {"full_name": "owner/repo"}}}} + self.e.cache = {} + self.run = {"id": 12, "run_attempt": 1, "path": ".github/workflows/check.yml", "event": "push", + "head_branch": "main", "head_sha": "a" * 40, "head_repository": {"full_name": "owner/repo"}, + "created_at": reuse.now().isoformat(), "status": "completed", "conclusion": "success", "html_url": "https://example.invalid/12"} + self.keys = reuse.fingerprints(self.e.files, False, False) + self.receipt = {"version": reuse.VERSION, "repository": "owner/repo", "run_id": 12, "attempt": 1, + "revision": "a" * 40, "runner": False, "image": False, "native_run_id": 12, + "passed": {"backend": {"key": self.keys["backend"], "verified_at": reuse.now().isoformat()}}} + + def read(self, receipt=None, run=None, files=None): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as z: + z.writestr("evidence.json", json.dumps(self.receipt if receipt is None else receipt)) + artifacts = [{"name": "ci-evidence-1", "expired": False, "size_in_bytes": 100, "id": 1}] + with patch.object(self.e, "artifacts", return_value=artifacts), patch.object(reuse, "api", return_value=archive.getvalue()), patch.object(reuse, "tree", return_value=self.e.files if files is None else files): + return self.e.receipt(self.run if run is None else run) + + def test_success_and_producer_are_verified(self): + self.assertEqual(self.read(), self.receipt) + for fields in ({"conclusion": "failure"}, {"conclusion": "cancelled"}, {"status": "in_progress"}, + {"path": ".github/workflows/untrusted.yml"}, {"head_repository": {"full_name": "fork/repo"}}, + {"id": 99}, {"created_at": (reuse.now() - timedelta(days=2)).isoformat()}, + {"run_attempt": 2}, {"head_sha": "b" * 40}): + with self.subTest(fields=fields), self.assertRaises(ValueError): + self.read(run=self.run | fields) + with self.assertRaises(ValueError): + self.read(files=self.e.files | {"scripts/ci_reuse.py": "forged producer"}) + + def test_forged_stale_and_wrong_mode_receipts_are_rejected(self): + for fields in ({"run_id": 13}, {"attempt": 2}, {"repository": "fork/repo"}, {"runner": True}, + {"image": "false"}, {"passed": {"backend": {"key": "forged", "verified_at": reuse.now().isoformat()}}}, + {"passed": {"backend": {"key": self.keys["backend"], "verified_at": (reuse.now() - timedelta(days=2)).isoformat()}}}): + with self.subTest(fields=fields), self.assertRaises(ValueError): + self.read(receipt=self.receipt | fields) + + def test_pr_scope_and_release_trust(self): + pr = self.run | {"event": "pull_request", "head_branch": "feature"} + self.assertTrue(self.e.eligible(pr)) + self.assertFalse(self.e.eligible(pr | {"head_branch": "another-pr"})) + self.e.release = True + self.assertFalse(self.e.eligible(pr)) + self.assertTrue(self.e.eligible(self.run)) + + def test_pr_merge_parent_and_main_tree_are_verified(self): + pr = self.run | {"event": "pull_request", "head_branch": "feature", "head_sha": "b" * 40} + with patch.object(ci, "git", return_value=("c"*40 + " " + "b"*40).encode()): + self.assertEqual(self.read(run=pr), self.receipt) + with patch.object(ci, "git", return_value=b"wrong parents"), self.assertRaises(ValueError): + self.read(run=pr) + self.e.event_name = "push" + self.e.event = {"ref": "refs/heads/main"} + with patch.object(ci, "git", return_value=("c"*40 + " " + "b"*40).encode()), self.assertRaises(ValueError): + self.read(run=pr, files=self.e.files | {"docs/new.md": "changed"}) + + def test_native_artifacts_require_exact_revision_and_trusted_source(self): + artifacts = [{"name": name, "expired": False} for name in reuse.NATIVE_ARTIFACTS] + with patch.object(reuse, "api", return_value=self.run), patch.object(self.e, "artifacts", return_value=artifacts): + self.assertTrue(self.e.native_available(self.receipt)) + self.assertFalse(self.e.native_available(self.receipt | {"revision": "b" * 40})) + with patch.object(reuse, "api", return_value=self.run), patch.object(self.e, "artifacts", return_value=artifacts[:-1]): + self.assertFalse(self.e.native_available(self.receipt)) + with patch.object(reuse, "api", return_value=self.run | {"event": "pull_request"}): + self.assertFalse(self.e.native_available(self.receipt)) + + def test_lookup_failure_and_force_execute_checks(self): + plan = ci.select(["services/core/code.go"]) + with patch.object(self.e, "candidates", side_effect=ValueError("unavailable")): + self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) + with patch.dict(os.environ, {"CI_FORCE": "true"}), patch.object(self.e, "candidates") as candidates: + self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) + candidates.assert_not_called() + + def test_matching_result_reuses_only_selected_jobs_and_gate_rechecks_it(self): + plan = ci.select(["services/core/code.go"]) + with patch.object(self.e, "candidates", return_value=[self.run]), patch.object(self.e, "receipt", return_value=self.receipt): + result = self.e.plan(plan) + self.assertEqual(result["execute"], ["hygiene", "api"]) + self.assertEqual(set(result["reused"]), {"backend"}) + needs = {j: {"result": "success" if j in result["execute"] else "skipped"} for j in ci.JOBS} + needs["plan"] = {"result": "success"} + ci.check_results(result, needs) + with patch.object(self.e, "load", return_value=self.receipt): + self.e.verify(result) + with patch.object(self.e, "load", return_value=self.receipt | {"passed": {}}), self.assertRaises(ValueError): + self.e.verify(result) + for bad in (result | {"execute": ["hygiene"]}, result | {"reused": {}}, result | {"execute": plan["jobs"]}): + with self.assertRaises(ValueError): + ci.check_results(bad, needs) + with self.assertRaises(ValueError): + ci.check_results(result, needs | {"api": {"result": "skipped"}}) + + def test_reuse_does_not_renew_expiry(self): + verified = (reuse.now() - timedelta(hours=20)).isoformat() + self.receipt["passed"]["backend"]["verified_at"] = verified + plan = {"keys": self.keys, "execute": ["hygiene"], "reused": {"backend": {"run_id": 12}}, "image": False} + with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"RUNNER_TEMP": tmp, "GITHUB_OUTPUT": tmp + "/output"}), patch.object(self.e, "load", return_value=self.receipt): + self.e.record(plan) + recorded = json.loads((Path(tmp) / "ci-evidence/evidence.json").read_text()) + self.assertEqual(recorded["passed"]["backend"]["verified_at"], verified) + + def test_release_waits_for_exact_main_run_and_failure_falls_back(self): + self.e.release = True + pending = self.run | {"status": "in_progress", "conclusion": None} + with patch.object(reuse, "api", side_effect=[{"workflow_runs": [pending]}, self.run]), patch.object(reuse.time, "sleep") as sleep: + self.assertEqual(self.e.candidates(), [self.run]) + sleep.assert_called_once_with(20) + failed = self.run | {"conclusion": "failure"} + with patch.object(reuse, "api", side_effect=[{"workflow_runs": [pending]}, failed]), patch.object(reuse.time, "sleep"): + self.assertEqual(self.e.plan(ci.full("release"))["execute"], list(ci.JOBS)) + + +class WorkflowTests(unittest.TestCase): + def test_site_has_one_trigger_owner_and_build_owner(self): + root = Path(__file__).resolve().parents[1] + check = (root / ".github/workflows/check.yml").read_text() + site = (root / ".github/workflows/website.yml").read_text() + self.assertIn("uses: ./.github/workflows/website.yml", check) + self.assertNotIn("make check-website", check) + self.assertIn(" workflow_call:", site) + self.assertNotIn(" pull_request:", site) + self.assertNotIn(" push:", site) + + def test_browser_suite_can_follow_a_reused_unit_result(self): + root = Path(__file__).resolve().parents[1] + body = (root / ".github/workflows/check.yml").read_text().split(" web-acceptance:\n")[1].split(" strategy:")[0] + self.assertIn("always() && !cancelled()", body) + self.assertIn("reused.web != null", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/README.md b/website/README.md index 3aed20ce..bd7ade6d 100644 --- a/website/README.md +++ b/website/README.md @@ -23,4 +23,4 @@ The landing page lives in `.vitepress/theme/`. `landing-content.ts` holds its En ## Publish -`make check-website` builds and tests the site. core-check runs it for changes under `website/` and to Node dependencies; `.github/workflows/website.yml` runs it for documentation-only pull requests and deploys `main` to GitHub Pages. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. The build reads the Pages base path, so the site works both at `https://.github.io//` and on a custom domain set under **Settings → Pages → Custom domain**. +`make check-website` builds and tests the site. `core-check` selects the reusable `.github/workflows/website.yml` for website, published documentation and dependency changes. That workflow builds and tests once; on main pushes it deploys the same artifact to GitHub Pages. The [CI planner](../docs/maintainers.md#continuous-integration) owns the input map. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. The build reads the Pages base path, so the site works both at `https://.github.io//` and on a custom domain set under **Settings → Pages → Custom domain**. From 7ce254fee8a19e000d79ee2ea678fec1cde933da Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 18:59:41 +0800 Subject: [PATCH 2/4] ci: retain coverage across coalesced main pushes --- .github/workflows/release.yml | 1 + docs/maintainers.md | 4 ++-- scripts/ci_reuse.py | 33 ++++++++++++++++++++++++++------- scripts/ci_reuse_test.py | 25 +++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 738191ee..5633a6ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,7 @@ jobs: permissions: contents: write packages: write + actions: read steps: - uses: actions/checkout@v7 with: diff --git a/docs/maintainers.md b/docs/maintainers.md index c00269d7..5782f069 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -158,7 +158,7 @@ With `draft_release=true` the result is an unpublished `build-` draft Every PR and main push runs `core-check` and reports the required status `check`. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. -The planner compares the PR event's tested merge commit with its verified first parent. Main pushes use the verified event `before`/`after` range with the same selection rules. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls select every group. Selected groups are then partitioned into execution and verified reuse. +The planner compares the PR event's tested merge commit with its verified first parent. Main pushes validate the event `before`/`after` range and include changes since the latest verified successful main run. This retains coverage when GitHub replaces a pending run; without an eligible main baseline, the full plan executes. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls select every group. Selected groups are then partitioned into execution and verified reuse. | Group | Checks and consumers | | --- | --- | @@ -181,7 +181,7 @@ Compose template and Compose test changes select both `distribution` fixtures an Go module and workspace inputs select backend, API (including the container), native and distribution checks. Each Node module owns its manifest and lockfile. Website dependencies select website checks; Web dependencies select Web and browser checks; example dependencies select example checks; shared TypeScript client dependencies select Web, browser and example checks; Claude adapter dependencies select Harness, native and distribution checks. Shared package-manager configuration selects all Node consumers. The root TypeScript configuration selects Web and example checks; the adapter TypeScript configuration selects Harness and native checks. Each selected set includes hygiene. Mixed changes accumulate their consumers, and every job reads the same plan instead of maintaining its own path list. For example, a notification-only PR skips database, browser and native jobs, while a notification plus Core change adds backend and API checks. -Ordinary Markdown and documentation-site configuration run hygiene only, including documentation inside source directories. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. +Published Markdown and assets under `docs/` and `contracts/`, plus `docs.json`, select hygiene and website. Other ordinary Markdown, including documentation inside source directories, selects hygiene only. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. The final `check` runs even when planning or a dependency fails. It requires a valid plan, every executing job to succeed, every reused job to have independently verified evidence, and every other job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API, native and website reusable workflows are direct dependencies. A newer run on the same PR cancels its predecessor. Main runs finish before the latest pending main run starts, preserving successful evidence and Pages deployment. Release checks run at their requested immutable ref; the distribution build waits for the gate and consumes its native artifact source. diff --git a/scripts/ci_reuse.py b/scripts/ci_reuse.py index 8756ec7d..6e356290 100644 --- a/scripts/ci_reuse.py +++ b/scripts/ci_reuse.py @@ -187,16 +187,35 @@ def candidates(self): runs = [run] + [r for r in runs if r["id"] != run["id"]] return runs + def main_plan(self, plan, runs): + if self.event_name != "push" or self.event.get("ref") != "refs/heads/main" or os.environ.get("REQUESTED_REF"): + return plan + # GitHub replaces pending runs even with cancel-in-progress=false. Use + # the last verified main success so intermediate pushes remain covered. + for run in runs: + if run.get("event") != "push" or run.get("head_branch") != "main": + continue + try: + receipt = self.receipt(run) + ci.git("merge-base", "--is-ancestor", receipt["revision"], self.revision) + pending = ci.select(ci.changed_paths(receipt["revision"], self.revision)) + return dict(plan, jobs=[j for j in ci.JOBS if j in set(plan["jobs"]) | set(pending["jobs"])], + image=plan["image"] or pending["image"], + reasons=plan["reasons"] + [f"Include changes since successful main run {run['id']}"] + pending["reasons"]) + except (subprocess.SubprocessError, ValueError, KeyError, TypeError, zipfile.BadZipFile): + continue + return ci.full("No verified main baseline; execute the full plan") + def plan(self, plan): + runs = [] + if os.environ.get("CI_FORCE") != "true": + try: + runs = self.candidates() + except (subprocess.SubprocessError, ValueError, KeyError): + print("Evidence lookup unavailable; executing selected checks.") + plan = self.main_plan(plan, runs) keys = fingerprints(self.files, self.mode, plan["image"]) result = dict(plan, execute=list(plan["jobs"]), reused={}, keys=keys) - if os.environ.get("CI_FORCE") == "true": - return result - try: - runs = self.candidates() - except (subprocess.SubprocessError, ValueError, KeyError): - print("Evidence lookup unavailable; executing selected checks.") - return result for run in runs: if not self.eligible(run) or run.get("conclusion") != "success": continue diff --git a/scripts/ci_reuse_test.py b/scripts/ci_reuse_test.py index 425b05f7..162c4131 100644 --- a/scripts/ci_reuse_test.py +++ b/scripts/ci_reuse_test.py @@ -158,6 +158,25 @@ def test_matching_result_reuses_only_selected_jobs_and_gate_rechecks_it(self): with self.assertRaises(ValueError): ci.check_results(result, needs | {"api": {"result": "skipped"}}) + def test_three_main_pushes_retain_work_from_replaced_pending_run(self): + # A completed; B changed docs but was replaced while pending by C, + # whose event diff only contains backend code. + self.e.event_name = "push" + self.e.event = {"ref": "refs/heads/main"} + plan = ci.select(["services/core/code.go"]) + with patch.object(self.e, "receipt", return_value=self.receipt), patch.object(ci, "git", return_value=b""), patch.object(ci, "changed_paths", return_value=["docs/guide.md", "services/core/code.go"]) as changes: + selected = self.e.main_plan(plan, [self.run]) + self.assertEqual(set(selected["jobs"]), {"hygiene", "backend", "api", "website"}) + changes.assert_called_once_with(self.receipt["revision"], self.e.revision) + + def test_main_without_verified_ancestor_runs_full(self): + self.e.event_name = "push" + self.e.event = {"ref": "refs/heads/main"} + plan = ci.select(["README.md"]) + self.assertEqual(self.e.main_plan(plan, [])["jobs"], list(ci.JOBS)) + with patch.object(self.e, "receipt", side_effect=ValueError("invalid")): + self.assertEqual(self.e.main_plan(plan, [self.run])["jobs"], list(ci.JOBS)) + def test_reuse_does_not_renew_expiry(self): verified = (reuse.now() - timedelta(hours=20)).isoformat() self.receipt["passed"]["backend"]["verified_at"] = verified @@ -189,6 +208,12 @@ def test_site_has_one_trigger_owner_and_build_owner(self): self.assertNotIn(" pull_request:", site) self.assertNotIn(" push:", site) + def test_release_cross_run_artifact_download_has_read_permission(self): + root = Path(__file__).resolve().parents[1] + body = (root / ".github/workflows/release.yml").read_text().split(" build:\n")[1] + self.assertIn(" actions: read\n", body.split(" steps:")[0]) + self.assertIn("run-id: ${{ needs.check.outputs.native-run-id }}", body) + def test_browser_suite_can_follow_a_reused_unit_result(self): root = Path(__file__).resolve().parents[1] body = (root / ".github/workflows/check.yml").read_text().split(" web-acceptance:\n")[1].split(" strategy:")[0] From cb995ee30c350e919136276ee5bc53911bdec6df Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 19:03:39 +0800 Subject: [PATCH 3/4] ci: execute checks when stored evidence is malformed --- scripts/ci_reuse.py | 16 ++++++++++++---- scripts/ci_reuse_test.py | 10 +++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/ci_reuse.py b/scripts/ci_reuse.py index 6e356290..acf121f7 100644 --- a/scripts/ci_reuse.py +++ b/scripts/ci_reuse.py @@ -57,7 +57,12 @@ def now(): def fresh(timestamp): - age = now() - datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + if not isinstance(timestamp, str): + raise ValueError("Evidence timestamp must be a string") + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("Evidence timestamp must have a timezone") + age = now() - parsed return timedelta(0) <= age < MAX_AGE @@ -84,7 +89,7 @@ def __init__(self): def eligible(self, run): if (run["id"] == self.run_id or run.get("path") != ".github/workflows/check.yml" - or run.get("head_repository", {}).get("full_name") != self.repository): + or (run.get("head_repository") or {}).get("full_name") != self.repository): return False if run.get("event") == "push" and run.get("head_branch") == "main": return True @@ -137,8 +142,11 @@ def receipt(self, run): if self.event_name == "push" and files != self.files: raise ValueError("Main can only promote the identical PR tree") expected = fingerprints(files, self.mode, receipt["image"]) + if not isinstance(receipt["passed"], dict): + raise ValueError("Evidence checks must be an object") for job, item in receipt["passed"].items(): - if job not in expected or item["key"] != expected[job] or not fresh(item["verified_at"]): + if (not isinstance(item, dict) or job not in expected + or item.get("key") != expected[job] or not fresh(item.get("verified_at"))): raise ValueError("Invalid or expired check evidence") return receipt @@ -160,7 +168,7 @@ def native_available(self, receipt): if (run.get("event") != "push" or run.get("head_branch") != "main" or run.get("head_sha") != self.revision or run.get("conclusion") != "success" or run.get("path") != ".github/workflows/check.yml" - or run.get("head_repository", {}).get("full_name") != self.repository): + or (run.get("head_repository") or {}).get("full_name") != self.repository): return False names = {a["name"] for a in self.artifacts(run_id) if not a["expired"]} return NATIVE_ARTIFACTS <= names diff --git a/scripts/ci_reuse_test.py b/scripts/ci_reuse_test.py index 162c4131..c87568d6 100644 --- a/scripts/ci_reuse_test.py +++ b/scripts/ci_reuse_test.py @@ -82,7 +82,7 @@ def read(self, receipt=None, run=None, files=None): z.writestr("evidence.json", json.dumps(self.receipt if receipt is None else receipt)) artifacts = [{"name": "ci-evidence-1", "expired": False, "size_in_bytes": 100, "id": 1}] with patch.object(self.e, "artifacts", return_value=artifacts), patch.object(reuse, "api", return_value=archive.getvalue()), patch.object(reuse, "tree", return_value=self.e.files if files is None else files): - return self.e.receipt(self.run if run is None else run) + return reuse.Evidence.receipt(self.e, self.run if run is None else run) def test_success_and_producer_are_verified(self): self.assertEqual(self.read(), self.receipt) @@ -102,6 +102,14 @@ def test_forged_stale_and_wrong_mode_receipts_are_rejected(self): with self.subTest(fields=fields), self.assertRaises(ValueError): self.read(receipt=self.receipt | fields) + def test_malformed_artifacts_fall_back_to_execution(self): + plan = ci.select(["services/core/code.go"]) + for passed in ([], None, {"backend": []}, {"backend": {"key": self.keys["backend"], "verified_at": 42}}, + {"backend": {"key": self.keys["backend"], "verified_at": "2026-10-01T10:00:00"}}): + malformed = self.receipt | {"passed": passed} + with self.subTest(passed=passed), patch.object(self.e, "candidates", return_value=[self.run]), patch.object(self.e, "receipt", side_effect=lambda run: self.read(receipt=malformed)): + self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) + def test_pr_scope_and_release_trust(self): pr = self.run | {"event": "pull_request", "head_branch": "feature"} self.assertTrue(self.e.eligible(pr)) From 0bb1d4f0ea2f2bc77969aa31a200800ae1340b75 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 19:19:03 +0800 Subject: [PATCH 4/4] ci: separate PR validation from main deployment --- .github/workflows/check.yml | 67 ++------ .github/workflows/ci-review.yml | 25 ++- .github/workflows/release.yml | 15 -- .github/workflows/website.yml | 34 ++-- docs/maintainers.md | 30 ++-- scripts/ci_plan.py | 46 +----- scripts/ci_plan_test.py | 100 +++--------- scripts/ci_reuse.py | 269 -------------------------------- scripts/ci_reuse_test.py | 233 --------------------------- website/README.md | 2 +- 10 files changed, 92 insertions(+), 729 deletions(-) delete mode 100644 scripts/ci_reuse.py delete mode 100644 scripts/ci_reuse_test.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 01734341..6e99127f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,38 +11,20 @@ on: description: Retain native installers for a release build type: boolean default: false - force: - description: Execute checks even when matching success evidence exists - type: boolean - default: false - outputs: - native-run-id: - description: Run containing the verified native installers - value: ${{ jobs.check.outputs.native-run-id }} workflow_dispatch: - inputs: - force: - description: Execute the full gate without reusing results - type: boolean - default: true - push: - branches: [main] pull_request: permissions: contents: read - actions: read - pages: read concurrency: group: core-check-${{ github.workflow }}-${{ inputs.ref && github.run_id || github.ref }} - # Finish main runs so their evidence and Pages deployment remain usable. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true jobs: plan: runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 15 + timeout-minutes: 5 outputs: plan: ${{ steps.select.outputs.plan }} jobs: ${{ steps.select.outputs.jobs }} @@ -51,17 +33,11 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} - fetch-depth: 0 - persist-credentials: false + fetch-depth: 2 - name: Select checks from the integrated change id: select env: REQUESTED_REF: ${{ inputs.ref }} - GH_TOKEN: ${{ github.token }} - CI_REUSE: 'true' - CI_FORCE: ${{ inputs.force || false }} - CI_NATIVE_ARTIFACTS: ${{ inputs.native-artifacts || false }} - OAC_USE_GITHUB_RUNNERS: ${{ vars.OAC_USE_GITHUB_RUNNERS }} run: python3 scripts/ci_plan.py plan hygiene: @@ -293,18 +269,22 @@ jobs: website: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'website') - uses: ./.github/workflows/website.yml - with: - ref: ${{ inputs.ref || github.sha }} - publish: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !inputs.ref }} - permissions: - contents: read - pages: write - id-token: write + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + - uses: ./.github/actions/node + with: + lockfiles: | + website/pnpm-lock.yaml + - name: Build and test the website + run: make check-website web-acceptance: needs: [plan, web] - if: always() && !cancelled() && needs.plan.result == 'success' && (needs.web.result == 'success' || fromJSON(needs.plan.outputs.plan).reused.web != null) && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web-acceptance') + if: needs.web.result == 'success' && needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web-acceptance') strategy: fail-fast: false matrix: @@ -352,7 +332,7 @@ jobs: uses: ./.github/workflows/native.yml with: ref: ${{ inputs.ref || github.sha }} - upload-artifacts: ${{ inputs.native-artifacts || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} + upload-artifacts: ${{ inputs.native-artifacts || false }} lint: needs: plan @@ -363,8 +343,6 @@ jobs: # Always report the required check, even when planning or a dependency fails. check: - outputs: - native-run-id: ${{ steps.gate.outputs.native-run-id }} if: always() needs: [plan, hygiene, distribution, compose, backend, harness, example, web, web-acceptance, website, api, native, lint] runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} @@ -374,18 +352,7 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - name: Require every selected check to succeed - id: gate env: - GH_TOKEN: ${{ github.token }} - CI_NATIVE_ARTIFACTS: ${{ inputs.native-artifacts || false }} - OAC_USE_GITHUB_RUNNERS: ${{ vars.OAC_USE_GITHUB_RUNNERS }} PLAN: ${{ needs.plan.outputs.plan }} RESULTS: ${{ toJSON(needs) }} run: python3 scripts/ci_plan.py gate - - name: Retain verified check evidence - uses: actions/upload-artifact@v6 - with: - name: ci-evidence-${{ github.run_attempt }} - path: ${{ runner.temp }}/ci-evidence/evidence.json - retention-days: 2 - if-no-files-found: error diff --git a/.github/workflows/ci-review.yml b/.github/workflows/ci-review.yml index 2764bbb0..bb760f38 100644 --- a/.github/workflows/ci-review.yml +++ b/.github/workflows/ci-review.yml @@ -1,21 +1,22 @@ # Actions secrets: MINIMAX_API_KEY, FEISHU_WEBHOOK_URL; optional FEISHU_WEBHOOK_SECRET. -# workflow_run activates after this file reaches the default branch. +# Review the merged source and existing PR checks without running CI again. name: CI review and Feishu notification on: - workflow_run: - workflows: [core-check] + pull_request_target: branches: [main] - types: [completed] + types: [closed] permissions: contents: read actions: read + checks: read + statuses: read pull-requests: read jobs: review: - if: github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' + if: github.event.pull_request.merged == true runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 30 env: @@ -26,7 +27,7 @@ jobs: # The triggering revision is already on main, so its rules and code are trusted. - uses: actions/checkout@v7 with: - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 2 persist-credentials: false - name: Review and send Feishu card with Claude Code @@ -54,17 +55,15 @@ jobs: allowed_non_write_users: '*' prompt: | 你是 CI 的负责人,负责审核 CI 结果并给出结论。 - 仓库 ${{ github.repository }} 的 main 分支刚跑完一次 core-check CI: - - 运行 ID:${{ github.event.workflow_run.id }}(第 ${{ github.event.workflow_run.run_attempt }} 次尝试) - - 结论:${{ github.event.workflow_run.conclusion }} - - 运行链接:${{ github.event.workflow_run.html_url }} - - commit:${{ github.event.workflow_run.head_sha }}(已 checkout 到当前目录) + 仓库 ${{ github.repository }} 的 PR #${{ github.event.pull_request.number }} 已合入 main: + - PR:https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }} + - 合并 commit:${{ github.event.pull_request.merge_commit_sha }}(已 checkout 到当前目录) + + 用 gh pr checks ${{ github.event.pull_request.number }} --repo ${{ github.repository }} 查询这个 PR 已有的检查结果,必要时读取对应 Actions 日志。不要触发新的 CI,也不要把合并本身当作检查通过的证据;管理员可能绕过门禁。检查失败、缺失或尚未完成时如实报告。 任务:给飞书群发一张中文卡片(Card 2.0)总结这次 CI,尽量 10 轮以内给出结论,工具调用尽可能的并行。 环境里有 gh(已登录,GH_TOKEN)、git、node 和完整的仓库代码。 - 如果这个 commit 不是某个 PR 合入 main 产生的(例如直接 push),不发卡片,直接结束。 - 卡片要让手机上的读者快速看懂,如果一切正常,表达的尽可能简单: 1. 哪个 PR(github id + PR 标题 + 链接) 2. 改了什么(实际行为变化,1–3 条) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5633a6ac..e67a7367 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,6 @@ on: description: Full source commit SHA to build required: true type: string - force-checks: - description: Execute the full gate without reusing successful checks - default: false - type: boolean offline: description: Also create the full offline archive default: true @@ -24,8 +20,6 @@ on: permissions: contents: read - actions: read - pages: read concurrency: group: core-release-${{ github.event_name == 'push' && github.ref || inputs.ref }} @@ -33,16 +27,10 @@ concurrency: jobs: check: - permissions: - contents: read - actions: read - pages: write - id-token: write uses: ./.github/workflows/check.yml with: ref: ${{ inputs.ref || github.sha }} native-artifacts: true - force: ${{ inputs.force-checks || false }} build: needs: check @@ -51,7 +39,6 @@ jobs: permissions: contents: write packages: write - actions: read steps: - uses: actions/checkout@v7 with: @@ -122,8 +109,6 @@ jobs: bash scripts/prepare-release-runtimes.sh - uses: actions/download-artifact@v6 with: - github-token: ${{ github.token }} - run-id: ${{ needs.check.outputs.native-run-id }} pattern: oac-native-installer-* merge-multiple: true path: ${{ runner.temp }}/native-artifacts diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 3c24c1a6..fb0abbad 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -1,21 +1,29 @@ -# Builds and tests the website once for core-check; main also deploys that artifact. +# Publishes the website (landing page and documentation) to GitHub Pages from +# main. Pull request validation belongs to core-check. The website reads the +# existing docs/, contracts/ and docs.json in place; see website/README.md. name: website on: - workflow_call: - inputs: - ref: - type: string - required: true - publish: - type: boolean - default: false + push: + branches: [main] + paths: + - website/** + - docs/** + - contracts/** + - docs.json + - package.json + - pnpm-workspace.yaml + - .npmrc + - Makefile + - .github/actions/node/** + - .github/workflows/website.yml + workflow_dispatch: permissions: contents: read concurrency: - group: website-${{ github.run_id }} + group: website-${{ github.ref }} # Never cancel a run that may be deploying main. cancel-in-progress: false @@ -28,12 +36,10 @@ jobs: # configure-pages reads the site's base path. pages: read env: - PUBLISH: ${{ inputs.publish }} + PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v7 with: - ref: ${{ inputs.ref }} - persist-credentials: false # Full history gives each page its last-updated date. fetch-depth: 0 - uses: ./.github/actions/node @@ -55,7 +61,7 @@ jobs: deploy: needs: build - if: inputs.publish + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: diff --git a/docs/maintainers.md b/docs/maintainers.md index 5782f069..3eef76a3 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -124,7 +124,7 @@ git push origin v1.2.3 Tags use `vMAJOR.MINOR.PATCH`, optionally with a prerelease suffix such as `-rc.1` and build metadata such as `+build.1`. A prerelease suffix creates a GitHub prerelease. Pushing the tag is the release decision. Automated checks establish build and test results, not real-model qualification: assess live execution evidence before you push the tag. Model credentials and private certificate authorities never enter CI or release inputs, including acceptance images that contain them. -The workflow requires the full check plan on the tagged commit, including official-client and image acceptance and native packaging. It waits up to ten minutes for an existing main check of that exact commit, verifies reusable main evidence, and executes the remaining checks according to [result reuse](#check-result-reuse). After checks succeed, one `build` job on the [release runner](#ci-runners-and-free-allowance) prepares the pinned Runtime inputs, downloads native installers from the verified source run, builds the distribution and publishes directly from its local files. This combined job has `contents: write` and `packages: write`; checkout does not persist credentials. It retains an uncompressed Actions artifact before publication for recovery, without downloading that artifact again during normal publication. +The workflow runs `check` on the tagged commit, including the full local gate, official-client and image acceptance, and the native matrix with its packaging artifacts enabled. After checks succeed, one `build` job on GitHub-hosted `ubuntu-22.04` prepares the pinned Runtime inputs, reuses the native installers, builds the distribution and publishes directly from its local files. This combined job has `contents: write` and `packages: write`; checkout does not persist credentials. It retains an uncompressed Actions artifact before publication for recovery, without downloading that artifact again during normal publication. Distribution and Runtime archives use `pigz` level 6 with at most four compression workers and no filename or timestamp in the gzip header. The publisher verifies archive and native installer checksums, resolves the repository identity, refuses an existing Release or draft for the tag and creates one draft with a fixed ID. Up to four assets upload concurrently, largest first, without retries. After confirming the complete remote inventory, the publisher validates all image archives and existing registry tags before pushing up to four images concurrently. Each image config and registry manifest is verified; any error leaves the Release unpublished. In-flight transfers finish before a failed operation returns. The publisher rechecks the version tag before publishing the draft by its ID. @@ -138,7 +138,7 @@ GHCR and GitHub Releases do not share a transaction. A failed release may leave `install.sh` resolves the latest stable release once, or the release named by `--version`, verifies the control archive and runs that bundle's installer; the [installation guide](./getting-started/install.md#install) covers its use. -Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older dependency cache only seeds downloads and compilation; check-result reuse requires separate verified evidence. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. +Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older cache only seeds downloads and compilation; every check still runs. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. Never move a release tag or overwrite published assets. If publication fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft only after inspection, download the original `core-release-` Actions artifact with `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY`, and use a checkout of that exact source revision to run `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`. Set `GH_REPO`, `GH_TOKEN`, `RELEASE_REVISION`, `RELEASE_TAG` and `RELEASE_MODE` to the original publication inputs and sign Docker into GHCR for version publication. The script revalidates the assets and refuses existing releases. Do not rerun the combined build job or recreate the tag to recover a failed upload. @@ -156,9 +156,9 @@ With `draft_release=true` the result is an unpublished `build-` draft ## Continuous integration -Every PR and main push runs `core-check` and reports the required status `check`. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. +Every PR runs `core-check` and reports the required status `check`. Main uses GitHub branch protection requiring this check and an up-to-date branch before merging, so merging does not start another copy of the test suite. Changes must enter through checked PRs; an administrator bypass does not establish CI success. Main pushes publish the website when its inputs change. Version tags and manual release builds run the full release gate at their exact source commit. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. -The planner compares the PR event's tested merge commit with its verified first parent. Main pushes validate the event `before`/`after` range and include changes since the latest verified successful main run. This retains coverage when GitHub replaces a pending run; without an eligible main baseline, the full plan executes. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls select every group. Selected groups are then partitioned into execution and verified reuse. +The planner compares the PR event's tested merge commit with its verified first parent. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls always select every group. | Group | Checks and consumers | | --- | --- | @@ -169,35 +169,27 @@ The planner compares the PR event's tested merge commit with its verified first | `harness` | Claude SDK tests and packaging, MiniMax companion scripts | | `example` | Optional application typecheck, tests, build and isolated browser acceptance | | `web` | TypeScript, Web/client tests and Web build | -| `website` | Website build, published documentation links and output checks; main deploys the same artifact to Pages | +| `website` | Website build and output checks for website, published documentation and dependency changes | | `web-acceptance` | Full Web browser suite in four isolated shards after Web unit/build success; each keeps one worker | | `api` | Reusable official-client acceptance against standalone commands and migrations; image acceptance when image/build/helper inputs change, and in every full gate | | `native` | Reusable Linux, macOS and Windows builds, filesystem/process/Harness checks and native installation; all three platforms can run concurrently | | `lint` | Reusable actionlint check, including local composite actions | -Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. `.github/actionlint.yaml` selects lint. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. +`.github/actionlint.yaml` selects hygiene and lint. Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. This checks the declared release images and generic Compose behavior; it does not run a Dokploy/Coolify instance or execute a model. Go module and workspace inputs select backend, API (including the container), native and distribution checks. Each Node module owns its manifest and lockfile. Website dependencies select website checks; Web dependencies select Web and browser checks; example dependencies select example checks; shared TypeScript client dependencies select Web, browser and example checks; Claude adapter dependencies select Harness, native and distribution checks. Shared package-manager configuration selects all Node consumers. The root TypeScript configuration selects Web and example checks; the adapter TypeScript configuration selects Harness and native checks. Each selected set includes hygiene. Mixed changes accumulate their consumers, and every job reads the same plan instead of maintaining its own path list. For example, a notification-only PR skips database, browser and native jobs, while a notification plus Core change adds backend and API checks. -Published Markdown and assets under `docs/` and `contracts/`, plus `docs.json`, select hygiene and website. Other ordinary Markdown, including documentation inside source directories, selects hygiene only. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. +Published documentation and assets under `docs/` and `contracts/`, plus `docs.json`, select hygiene and website. Other ordinary Markdown, including documentation inside source directories, selects hygiene only. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. -The final `check` runs even when planning or a dependency fails. It requires a valid plan, every executing job to succeed, every reused job to have independently verified evidence, and every other job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API, native and website reusable workflows are direct dependencies. A newer run on the same PR cancels its predecessor. Main runs finish before the latest pending main run starts, preserving successful evidence and Pages deployment. Release checks run at their requested immutable ref; the distribution build waits for the gate and consumes its native artifact source. +The final `check` runs even when planning or a dependency fails. It requires a successful, valid plan, every selected job to be successful, and every unselected job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API/native reusable workflows are direct dependencies of this gate. A newer run on the same PR cancels its predecessor. Release checks run at their requested immutable ref; native packaging executes once inside those checks, and the distribution build waits for them. -### Check result reuse +Use **Actions → core-check → Run workflow** for a manual full check. For a transient failure, use GitHub's **Re-run failed jobs** so successful jobs remain completed. PR updates cancel the superseded run through Actions concurrency. Build and dependency caches speed execution; they do not stand in for successful tests. Native release installers are passed between jobs using Actions artifacts, within the same release workflow. -`scripts/ci_reuse.py` fingerprints each task's tracked inputs using the planner's input-to-check map, including file paths, modes and Git object IDs. Task keys include the GitHub/Blacksmith runner switch and API container-check mode. Shared build files, toolchain declarations and CI implementation changes invalidate their consumers. Documentation and site inputs select the website through this same map; `website.yml` has no separate push or PR trigger. A documentation follow-up can therefore reuse unchanged backend checks while still building the site. +The `CI review and Feishu notification` workflow runs once after a PR merges into main. It checks out the merged commit, reads that PR's existing checks and logs, and reports their actual status. It does not trigger another test run. Closing an unmerged PR does not invoke the review. The workflow uses `pull_request_target` only for the merged event and never checks out an unmerged PR head with notification credentials. -The planner examines up to 20 recent `core-check` runs. Evidence must come from a completed successful run in this repository and be less than 24 hours old. It verifies the artifact's run, attempt, repository, tested commit, producer workflow and planner code, then recomputes input fingerprints from Git. PRs may reuse main evidence or evidence from their own repository branch; fork PR evidence is not consumed. A main push may promote PR evidence only when the complete tested merge tree equals its own tree. Release callers accept main evidence only. Reuse retains the original verification timestamp, so repeated promotions never extend its lifetime. Hygiene, Compose startup and website checks always execute when selected because they check repository integrity, external state or Git history and Pages settings. - -The final gate revalidates each reuse source and writes `ci-evidence-` with executed and verified results. The plan summary lists executed tasks and source run IDs for reused tasks. Evidence artifacts are retained for two days; native archives are retained for seven. Missing, malformed, expired or inaccessible evidence makes planning execute the affected checks. Evidence becoming invalid after planning fails the gate. Dependency caches are never accepted as successful check evidence. - -Native test results may be reused across equal task inputs in PRs. Main and release packaging additionally require installers from a successful main run at the exact source commit, with all three platform artifacts still available; versioned native binaries are never substituted from another commit. Main retains these installers when native checks execute. Website build and deployment stay together in the reusable website workflow so deployment consumes its own build with the correct Pages base path and Git history. - -Use **Actions → core-check → Run workflow** with `force=true` to execute the full gate without result reuse. Manual releases expose `force-checks` for the same purpose. Mutable runner images, package registries and other external inputs are bounded by the evidence lifetime rather than assumed immutable. The planner retains conservative full selection for changes to shared orchestration and build inputs. - -Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded for main checks, explicit manual packaging and releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. +Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the existing module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The three native platforms run independently; parallel execution reduces elapsed time without reducing total machine time. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index cd754869..e2747ce5 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -4,7 +4,6 @@ import argparse import json import os -import re from pathlib import Path, PurePosixPath import subprocess @@ -18,13 +17,11 @@ ".github/workflows/api-acceptance.yml": ("api", "lint"), ".github/workflows/native.yml": ("native", "lint"), ".github/workflows/actionlint.yml": ("lint",), + ".github/actionlint.yaml": ("lint",), ".github/workflows/ci-review.yml": ("lint",), ".github/workflows/website.yml": ("website", "lint"), - ".github/actionlint.yaml": ("lint",), ".github/actions/node/action.yml": (*NODE_JOBS, "lint"), "scripts/ci_plan.py": JOBS, - "scripts/ci_reuse.py": JOBS, - "scripts/ci_reuse_test.py": ("hygiene",), "scripts/ci_plan_test.py": ("hygiene",), "scripts/ci_metrics.py": ("hygiene",), "scripts/ci_metrics_test.py": ("hygiene",), @@ -55,7 +52,7 @@ (("apps/web/",), WEB, ("web", "web-acceptance")), (("services/web/",), (*GO, "Dockerfile"), ("distribution", "web", "web-acceptance")), (("example/",), WEB, ("example",)), - (("docs/", "contracts/"), (".md", ".svg", ".png", ".jpg", ".jpeg", ".webp"), ("website",)), + (("docs/", "contracts/"), ("",), ("website",)), (("website/",), (*WEB, ".vue", ".md"), ("website",)), (("services/core/",), CORE, ("backend", "api")), (("services/core/internal/nativeinstaller/",), GO, ("native", "distribution")), @@ -164,19 +161,6 @@ def changed_paths(base, head): def event_plan(event_name, event, requested_ref=""): if requested_ref: return full("Explicit ref: full gate") - if event_name == "push": - try: - if event.get("ref") != "refs/heads/main" or event.get("forced") or event.get("deleted"): - return full("Non-main or rewritten push: full gate") - before, after = event["before"], event["after"] - if any(not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha) or sha == "0" * 40 for sha in (before, after)): - raise ValueError("Invalid push commits") - if git("rev-parse", "HEAD").decode().strip() != after: - raise ValueError("Checkout does not match push head") - git("merge-base", "--is-ancestor", before, after) - return select(changed_paths(before, after)) - except (KeyError, TypeError, ValueError, UnicodeError, subprocess.CalledProcessError) as err: - return full(f"Push diff unavailable ({type(err).__name__}); full gate") if event_name != "pull_request": return full("Manual or reusable run: full gate") try: @@ -204,13 +188,6 @@ def validate_plan(plan): raise ValueError("Invalid selected jobs") if plan["image"] and "api" not in selected: raise ValueError("Image checks require API acceptance") - if "execute" in plan or "reused" in plan: - execute, reused = plan.get("execute"), plan.get("reused") - if (not isinstance(execute, list) or any(not isinstance(j, str) for j in execute) - or len(execute) != len(set(execute)) or not isinstance(reused, dict) - or set(execute) & set(reused) or set(execute) | set(reused) != set(selected) - or "hygiene" not in execute): - raise ValueError("Invalid execution/reuse partition") return set(selected) @@ -218,8 +195,7 @@ def check_results(plan, needs): selected = validate_plan(plan) if set(needs) != set(JOBS) | {"plan"} or needs["plan"].get("result") != "success": raise ValueError("Missing jobs or unsuccessful plan") - execute = set(plan.get("execute", selected)) - failed = [job for job in JOBS if needs[job].get("result") != ("success" if job in execute else "skipped")] + failed = [job for job in JOBS if needs[job].get("result") != ("success" if job in selected else "skipped")] if failed: raise ValueError("Check results do not match the plan: " + ", ".join(failed)) @@ -233,14 +209,8 @@ def main(): sub.add_parser("gate") args = parser.parse_args() if args.command == "gate": - plan = json.loads(os.environ["PLAN"]) - check_results(plan, json.loads(os.environ["RESULTS"])) - if "execute" in plan: - from ci_reuse import Evidence - evidence = Evidence() - evidence.verify(plan) - evidence.record(plan) - print("All selected checks passed or have verified successful evidence.") + check_results(json.loads(os.environ["PLAN"]), json.loads(os.environ["RESULTS"])) + print("All checks selected by the plan passed.") return if args.base: plan = select(changed_paths(args.base, args.head)) @@ -250,15 +220,11 @@ def main(): except (OSError, ValueError, KeyError): event = {} plan = event_plan(os.environ.get("GITHUB_EVENT_NAME"), event, os.environ.get("REQUESTED_REF", "")) - if os.environ.get("CI_REUSE") == "true" and not args.base: - from ci_reuse import Evidence - plan = Evidence().plan(plan) - validate_plan(plan) print(json.dumps(plan, indent=2)) if output := os.environ.get("GITHUB_OUTPUT"): with open(output, "a") as f: f.write("plan=" + json.dumps(plan, separators=(",", ":")) + "\n") - f.write("jobs=" + json.dumps(plan.get("execute", plan["jobs"])) + "\n") + f.write("jobs=" + json.dumps(plan["jobs"]) + "\n") f.write("image=" + json.dumps(plan["image"]) + "\n") if summary := os.environ.get("GITHUB_STEP_SUMMARY"): with open(summary, "a") as f: diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index 50e89d08..02791e08 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -14,8 +14,8 @@ class SelectionTests(unittest.TestCase): def jobs(self, *paths): return set(ci.select(paths)["jobs"]) - def test_documents_only_need_repository_integrity(self): - for path in ("docs/maintainers.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg"): + def test_published_documents_also_build_the_website(self): + for path in ("docs/maintainers.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg", "docs.json"): self.assertEqual(self.jobs(path), {"hygiene", "website"}) self.assertEqual(self.jobs("README.md"), {"hygiene"}) @@ -76,7 +76,7 @@ def test_core_fixtures_retain_client_and_installer_consumers(self): with self.subTest(path=path): self.assertTrue({"backend", "api", "distribution"} <= self.jobs(path)) - def test_shared_inputs_planner_and_empty_diffs_are_full(self): + def test_shared_inputs_and_planner_are_full(self): for paths in (["Makefile"], [".github/workflows/new.yml"], [".github/actions/new/action.yml"], [".github/workflows/check.yml"], [".github/workflows/release.yml"], ["scripts/ci_plan.py"], ["../outside"], ["/outside"]): self.assertEqual(set(ci.select(paths)["jobs"]), set(ci.JOBS)) @@ -101,7 +101,7 @@ def test_node_action_selects_all_direct_consumers_and_lint(self): consumers = {name for name, body in re.findall( r"^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:|\Z)", workflow, re.M | re.S) if "uses: ./.github/actions/node" in body} - for name, filename in (("api", "api-acceptance"), ("native", "native"), ("website", "website")): + for name, filename in (("api", "api-acceptance"), ("native", "native")): if "uses: ./.github/actions/node" in (root / f".github/workflows/{filename}.yml").read_text(): consumers.add(name) self.assertEqual(self.jobs(".github/actions/node/action.yml"), consumers | {"hygiene", "lint"}) @@ -178,7 +178,7 @@ def test_workflow_graph_cannot_silently_omit_or_add_a_gate_dependency(self): def test_only_matching_directory_and_suffix_trigger_product_checks(self): for path in ("services/core/notes.md", "apps/daemon/design.md", "internal/architecture.md", "apps/web/notes.md", "scripts/build-core.sh.md", "new-component/source.rs", - "docs/example.go", "services/core/code.go.bak"): + "services/core/code.go.bak"): self.assertEqual(self.jobs(path), {"hygiene"}, path) self.assertEqual(self.jobs("services/core/code.go"), {"hygiene", "backend", "api"}) self.assertEqual(self.jobs("apps/web/src/style.css"), {"hygiene", "web", "web-acceptance"}) @@ -199,6 +199,26 @@ def test_tracked_program_sources_have_a_matching_rule(self): if Path(path).suffix in {".go", ".sql", ".ts", ".tsx", ".mjs", ".sh", ".ps1"} or path.endswith("Dockerfile"): self.assertNotEqual(self.jobs(path), {"hygiene"}, path) + def test_verified_empty_diff_only_needs_hygiene(self): + self.assertEqual(self.jobs(), {"hygiene"}) + + def test_actionlint_config_selects_lint(self): + self.assertEqual(self.jobs(".github/actionlint.yaml"), {"hygiene", "lint"}) + + def test_ci_and_deployment_have_distinct_triggers(self): + root = Path(__file__).resolve().parents[1] + check = (root / ".github/workflows/check.yml").read_text() + website = (root / ".github/workflows/website.yml").read_text() + review = (root / ".github/workflows/ci-review.yml").read_text() + self.assertIn(" pull_request:", check) + self.assertIn(" workflow_dispatch:", check) + self.assertNotIn(" push:", check) + self.assertIn(" push:", website) + self.assertNotIn(" pull_request:", website) + self.assertIn("pull_request.merged == true", review) + self.assertIn("ref: ${{ github.event.pull_request.merge_commit_sha }}", review) + self.assertNotIn("workflow_run", review) + def test_non_pr_events_always_run_full(self): for event in ("push", "workflow_dispatch", "workflow_call"): self.assertEqual(ci.event_plan(event, {})["jobs"], list(ci.JOBS)) @@ -298,73 +318,3 @@ def test_malformed_plan_cannot_turn_checks_off(self): if __name__ == "__main__": unittest.main() - - -class DocumentationPushTests(unittest.TestCase): - def setUp(self): - self.before, self.after = "a" * 40, "b" * 40 - self.event = {"ref": "refs/heads/main", "before": self.before, "after": self.after, "forced": False, "deleted": False} - - def plan(self, paths, event=None, ref=""): - with patch.object(ci, "git", side_effect=[self.after.encode(), b""]), patch.object(ci, "changed_paths", return_value=paths) as diff: - plan = ci.event_plan("push", self.event if event is None else event, ref) - return plan, diff - - def test_doc_site_configuration_and_docs_only_push_skip_product_checks(self): - paths = ["docs.json", ".mintignore", "docs/getting-started/index.md", "README.md"] - self.assertEqual(set(ci.select(paths)["jobs"]), {"hygiene", "website"}) - plan, diff = self.plan(paths) - self.assertEqual(set(plan["jobs"]), {"hygiene", "website"}) - diff.assert_called_once_with(self.before, self.after) - - def test_generated_documentation_keeps_freshness_checks(self): - plan, _ = self.plan(["docs/configuration.md", "contracts/agents-api/harness-catalog.md"]) - self.assertEqual(set(plan["jobs"]), {"hygiene", "distribution", "website"}) - - def test_main_pushes_use_the_same_directory_suffix_rules_as_prs(self): - for paths in (["README.md", "services/core/cmd/server/main.go"], ["new.md"], [], - ["apps/web/src/app.tsx"], ["docs.json", "scripts/generate-harness-catalog.py"]): - with self.subTest(paths=paths): - self.assertEqual(self.plan(paths)[0], ci.select(paths)) - - def test_releases_and_untrusted_pushes_keep_full_gate(self): - self.assertEqual(self.plan(["README.md"], ref=self.after)[0]["jobs"], list(ci.JOBS)) - for fields in ({"ref": "refs/tags/v1"}, {"forced": True}, {"deleted": True}, {"before": "0" * 40}, {"before": "--bad"}, {"after": "c" * 40}): - with self.subTest(fields=fields): - self.assertEqual(self.plan(["README.md"], self.event | fields)[0]["jobs"], list(ci.JOBS)) - with patch.object(ci, "git", side_effect=subprocess.CalledProcessError(1, "git")): - self.assertEqual(ci.event_plan("push", self.event)["jobs"], list(ci.JOBS)) - - def test_push_uses_entire_commit_range_and_fails_closed_on_shallow_history(self): - with tempfile.TemporaryDirectory() as tmp: - repo = Path(tmp) / "source" - repo.mkdir() - def git(*args): - return subprocess.check_output(["git", "-C", str(repo), *args], stderr=subprocess.DEVNULL).decode().strip() - git("init", "-b", "main") - git("config", "user.email", "ci-test@example.invalid") - git("config", "user.name", "CI test") - (repo / "README.md").write_text("base\n") - git("add", "."); git("commit", "-m", "base") - before = git("rev-parse", "HEAD") - (repo / "docs.json").write_text("{}\n") - git("add", "."); git("commit", "-m", "docs") - docs_head = git("rev-parse", "HEAD") - previous = Path.cwd() - try: - os.chdir(repo) - event = self.event | {"before": before, "after": docs_head} - self.assertEqual(ci.event_plan("push", event)["jobs"], ["hygiene", "website"]) - (repo / "services/core").mkdir(parents=True) - (repo / "services/core/code.go").write_text("package example\n") - git("add", "."); git("commit", "-m", "code") - (repo / "README.md").write_text("updated\n") - git("add", "."); git("commit", "-m", "docs again") - event["after"] = git("rev-parse", "HEAD") - self.assertEqual(set(ci.event_plan("push", event)["jobs"]), {"hygiene", "website", "backend", "api"}) - clone = Path(tmp) / "shallow" - subprocess.run(["git", "clone", "--depth=2", repo.as_uri(), str(clone)], check=True, capture_output=True) - os.chdir(clone) - self.assertEqual(ci.event_plan("push", event)["jobs"], list(ci.JOBS)) - finally: - os.chdir(previous) diff --git a/scripts/ci_reuse.py b/scripts/ci_reuse.py deleted file mode 100644 index acf121f7..00000000 --- a/scripts/ci_reuse.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python3 -"""Reuse successful Actions checks with verified source and task inputs.""" - -from datetime import datetime, timedelta, timezone -import hashlib -import io -import json -import os -from pathlib import Path -import re -import subprocess -import time -import zipfile - -import ci_plan as ci - -VERSION = 1 -MAX_AGE = timedelta(hours=24) -LIMIT = 20 -# These jobs inspect external state or Git history and always run when selected. -FRESH = {"hygiene", "compose", "website"} -CONTROL_FILES = ("scripts/ci_plan.py", "scripts/ci_reuse.py") -NATIVE_ARTIFACTS = {f"oac-native-installer-{platform}" for platform in ("Linux-X64", "macOS-ARM64", "Windows-X64")} - - -def digest(value): - return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - - -def tree(revision): - if not re.fullmatch(r"[0-9a-f]{40}", revision): - raise ValueError("Expected an immutable commit") - try: - raw = ci.git("ls-tree", "-rz", "--full-tree", revision) - except subprocess.CalledProcessError: - ci.git("fetch", "--no-tags", "--depth=2", "origin", revision) - raw = ci.git("ls-tree", "-rz", "--full-tree", revision) - return dict(entry.split("\t", 1)[::-1] for entry in raw.decode().split("\0") if entry) - - -def controls(files): - return digest({path: value for path, value in files.items() - if path in CONTROL_FILES or path.startswith((".github/workflows/", ".github/actions/"))}) - - -def fingerprints(files, runner_mode, image): - inputs = {job: {} for job in ci.JOBS} - for path, value in files.items(): - for job in ci.select([path])["jobs"]: - inputs[job][path] = value - return {job: digest({"version": VERSION, "files": values, "runner": runner_mode, - "image": image if job == "api" else False}) for job, values in inputs.items()} - - -def now(): - return datetime.now(timezone.utc) - - -def fresh(timestamp): - if not isinstance(timestamp, str): - raise ValueError("Evidence timestamp must be a string") - parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - if parsed.tzinfo is None: - raise ValueError("Evidence timestamp must have a timezone") - age = now() - parsed - return timedelta(0) <= age < MAX_AGE - - -def api(path, binary=False): - result = subprocess.run(["gh", "api", path], check=True, capture_output=True, timeout=45) - return result.stdout if binary else json.loads(result.stdout) - - -class Evidence: - def __init__(self): - self.repository = os.environ["GITHUB_REPOSITORY"] - self.prefix = f"repos/{self.repository}/actions" - self.revision = ci.git("rev-parse", "HEAD").decode().strip() - self.files = tree(self.revision) - self.control = controls(self.files) - self.mode = os.environ.get("OAC_USE_GITHUB_RUNNERS", "") == "true" - self.run_id = int(os.environ["GITHUB_RUN_ID"]) - self.attempt = int(os.environ["GITHUB_RUN_ATTEMPT"]) - self.release = os.environ.get("CI_NATIVE_ARTIFACTS") == "true" - self.event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) - self.event_name = os.environ["GITHUB_EVENT_NAME"] - self.artifacts_required = self.release or (self.event_name == "push" and self.event.get("ref") == "refs/heads/main") - self.cache = {} - - def eligible(self, run): - if (run["id"] == self.run_id or run.get("path") != ".github/workflows/check.yml" - or (run.get("head_repository") or {}).get("full_name") != self.repository): - return False - if run.get("event") == "push" and run.get("head_branch") == "main": - return True - # PR evidence stays within the same repository PR. Main may promote an - # identical tested tree, but release callers only read main evidence. - if self.release or run.get("event") != "pull_request": - return False - if self.event_name == "pull_request": - pr = self.event.get("pull_request", {}) - return (pr.get("head", {}).get("repo", {}).get("full_name") == self.repository - and run.get("head_branch") == pr.get("head", {}).get("ref")) - return self.event_name == "push" and self.event.get("ref") == "refs/heads/main" - - def artifacts(self, run_id): - return api(f"{self.prefix}/runs/{run_id}/artifacts?per_page=100")["artifacts"] - - def receipt(self, run): - if not self.eligible(run) or run.get("conclusion") != "success" or run.get("status") != "completed": - raise ValueError("Run is not an eligible success") - if not fresh(run["created_at"]): - raise ValueError("Run is too old") - attempt = run["run_attempt"] - name = f"ci-evidence-{attempt}" - matches = [a for a in self.artifacts(run["id"]) if a["name"] == name and not a["expired"]] - if len(matches) != 1 or matches[0]["size_in_bytes"] > 100_000: - raise ValueError("Missing or invalid evidence artifact") - raw = api(f"{self.prefix}/artifacts/{matches[0]['id']}/zip", binary=True) - with zipfile.ZipFile(io.BytesIO(raw)) as archive: - info = archive.getinfo("evidence.json") - if info.file_size > 100_000: - raise ValueError("Oversized evidence") - receipt = json.loads(archive.read(info)) - if (receipt["version"] != VERSION or receipt["run_id"] != run["id"] - or receipt["attempt"] != attempt or receipt["repository"] != self.repository - or receipt["runner"] != self.mode or type(receipt["image"]) is not bool): - raise ValueError("Evidence identity mismatch") - revision = receipt["revision"] - files = tree(revision) - # Verify the producer implementation, not a fingerprint claimed in an - # artifact. PR workflow code must also match at the event's head SHA. - if controls(files) != self.control or controls(tree(run["head_sha"])) != self.control: - raise ValueError("Evidence producer changed") - if run["event"] == "push": - if revision != run["head_sha"]: - raise ValueError("Push evidence has another checkout") - else: - parents = ci.git("show", "-s", "--format=%P", revision).decode().split() - if len(parents) != 2 or parents[1] != run["head_sha"]: - raise ValueError("PR evidence is not the tested merge") - if self.event_name == "push" and files != self.files: - raise ValueError("Main can only promote the identical PR tree") - expected = fingerprints(files, self.mode, receipt["image"]) - if not isinstance(receipt["passed"], dict): - raise ValueError("Evidence checks must be an object") - for job, item in receipt["passed"].items(): - if (not isinstance(item, dict) or job not in expected - or item.get("key") != expected[job] or not fresh(item.get("verified_at"))): - raise ValueError("Invalid or expired check evidence") - return receipt - - def load(self, run_id): - if type(run_id) is not int or run_id <= 0: - raise ValueError("Invalid source run ID") - if run_id not in self.cache: - run = api(f"{self.prefix}/runs/{run_id}") - self.cache[run_id] = self.receipt(run) - return self.cache[run_id] - - def native_available(self, receipt): - if receipt["revision"] != self.revision: - return False - run_id = receipt.get("native_run_id") - if not run_id: - return False - run = api(f"{self.prefix}/runs/{run_id}") - if (run.get("event") != "push" or run.get("head_branch") != "main" - or run.get("head_sha") != self.revision or run.get("conclusion") != "success" - or run.get("path") != ".github/workflows/check.yml" - or (run.get("head_repository") or {}).get("full_name") != self.repository): - return False - names = {a["name"] for a in self.artifacts(run_id) if not a["expired"]} - return NATIVE_ARTIFACTS <= names - - def matching(self, receipt, job, key): - item = receipt["passed"].get(job) - return (job not in FRESH and item is not None and item["key"] == key - and fresh(item["verified_at"]) - and (job != "native" or not self.artifacts_required or self.native_available(receipt))) - - def candidates(self): - runs = api(f"{self.prefix}/workflows/check.yml/runs?per_page={LIMIT}")["workflow_runs"] - # A release waits for an existing main run of its exact source, bounded - # to ten minutes. Failure/cancellation falls back to executing checks. - pending = [r for r in runs if self.release and self.eligible(r) - and r["head_sha"] == self.revision and r["status"] != "completed"] - if pending: - run = pending[0] - deadline = time.monotonic() + 600 - while run["status"] != "completed" and time.monotonic() < deadline: - print(f"Waiting for main checks: {run['html_url']}", flush=True) - time.sleep(20) - run = api(f"{self.prefix}/runs/{run['id']}") - runs = [run] + [r for r in runs if r["id"] != run["id"]] - return runs - - def main_plan(self, plan, runs): - if self.event_name != "push" or self.event.get("ref") != "refs/heads/main" or os.environ.get("REQUESTED_REF"): - return plan - # GitHub replaces pending runs even with cancel-in-progress=false. Use - # the last verified main success so intermediate pushes remain covered. - for run in runs: - if run.get("event") != "push" or run.get("head_branch") != "main": - continue - try: - receipt = self.receipt(run) - ci.git("merge-base", "--is-ancestor", receipt["revision"], self.revision) - pending = ci.select(ci.changed_paths(receipt["revision"], self.revision)) - return dict(plan, jobs=[j for j in ci.JOBS if j in set(plan["jobs"]) | set(pending["jobs"])], - image=plan["image"] or pending["image"], - reasons=plan["reasons"] + [f"Include changes since successful main run {run['id']}"] + pending["reasons"]) - except (subprocess.SubprocessError, ValueError, KeyError, TypeError, zipfile.BadZipFile): - continue - return ci.full("No verified main baseline; execute the full plan") - - def plan(self, plan): - runs = [] - if os.environ.get("CI_FORCE") != "true": - try: - runs = self.candidates() - except (subprocess.SubprocessError, ValueError, KeyError): - print("Evidence lookup unavailable; executing selected checks.") - plan = self.main_plan(plan, runs) - keys = fingerprints(self.files, self.mode, plan["image"]) - result = dict(plan, execute=list(plan["jobs"]), reused={}, keys=keys) - for run in runs: - if not self.eligible(run) or run.get("conclusion") != "success": - continue - try: - receipt = self.receipt(run) - for job in list(result["execute"]): - if self.matching(receipt, job, keys[job]): - result["execute"].remove(job) - result["reused"][job] = {"run_id": run["id"], "key": keys[job]} - print(f"Reuse {job}: {run['html_url']}") - if set(result["execute"]) <= FRESH: - break - except (subprocess.SubprocessError, ValueError, KeyError, TypeError, zipfile.BadZipFile): - continue - return result - - def verify(self, plan): - if plan["keys"] != fingerprints(self.files, self.mode, plan["image"]): - raise ValueError("Plan inputs changed") - for job, source in plan["reused"].items(): - if source["key"] != plan["keys"][job] or not self.matching(self.load(source["run_id"]), job, source["key"]): - raise ValueError(f"Cannot verify reused check: {job}") - - def record(self, plan): - passed = {job: {"key": plan["keys"][job], "verified_at": now().isoformat()} - for job in plan["execute"]} - for job, source in plan["reused"].items(): - passed[job] = self.load(source["run_id"])["passed"][job] - native_run = None - if "native" in plan["execute"] and (self.release or self.event_name == "push" and self.event.get("ref") == "refs/heads/main"): - native_run = self.run_id - elif "native" in plan["reused"]: - receipt = self.load(plan["reused"]["native"]["run_id"]) - if receipt["revision"] == self.revision: - native_run = receipt.get("native_run_id") - receipt = {"version": VERSION, "repository": self.repository, "run_id": self.run_id, - "attempt": self.attempt, "revision": self.revision, "runner": self.mode, - "image": plan["image"], "passed": passed, "native_run_id": native_run} - directory = Path(os.environ["RUNNER_TEMP"]) / "ci-evidence" - directory.mkdir(exist_ok=True) - (directory / "evidence.json").write_text(json.dumps(receipt)) - with open(os.environ["GITHUB_OUTPUT"], "a") as output: - output.write(f"native-run-id={native_run or ''}\n") diff --git a/scripts/ci_reuse_test.py b/scripts/ci_reuse_test.py deleted file mode 100644 index c87568d6..00000000 --- a/scripts/ci_reuse_test.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Input invalidation, provenance, gate and workflow regression tests.""" -from datetime import timedelta -import io -import json -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch -import zipfile - -import ci_plan as ci -import ci_reuse as reuse - - -class FingerprintTests(unittest.TestCase): - def setUp(self): - self.files = {"services/core/main.go": "100644 blob code", "docs/guide.md": "100644 blob docs", - "apps/web/src/app.tsx": "100644 blob web", "Makefile": "100644 blob make", - "scripts/ci_reuse.py": "100644 blob reuse", ".github/workflows/check.yml": "100644 blob workflow"} - - def keys(self, files=None, mode=False, image=False): - return reuse.fingerprints(self.files if files is None else files, mode, image) - - def test_docs_followup_keeps_code_evidence(self): - changed = self.files | {"docs/guide.md": "100644 blob new-docs"} - a, b = self.keys(), self.keys(changed) - self.assertNotEqual(a["hygiene"], b["hygiene"]) - self.assertNotEqual(a["website"], b["website"]) - for job in ("backend", "api", "web", "web-acceptance", "native"): - self.assertEqual(a[job], b[job], job) - - def test_source_addition_deletion_mode_and_shared_inputs_invalidate(self): - for change in ({"services/core/main.go": "100644 blob new-code"}, - {"services/core/main.go": "100755 blob code"}, - {"services/core/new.go": "100644 blob code"}): - self.assertNotEqual(self.keys()["backend"], self.keys(self.files | change)["backend"]) - self.assertNotEqual(self.keys()["backend"], self.keys({k:v for k,v in self.files.items() if k != "services/core/main.go"})["backend"]) - for path in ("Makefile", "scripts/ci_reuse.py", ".github/workflows/check.yml"): - self.assertTrue(all(self.keys()[j] != self.keys(self.files | {path: "new"})[j] for j in ci.JOBS)) - - def test_runner_and_image_modes_are_part_of_evidence(self): - self.assertTrue(all(self.keys()[j] != self.keys(mode=True)[j] for j in ci.JOBS)) - self.assertNotEqual(self.keys()["api"], self.keys(image=True)["api"]) - self.assertEqual(self.keys()["backend"], self.keys(image=True)["backend"]) - - def test_unrelated_node_module_does_not_invalidate_backend(self): - self.assertEqual(self.keys()["backend"], self.keys(self.files | {"apps/web/pnpm-lock.yaml": "new"})["backend"]) - - def test_empty_diff_is_not_full_and_lint_config_is_checked(self): - self.assertEqual(ci.select([])["jobs"], ["hygiene"]) - self.assertEqual(set(ci.select([".github/actionlint.yaml"])["jobs"]), {"hygiene", "lint"}) - - -class EvidenceTests(unittest.TestCase): - def setUp(self): - self.e = object.__new__(reuse.Evidence) - self.e.repository = "owner/repo" - self.e.prefix = "repos/owner/repo/actions" - self.e.revision = "a" * 40 - self.e.files = {"scripts/ci_reuse.py": "reuse", ".github/workflows/check.yml": "workflow", "services/core/code.go": "code"} - self.e.control = reuse.controls(self.e.files) - self.e.run_id = 99 - self.e.attempt = 1 - self.e.mode = False - self.e.release = False - self.e.artifacts_required = False - self.e.event_name = "pull_request" - self.e.event = {"pull_request": {"head": {"ref": "feature", "repo": {"full_name": "owner/repo"}}}} - self.e.cache = {} - self.run = {"id": 12, "run_attempt": 1, "path": ".github/workflows/check.yml", "event": "push", - "head_branch": "main", "head_sha": "a" * 40, "head_repository": {"full_name": "owner/repo"}, - "created_at": reuse.now().isoformat(), "status": "completed", "conclusion": "success", "html_url": "https://example.invalid/12"} - self.keys = reuse.fingerprints(self.e.files, False, False) - self.receipt = {"version": reuse.VERSION, "repository": "owner/repo", "run_id": 12, "attempt": 1, - "revision": "a" * 40, "runner": False, "image": False, "native_run_id": 12, - "passed": {"backend": {"key": self.keys["backend"], "verified_at": reuse.now().isoformat()}}} - - def read(self, receipt=None, run=None, files=None): - archive = io.BytesIO() - with zipfile.ZipFile(archive, "w") as z: - z.writestr("evidence.json", json.dumps(self.receipt if receipt is None else receipt)) - artifacts = [{"name": "ci-evidence-1", "expired": False, "size_in_bytes": 100, "id": 1}] - with patch.object(self.e, "artifacts", return_value=artifacts), patch.object(reuse, "api", return_value=archive.getvalue()), patch.object(reuse, "tree", return_value=self.e.files if files is None else files): - return reuse.Evidence.receipt(self.e, self.run if run is None else run) - - def test_success_and_producer_are_verified(self): - self.assertEqual(self.read(), self.receipt) - for fields in ({"conclusion": "failure"}, {"conclusion": "cancelled"}, {"status": "in_progress"}, - {"path": ".github/workflows/untrusted.yml"}, {"head_repository": {"full_name": "fork/repo"}}, - {"id": 99}, {"created_at": (reuse.now() - timedelta(days=2)).isoformat()}, - {"run_attempt": 2}, {"head_sha": "b" * 40}): - with self.subTest(fields=fields), self.assertRaises(ValueError): - self.read(run=self.run | fields) - with self.assertRaises(ValueError): - self.read(files=self.e.files | {"scripts/ci_reuse.py": "forged producer"}) - - def test_forged_stale_and_wrong_mode_receipts_are_rejected(self): - for fields in ({"run_id": 13}, {"attempt": 2}, {"repository": "fork/repo"}, {"runner": True}, - {"image": "false"}, {"passed": {"backend": {"key": "forged", "verified_at": reuse.now().isoformat()}}}, - {"passed": {"backend": {"key": self.keys["backend"], "verified_at": (reuse.now() - timedelta(days=2)).isoformat()}}}): - with self.subTest(fields=fields), self.assertRaises(ValueError): - self.read(receipt=self.receipt | fields) - - def test_malformed_artifacts_fall_back_to_execution(self): - plan = ci.select(["services/core/code.go"]) - for passed in ([], None, {"backend": []}, {"backend": {"key": self.keys["backend"], "verified_at": 42}}, - {"backend": {"key": self.keys["backend"], "verified_at": "2026-10-01T10:00:00"}}): - malformed = self.receipt | {"passed": passed} - with self.subTest(passed=passed), patch.object(self.e, "candidates", return_value=[self.run]), patch.object(self.e, "receipt", side_effect=lambda run: self.read(receipt=malformed)): - self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) - - def test_pr_scope_and_release_trust(self): - pr = self.run | {"event": "pull_request", "head_branch": "feature"} - self.assertTrue(self.e.eligible(pr)) - self.assertFalse(self.e.eligible(pr | {"head_branch": "another-pr"})) - self.e.release = True - self.assertFalse(self.e.eligible(pr)) - self.assertTrue(self.e.eligible(self.run)) - - def test_pr_merge_parent_and_main_tree_are_verified(self): - pr = self.run | {"event": "pull_request", "head_branch": "feature", "head_sha": "b" * 40} - with patch.object(ci, "git", return_value=("c"*40 + " " + "b"*40).encode()): - self.assertEqual(self.read(run=pr), self.receipt) - with patch.object(ci, "git", return_value=b"wrong parents"), self.assertRaises(ValueError): - self.read(run=pr) - self.e.event_name = "push" - self.e.event = {"ref": "refs/heads/main"} - with patch.object(ci, "git", return_value=("c"*40 + " " + "b"*40).encode()), self.assertRaises(ValueError): - self.read(run=pr, files=self.e.files | {"docs/new.md": "changed"}) - - def test_native_artifacts_require_exact_revision_and_trusted_source(self): - artifacts = [{"name": name, "expired": False} for name in reuse.NATIVE_ARTIFACTS] - with patch.object(reuse, "api", return_value=self.run), patch.object(self.e, "artifacts", return_value=artifacts): - self.assertTrue(self.e.native_available(self.receipt)) - self.assertFalse(self.e.native_available(self.receipt | {"revision": "b" * 40})) - with patch.object(reuse, "api", return_value=self.run), patch.object(self.e, "artifacts", return_value=artifacts[:-1]): - self.assertFalse(self.e.native_available(self.receipt)) - with patch.object(reuse, "api", return_value=self.run | {"event": "pull_request"}): - self.assertFalse(self.e.native_available(self.receipt)) - - def test_lookup_failure_and_force_execute_checks(self): - plan = ci.select(["services/core/code.go"]) - with patch.object(self.e, "candidates", side_effect=ValueError("unavailable")): - self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) - with patch.dict(os.environ, {"CI_FORCE": "true"}), patch.object(self.e, "candidates") as candidates: - self.assertEqual(self.e.plan(plan)["execute"], plan["jobs"]) - candidates.assert_not_called() - - def test_matching_result_reuses_only_selected_jobs_and_gate_rechecks_it(self): - plan = ci.select(["services/core/code.go"]) - with patch.object(self.e, "candidates", return_value=[self.run]), patch.object(self.e, "receipt", return_value=self.receipt): - result = self.e.plan(plan) - self.assertEqual(result["execute"], ["hygiene", "api"]) - self.assertEqual(set(result["reused"]), {"backend"}) - needs = {j: {"result": "success" if j in result["execute"] else "skipped"} for j in ci.JOBS} - needs["plan"] = {"result": "success"} - ci.check_results(result, needs) - with patch.object(self.e, "load", return_value=self.receipt): - self.e.verify(result) - with patch.object(self.e, "load", return_value=self.receipt | {"passed": {}}), self.assertRaises(ValueError): - self.e.verify(result) - for bad in (result | {"execute": ["hygiene"]}, result | {"reused": {}}, result | {"execute": plan["jobs"]}): - with self.assertRaises(ValueError): - ci.check_results(bad, needs) - with self.assertRaises(ValueError): - ci.check_results(result, needs | {"api": {"result": "skipped"}}) - - def test_three_main_pushes_retain_work_from_replaced_pending_run(self): - # A completed; B changed docs but was replaced while pending by C, - # whose event diff only contains backend code. - self.e.event_name = "push" - self.e.event = {"ref": "refs/heads/main"} - plan = ci.select(["services/core/code.go"]) - with patch.object(self.e, "receipt", return_value=self.receipt), patch.object(ci, "git", return_value=b""), patch.object(ci, "changed_paths", return_value=["docs/guide.md", "services/core/code.go"]) as changes: - selected = self.e.main_plan(plan, [self.run]) - self.assertEqual(set(selected["jobs"]), {"hygiene", "backend", "api", "website"}) - changes.assert_called_once_with(self.receipt["revision"], self.e.revision) - - def test_main_without_verified_ancestor_runs_full(self): - self.e.event_name = "push" - self.e.event = {"ref": "refs/heads/main"} - plan = ci.select(["README.md"]) - self.assertEqual(self.e.main_plan(plan, [])["jobs"], list(ci.JOBS)) - with patch.object(self.e, "receipt", side_effect=ValueError("invalid")): - self.assertEqual(self.e.main_plan(plan, [self.run])["jobs"], list(ci.JOBS)) - - def test_reuse_does_not_renew_expiry(self): - verified = (reuse.now() - timedelta(hours=20)).isoformat() - self.receipt["passed"]["backend"]["verified_at"] = verified - plan = {"keys": self.keys, "execute": ["hygiene"], "reused": {"backend": {"run_id": 12}}, "image": False} - with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"RUNNER_TEMP": tmp, "GITHUB_OUTPUT": tmp + "/output"}), patch.object(self.e, "load", return_value=self.receipt): - self.e.record(plan) - recorded = json.loads((Path(tmp) / "ci-evidence/evidence.json").read_text()) - self.assertEqual(recorded["passed"]["backend"]["verified_at"], verified) - - def test_release_waits_for_exact_main_run_and_failure_falls_back(self): - self.e.release = True - pending = self.run | {"status": "in_progress", "conclusion": None} - with patch.object(reuse, "api", side_effect=[{"workflow_runs": [pending]}, self.run]), patch.object(reuse.time, "sleep") as sleep: - self.assertEqual(self.e.candidates(), [self.run]) - sleep.assert_called_once_with(20) - failed = self.run | {"conclusion": "failure"} - with patch.object(reuse, "api", side_effect=[{"workflow_runs": [pending]}, failed]), patch.object(reuse.time, "sleep"): - self.assertEqual(self.e.plan(ci.full("release"))["execute"], list(ci.JOBS)) - - -class WorkflowTests(unittest.TestCase): - def test_site_has_one_trigger_owner_and_build_owner(self): - root = Path(__file__).resolve().parents[1] - check = (root / ".github/workflows/check.yml").read_text() - site = (root / ".github/workflows/website.yml").read_text() - self.assertIn("uses: ./.github/workflows/website.yml", check) - self.assertNotIn("make check-website", check) - self.assertIn(" workflow_call:", site) - self.assertNotIn(" pull_request:", site) - self.assertNotIn(" push:", site) - - def test_release_cross_run_artifact_download_has_read_permission(self): - root = Path(__file__).resolve().parents[1] - body = (root / ".github/workflows/release.yml").read_text().split(" build:\n")[1] - self.assertIn(" actions: read\n", body.split(" steps:")[0]) - self.assertIn("run-id: ${{ needs.check.outputs.native-run-id }}", body) - - def test_browser_suite_can_follow_a_reused_unit_result(self): - root = Path(__file__).resolve().parents[1] - body = (root / ".github/workflows/check.yml").read_text().split(" web-acceptance:\n")[1].split(" strategy:")[0] - self.assertIn("always() && !cancelled()", body) - self.assertIn("reused.web != null", body) - - -if __name__ == "__main__": - unittest.main() diff --git a/website/README.md b/website/README.md index bd7ade6d..0e87f39f 100644 --- a/website/README.md +++ b/website/README.md @@ -23,4 +23,4 @@ The landing page lives in `.vitepress/theme/`. `landing-content.ts` holds its En ## Publish -`make check-website` builds and tests the site. `core-check` selects the reusable `.github/workflows/website.yml` for website, published documentation and dependency changes. That workflow builds and tests once; on main pushes it deploys the same artifact to GitHub Pages. The [CI planner](../docs/maintainers.md#continuous-integration) owns the input map. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. The build reads the Pages base path, so the site works both at `https://.github.io//` and on a custom domain set under **Settings → Pages → Custom domain**. +`make check-website` builds and tests the site. `core-check` runs it for website, published documentation and dependency changes in pull requests. `.github/workflows/website.yml` builds and deploys main to GitHub Pages when site inputs change, and supports manual deployment. The publication build reads the main branch history and Pages configuration; PR builds validate changes before merge. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. The build reads the Pages base path, so the site works both at `https://.github.io//` and on a custom domain set under **Settings → Pages → Custom domain**.