diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml index 9101104d3..8faa4598f 100644 --- a/.github/workflows/free-durations-record.yml +++ b/.github/workflows/free-durations-record.yml @@ -71,6 +71,9 @@ jobs: windows: uses: ./.github/workflows/windows-free-tests.yml + permissions: + contents: read + actions: read with: record_durations: true diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 4af58829f..966419ad3 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -14,6 +14,15 @@ name: Free Tests # is permanent false comfort. # # Local `bun run test` still uses the existing bounded process pool. +# +# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse). The aggregate +# uploads a `tested-tree` record (tree.json) only after strict verification +# passes. A push to main first runs `reuse`: when the merged pull request's +# head has the pushed tree and a green pull_request or workflow_dispatch run of +# this workflow verifies (lib/run-evidence.ts), every suite job is skipped and +# the aggregate links that run instead. Any other answer, a reuse failure +# included, runs every job as before. Pull request and dispatch runs never +# consume; reused runs never upload a record, so they are never reused. on: pull_request: @@ -54,7 +63,40 @@ permissions: contents: read jobs: + # Main pushes only, and the one job with actions: read (it reads this + # repository's runs and their tested-tree artifacts). Every suite job below + # runs unless this job succeeded and reused a run; a skipped or failed reuse + # job runs them all. Their conditions start with !cancelled() because a + # skipped needed job would otherwise skip its dependents on pull_request and + # dispatch runs, where reuse never runs. + reuse: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + reused: ${{ steps.find.outputs.reused }} + run_id: ${{ steps.find.outputs.run_id }} + run_url: ${{ steps.find.outputs.run_url }} + tree: ${{ steps.find.outputs.tree }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - id: find + name: Find a green pull request or dispatch run of this exact tree + env: + GH_TOKEN: ${{ github.token }} + run: bun run scripts/ci-tree-reuse.ts find --workflow free-tests.yml --profile ci-reuse/free-tests + free-plan: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -106,6 +148,8 @@ jobs: if-no-files-found: error cso-macos-launcher: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: macos-latest timeout-minutes: 15 steps: @@ -144,6 +188,8 @@ jobs: # default, and the job fails if that layout is not symlinked. It also sources # the Codex probe from zsh, macOS's default shell (#3024; no paid call). macos-named-regressions: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: macos-latest timeout-minutes: 20 steps: @@ -188,6 +234,8 @@ jobs: bun test "${files[@]}" cso-windows-launcher: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -216,6 +264,8 @@ jobs: retention-days: 14 cso-docker-integration: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -246,6 +296,8 @@ jobs: DOCKER_HOST: unix:///var/run/docker.sock typecheck: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -269,6 +321,8 @@ jobs: # the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT; # test/bun-version-drift.test.ts checks this job exists and agrees. quick-bun-default: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubicloud-standard-8 timeout-minutes: 30 steps: @@ -365,7 +419,8 @@ jobs: run: xvfb-run -a bun run test:quick free-suite: - needs: free-plan + needs: [free-plan, reuse] + if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ${{ needs.free-plan.outputs.runner }} timeout-minutes: 20 strategy: @@ -590,13 +645,38 @@ jobs: # Branch protection already requires the `free-tests` context. Keep that # stable name as an always-running aggregate so every platform-specific CSO # gate is merge-blocking without a separate branch-protection migration. + # Its first step picks one of two exclusive paths before any download: a + # verified reuse (main pushes only) links the reused run and passes; anything + # else takes the strict path below unchanged, which alone uploads tested-tree. free-tests: if: always() - needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] + needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: + - name: Take the reuse path only for a verified main-push reuse + id: route + env: + REUSE_RESULT: ${{ needs.reuse.result }} + REUSED: ${{ needs.reuse.outputs.reused }} + REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }} + REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }} + REUSE_TREE: ${{ needs.reuse.outputs.tree }} + run: | + set -eu + if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then + echo 'reused=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$GITHUB_EVENT_NAME" = push + test "$GITHUB_REF" = refs/heads/main + case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac + case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac + test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID" + echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY" + echo 'reused=true' >> "$GITHUB_OUTPUT" - name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate + if: steps.route.outputs.reused != 'true' env: FREE_SUITE_RESULT: ${{ needs.free-suite.result }} TYPECHECK_RESULT: ${{ needs.typecheck.result }} @@ -615,16 +695,20 @@ jobs: test "$CSO_WINDOWS_RESULT" = success test "$CSO_DOCKER_RESULT" = success - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.route.outputs.reused != 'true' with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.route.outputs.reused != 'true' with: bun-version: 1.4.2 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: name: free-plan path: ${{ runner.temp }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: free-result-* merge-multiple: true @@ -633,10 +717,27 @@ jobs: # flake-ledger.jsonl). A green run is where a retried flake hides, so the # summary runs before strict verification. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: flake-ledger-* path: ${{ runner.temp }}/flake-ledgers - name: Summarize flaky passes absorbed by the serial retry + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY" - name: Require exact coverage and complete strict results + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" + # Only after strict verification passed, so a red or partial run never + # leaves a record; the reuse path never reaches here. + - name: Record the tested tree + if: steps.route.outputs.reused != 'true' + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json" + - name: Upload the tested tree + if: steps.route.outputs.reused != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: tested-tree + path: ${{ runner.temp }}/tested-tree/tree.json + if-no-files-found: error diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 6d19e5b48..33e896c42 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -19,6 +19,12 @@ name: Windows Free Tests # # Native browser qualification campaigns (Windows cookie extraction, launch # diagnostics) are dispatch-only in native-qualification.yml. +# +# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse), as in +# free-tests.yml: the aggregate uploads `tested-tree` only after strict +# verification passes, and a push to main whose merged pull request already +# ran this workflow green on the same tree reuses that run. Pull request, +# dispatch and duration-recording runs never consume. on: pull_request: @@ -50,8 +56,36 @@ permissions: contents: read jobs: + # Main pushes only, and the one job with actions: read; see free-tests.yml. + # A skipped or failed reuse job runs every job below as before. + reuse: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + reused: ${{ steps.find.outputs.reused }} + run_id: ${{ steps.find.outputs.run_id }} + run_url: ${{ steps.find.outputs.run_url }} + tree: ${{ steps.find.outputs.tree }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - id: find + name: Find a green pull request or dispatch run of this exact tree + env: + GH_TOKEN: ${{ github.token }} + run: bun run scripts/ci-tree-reuse.ts find --workflow windows-free-tests.yml --profile ci-reuse/windows-free-tests + windows-plan: - if: ${{ !inputs.record_durations }} + needs: reuse + if: ${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -79,7 +113,8 @@ jobs: if-no-files-found: error windows-free-shard: - needs: windows-plan + needs: [windows-plan, reuse] + if: ${{ !cancelled() && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: windows-latest timeout-minutes: 15 strategy: @@ -184,11 +219,33 @@ jobs: windows-free-tests: if: ${{ always() && !inputs.record_durations }} - needs: [windows-plan, windows-free-shard] + needs: [reuse, windows-plan, windows-free-shard] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: + - name: Take the reuse path only for a verified main-push reuse + id: route + env: + REUSE_RESULT: ${{ needs.reuse.result }} + REUSED: ${{ needs.reuse.outputs.reused }} + REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }} + REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }} + REUSE_TREE: ${{ needs.reuse.outputs.tree }} + run: | + set -eu + if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then + echo 'reused=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$GITHUB_EVENT_NAME" = push + test "$GITHUB_REF" = refs/heads/main + case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac + case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac + test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID" + echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY" + echo 'reused=true' >> "$GITHUB_OUTPUT" - name: Require the plan and every Windows shard + if: steps.route.outputs.reused != 'true' env: PLAN_RESULT: ${{ needs.windows-plan.result }} SHARD_RESULT: ${{ needs.windows-free-shard.result }} @@ -197,22 +254,40 @@ jobs: test "$PLAN_RESULT" = success test "$SHARD_RESULT" = success - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.route.outputs.reused != 'true' with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.route.outputs.reused != 'true' with: bun-version: 1.4.2 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: name: windows-plan path: ${{ runner.temp }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: windows-result-* merge-multiple: true path: ${{ runner.temp }}/windows-results - name: Require exact coverage and complete strict results + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-free-shards.ts --windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" + # Only after strict verification passed; the reuse path never reaches here. + - name: Record the tested tree + if: steps.route.outputs.reused != 'true' + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" --os Windows --out "$RUNNER_TEMP/tested-tree/tree.json" + - name: Upload the tested tree + if: steps.route.outputs.reused != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: tested-tree + path: ${{ runner.temp }}/tested-tree/tree.json + if-no-files-found: error windows-record-durations: if: ${{ inputs.record_durations }} diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 0454e74f3..c424957cc 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -19,6 +19,10 @@ "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list", "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, + "run_profiles": { + "ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" }, + "ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" } + }, "warn": [], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcbe0188..758e468fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.92.39.0] - 2026-11-01 + +**A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage. + +### Added +- **Main-push reuse** in `free-tests.yml` and `windows-free-tests.yml` (`scripts/ci-tree-reuse.ts`, docs/pregate.md#ci-reuse). The `reuse` job is the only one with `actions: read`, and it reads at most 10 pull requests and 10 candidate runs. A newer red run on the head vetoes an older green one. A run that reused another run never counts. Pull request and dispatch runs never consume, and a skipped or failed `reuse` job runs every job exactly as before. The required `free-tests` and `windows-free-tests` aggregates pick their path before any download, and the strict path is unchanged. +- **`tested-tree` records.** Both aggregates upload `tree.json` only after strict verification passes: run, attempt, event, sha, head, tree, workflow, OS list, every planned test file and the test count. +- **`gstack-pregate --stage tests --from-run `** (docs/pregate.md#from-run). The selected local test files a verified Free Tests run on this exact tree lists count as passed and are recorded under `from_run` with the run receipt, and the rest still run. A refused run prints `REUSE: refused reason=` and runs everything. It changes only the tests stage; `--stage all` still runs every preflight check, the secrets scan included. +- **gstack's run profiles** in `.gstack/pregate.json`: `ci-reuse/free-tests` (the complete Free Tests run) and `ci-reuse/windows-free-tests`. Neither is named after a lane, so macOS and Windows lane clears and `gstack-ship-queue ready --dispatch-ci` keep recording the run URL. Lane profiles and dispatch-ci verification wait until open pull requests carry the `tested-tree` producer (GST-555). + +### Changed +- **Run profiles may accept `pull_request` runs.** Such a run counts only when its `tree.json` names the head and the merge commit, and the merge commit's tree equals the head's tree, which means the head already held its base. `tree.json` may list every OS its test jobs ran on, and the profile's OS must be one of them. +- **`free-durations-record.yml` grants its Windows caller job `contents: read` and `actions: read`.** That job calls `windows-free-tests.yml`, whose new `reuse` job asks for `actions: read`, and a called workflow can't hold more than its caller grants. Without the grant, GitHub rejects the scheduled duration run before it starts. +- **A run whose `tree.json` lists no test files or zero tests never counts** for `--from-run` or main-push reuse (`no_tests`). + +### Tests +- `test/ci-tree-reuse.test.ts`: candidate discovery with fake GitHub data (pull request found or missing, unmerged, fork head, tree mismatch, reused record, newer red run veto, dispatch and pull_request candidates, the 10-run bound, API errors), the `tree.json` producer, and the CLI refusing every event but a main push. +- `test/ci-reuse-workflow-wiring.test.ts`: both workflows. It covers the push-only reuse job and its permissions, each suite job's condition evaluated for a skipped, failed and successful reuse, and status functions on every downstream job. It also covers pregate still requiring every test lane, the aggregate's route step run under bash for each input, and the profiles naming real jobs and never a lane. +- `test/cso-distribution.test.ts`: the `free-tests` aggregate still requires `cso-docker-integration` on its strict path, and only a verified reuse skips that job. +- `test/pregate-from-run.test.ts` failed on the old code. `test/run-evidence.test.ts` adds the `pull_request` and OS-list cases and `verifyFromRun`, including the zero-test refusal, which failed on the old code. +- `test/free-durations-record-workflow.test.ts` pins the Windows caller grant, and `test/ci-reuse-workflow-wiring.test.ts` requires every caller of either workflow to grant `actions: read`; it failed without the grant. + ## [1.92.38.0] - 2026-11-01 **brain-sync no longer gets stuck in `push_failed` when another machine changed a file you have edited locally.** After a rejected push, brain-sync fetches and merges the other machine's commits. Git refuses that merge when a file it would update has uncommitted local edits. brain-sync hid git's error and hit the same refusal on every run, so cross-machine sync stopped for good (#3704). diff --git a/VERSION b/VERSION index d99ed55f4..0e6e6f87b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.38.0 +1.92.39.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f10b4a111..ab2e7139f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.38.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.39.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 10da90e78..772bec1b1 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -4,7 +4,7 @@ * * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] * [--allow-repo-commands] [--policy-from base|head] [--out ] - * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--explain] [--json] + * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--explain] [--json] * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared * gstack-pregate clear --remote --run-url | --run [--run …] [--allow-repo-commands] | --bundle-lane