mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
v1.92.39.0 ci: main pushes reuse the merged PR's verified run of the same tree; dispatch-ci and --from-run go through the run verifier (GSTA-156 P3b) (#3709)
* feat(pregate): gstack run profiles, dispatch-ci clears through the run verifier, --from-run tests stage (GSTA-156 P3b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(free-tests): main pushes reuse the merged pull request's verified run of the same tree (GSTA-156 P3b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(cso): the free-tests aggregate still requires cso-docker-integration on its strict path, and only a verified reuse skips it (GSTA-156 P3b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): name the reuse profiles after no lane, so lane clears and ready --dispatch-ci keep the URL path until GST-555 (GSTA-156 P3b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): the duration recorder grants actions: read to its windows-free-tests call, and --from-run refuses a run that recorded zero tests (GSTA-156 P3b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: pregate run_profiles row, no_verified_run code; changelog for caller grant and zero-test refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ab2852fb40
commit
77fe28c397
24 files changed
+1114
-66
No files matched your search
@@ -71,6 +71,9 @@ jobs:
|
||||
|
||||
windows:
|
||||
uses: ./.github/workflows/windows-free-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
with:
|
||||
record_durations: true
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
@@ -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 <run id|url>`** (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=<code>` 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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-30
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
|
||||
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
|
||||
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
|
||||
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
|
||||
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
|
||||
@@ -27,17 +27,15 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile, type RunCheck } from '../lib/pregate/index';
|
||||
import { ghRunDeps, runInventory } from '../lib/run-evidence';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
import { ensureTmpExcluded } from '../lib/restamp';
|
||||
import { normalizeRemoteUrl } from '../lib/ship-queue-train-record';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
@@ -90,6 +88,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
spent, reserved and unknown
|
||||
--run <id> the run label recorded in pregate.json and the name of this run's scratch directory (a taken name gets a numbered sibling, <id>-2 … -9)
|
||||
(<state root>/projects/<slug>/pregate/runs/<id>: lane and guard logs, scan files; default a unique id)
|
||||
--from-run <id|url> a Free Tests run on this exact tree, verified against the base branch's run profile for this OS
|
||||
whose workflow holds a local lane (docs/pregate.md#from-run): the selected test files its tree.json lists count as passed and
|
||||
are recorded with its receipt; the rest still run. A refused run prints REUSE: refused and runs everything
|
||||
--explain print each selected test's provenance and each uncertain mapping
|
||||
--json one JSON object on stdout; a refusal before any check runs (unavailable base, invalid pin
|
||||
file, WTREE_UNAVAILABLE) is {ok:false,status:'refused',code,detail,fix,docs} with exit 3,
|
||||
@@ -108,7 +109,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
|
||||
|
||||
/** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */
|
||||
const VERB_FLAGS: Record<string, [string[], string[]]> = {
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
verify: [['--out'], ['--json']],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
|
||||
lanes: [[], ['--explain', '--json']],
|
||||
@@ -141,6 +142,8 @@ function outPath(root: string, rest: string[]): string { const o = flag(rest, '-
|
||||
async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> {
|
||||
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all';
|
||||
if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; }
|
||||
const fromRun = flag(rest, '--from-run');
|
||||
if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
|
||||
const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id));
|
||||
if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; }
|
||||
@@ -172,6 +175,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
return EXIT.refused;
|
||||
}
|
||||
const ctx = loaded.ctx;
|
||||
if (fromRun !== undefined) ctx.fromRun = { ref: fromRun, check: buildRunCheck(root, null, allow, base) };
|
||||
ensureTmpExcluded(root);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
@@ -228,7 +232,7 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const runs = rest.flatMap((a, i) => a === '--run' ? [rest[i + 1]!] : []);
|
||||
const runUrl = flag(rest, '--run-url');
|
||||
const bundleLane = flag(rest, '--bundle-lane');
|
||||
const checked = runs.length || runUrl || bundleLane ? runCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
|
||||
const evidence = checked && !('error' in checked) ? checked : undefined;
|
||||
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
|
||||
@@ -248,28 +252,6 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** The run-evidence inputs for `clear --run`: the base branch's run profiles, this repository on GitHub, and the gate's inventory command (a pin-file command, so only with --allow-repo-commands). */
|
||||
function runCheck(root: string, lane: string, allowRepoCommands: boolean): RunCheck | { error: string; profiled?: boolean } {
|
||||
const base = resolveBaseBranch(root);
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
// Unknown profiles are treated as present: a lane is cleared unverified only once the base shows it has no profile.
|
||||
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
|
||||
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
|
||||
const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(gitOut(root, ['config', '--get', 'remote.origin.url']) ?? ''))?.[1];
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
inventory: (command, tree) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
|
||||
const listed = runInventory(command, root);
|
||||
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
|
||||
return listed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gitOut(root: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
return r.status === 0 ? r.stdout.trim() : null;
|
||||
|
||||
+95
-11
@@ -88,7 +88,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
|
||||
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
|
||||
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
|
||||
| `remote_gate_runs` | `{ profiles, inventory }`: the run profiles that together clear `pregate.json/remote_gate`, and the command that prints the remote gate's test files, one per line, which their runs must cover (see [run evidence](#run-evidence)) |
|
||||
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
|
||||
|
||||
@@ -173,7 +173,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
|
||||
|
||||
```
|
||||
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
|
||||
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
|
||||
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
|
||||
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
|
||||
```
|
||||
|
||||
@@ -668,15 +668,20 @@ old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires
|
||||
`tree.json` to record that runner OS. Each workflow uploads `tree.json` from
|
||||
its own job: `run_id`, `run_attempt`, `event`, `sha` (or `head_sha`),
|
||||
`tree` (`git rev-parse HEAD^{tree}` as tested), `files` (every test file the
|
||||
run executed), `tests` (the count) and, with `os`, the runner OS.
|
||||
run executed), `tests` (the count) and, with `os`, the runner OS or a list of
|
||||
every OS the run's test jobs ran on (the profile's `os` must be one of them).
|
||||
|
||||
**What `clear --run` checks** (`lib/run-evidence.ts`), for every run, online,
|
||||
once:
|
||||
|
||||
- The run belongs to this repository and ran for a same-repository head. Its
|
||||
workflow file is the profile's, its event is `push` or `workflow_dispatch`
|
||||
(a `pull_request` run tests the merge commit, not the head tree, so no
|
||||
profile may accept it), and it completed with success. The API answers with
|
||||
workflow file is the profile's, its event is one the profile lists (`push`,
|
||||
`workflow_dispatch` or `pull_request`), and it completed with success. A
|
||||
`pull_request` run tested GitHub's merge commit, not its head, so its
|
||||
`tree.json` must name the head (`head_sha`) and the merge commit (`sha`), and
|
||||
the tree git computes for that merge commit must equal the head's tree: the
|
||||
head already contained its base, so the merge tested exactly the head tree
|
||||
(`tree_mismatch` otherwise). The API answers with
|
||||
the newest attempt, so an earlier green attempt under a newer red one is
|
||||
refused, and the run is read again after the evidence is collected: a new
|
||||
attempt meanwhile is `run_changed`.
|
||||
@@ -744,9 +749,21 @@ recording. When the base config or the GitHub origin cannot be read, `--run`
|
||||
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
|
||||
shows the lane has no profile; then the lane is recorded as before, with a
|
||||
warning naming the cause. `gstack-ship-queue ready --dispatch-ci` still
|
||||
records its dispatched runs by URL; routing it through the verifier is the next
|
||||
change (P3b), so a repository that declares lane profiles clears those lanes
|
||||
with `gstack-pregate clear --run`.
|
||||
records its dispatched runs by URL; routing it through the verifier is a
|
||||
follow-up (GST-555), so a repository that declares lane profiles clears those
|
||||
lanes with `gstack-pregate clear --run`.
|
||||
|
||||
**gstack's own profiles.** gstack's `.gstack/pregate.json` declares two, and
|
||||
names neither after a lane: `ci-reuse/free-tests` (the complete Free Tests run:
|
||||
every non-matrix job and the `free-tests` aggregate, `os` Linux) and
|
||||
`ci-reuse/windows-free-tests` (`windows-plan` and the `windows-free-tests`
|
||||
aggregate, `os` Windows). Each accepts `pull_request` and `workflow_dispatch`
|
||||
runs and reads the `tested-tree` artifact, which both aggregates upload only
|
||||
after their strict verification passes. The main-push reuse job and
|
||||
`--from-run` read them. No lane has a profile, so every macOS and Windows lane
|
||||
still clears by `--run-url` as before. A pull request cut before these
|
||||
workflows uploaded `tested-tree` has no record a lane profile could verify, so
|
||||
lane profiles wait until open pull requests carry the producer (GST-555).
|
||||
|
||||
**`verify` stays offline.** The check ran at `clear`. `gstack-pregate verify`,
|
||||
and `gstack-ship-queue ready` through it, refuse a run receipt whose `tree=`
|
||||
@@ -756,8 +773,75 @@ merge path.
|
||||
|
||||
**What is never reusable.** Security and advisory scans (osv, audit,
|
||||
gitleaks) depend on the day, not on the tree, so they are not run profiles:
|
||||
they run on every head as before. Pull-request required checks always run
|
||||
fresh.
|
||||
they run on every head as before, and `--from-run` never skips them.
|
||||
Pull-request required checks and dispatched runs always run fresh; only a
|
||||
push to main reuses ([below](#ci-reuse)).
|
||||
|
||||
<a id="from-run"></a>
|
||||
### The tests stage from a Free Tests run (`--from-run`)
|
||||
|
||||
`gstack-pregate --stage tests --from-run <run id|url>` takes a Free Tests run
|
||||
that already passed on this exact tree, for example the one CI ran on the
|
||||
pushed head, instead of running its files again here. The run is verified
|
||||
online, once, against the base branch's run profiles for this machine's OS
|
||||
whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests`
|
||||
on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no
|
||||
stamp-only substitution). Then:
|
||||
|
||||
- The selected local test files the run's `tree.json` lists count as passed.
|
||||
The lanes check records them under `from_run` with the run receipt
|
||||
(`run tree=… tests=… runs=<id>/<attempt> profiles=… heads=… verified=…`) and
|
||||
prints `REUSE: accepted <n> of <m> selected local test file(s) from <receipt>;
|
||||
<k> still run`.
|
||||
- The rest run as they would have: locally, or recorded as the remote gate
|
||||
obligation on a small machine, or through `--remote`.
|
||||
- A run that does not verify prints `REUSE: refused reason=<code> — …; every
|
||||
selected test runs here` and changes nothing else. The codes are
|
||||
verifyRun's, plus `tree_mismatch` (another tree), `no_tests`, `no_profile`
|
||||
(no profile for this OS covers a local lane's workflow), `no_verified_run`
|
||||
(more than one profile applied and each refused; the detail lists every
|
||||
refusal) and `profiles_unavailable` (the base config or the GitHub origin
|
||||
could not be read).
|
||||
|
||||
`--from-run` applies to the tests stage only, so `--stage preflight` refuses it
|
||||
as a usage error. It changes nothing in the preflight stage: with `--stage all`
|
||||
the preflight checks, the secrets scan included, run as before, and
|
||||
`--stage tests` runs no preflight check in that invocation, with or without it.
|
||||
|
||||
<a id="ci-reuse"></a>
|
||||
### Main pushes reuse the merged pull request's run
|
||||
|
||||
A squash merge whose pull request head already held main produces a push with
|
||||
the identical tree, and Free Tests and Windows Free Tests used to run on it a
|
||||
second time. Now each workflow starts with a `reuse` job that runs only on a
|
||||
push to `main`, with job-level `contents: read` and `actions: read` (every other
|
||||
job keeps the workflow's `contents: read`). It runs
|
||||
`scripts/ci-tree-reuse.ts find`, which:
|
||||
|
||||
1. finds the pull request whose merge commit is the pushed commit
|
||||
(`repos/{repo}/commits/{sha}/pulls`, one page of at most 10), merged into
|
||||
main from a same-repository head (`no_pr`, `ambiguous_pr`, `fork_head`);
|
||||
2. requires that head's tree to equal the pushed tree (`tree_mismatch`);
|
||||
3. lists that head's `pull_request` and `workflow_dispatch` runs of the same
|
||||
workflow, newest first, at most 10 (`no_candidate`); and
|
||||
4. accepts the first one `verifyRun` accepts under the profile in the pushed
|
||||
tree's `.gstack/pregate.json` (`no_verified_run` lists each refusal; a newer
|
||||
red run on the head vetoes an older green one).
|
||||
|
||||
It writes `reused=true` with the run's id and link, or `reused=false` and the
|
||||
reason, and prints one `REUSE:` line in the step summary. Every suite job
|
||||
needs `reuse` and runs unless it succeeded with `reused=true`; their
|
||||
conditions start with `!cancelled()` because GitHub otherwise skips a job whose
|
||||
needed job was skipped, and `reuse` is skipped on every pull request and
|
||||
dispatch run. The required aggregates (`free-tests`, `windows-free-tests`)
|
||||
choose a path in their first step, before any download. A verified reuse writes
|
||||
the reused run's link to the summary and passes; anything else, a failed or
|
||||
skipped `reuse` job included, takes the strict path unchanged. Only the strict
|
||||
path records and uploads `tested-tree`, after its verification passes, so a
|
||||
red, partial or reused run never leaves a record and a reuse is never reused.
|
||||
The pull request lookup needs no `pull-requests` permission on a public
|
||||
repository; a private fork of this setup would need `pull-requests: read`,
|
||||
and without it every push falls back to the full run.
|
||||
|
||||
<a id="guards"></a>
|
||||
## Guards and the lane-done rule
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface PregateCheck {
|
||||
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
|
||||
/** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */
|
||||
proven?: string[];
|
||||
/** `lanes` under `--from-run`: the run receipt and the selected local test files that run already passed on this tree (not run again here). */
|
||||
from_run?: { receipt: string; files: string[] };
|
||||
}
|
||||
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
|
||||
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
|
||||
@@ -219,7 +221,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
type: 'object', required: ['schema_version', 'tree', 'checks'],
|
||||
properties: {
|
||||
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } },
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList, from_run: { type: 'object', required: ['receipt', 'files'], properties: { receipt: str, files: strList } } } } },
|
||||
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str,
|
||||
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
|
||||
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
* and a deleted test runs its owning suite or names the files that lost their
|
||||
* last test (issue #3529). Any other removal owes what an edit owes.
|
||||
* A test that reads the file still selects that test.
|
||||
* Under `--from-run <run>` the selected local test files a verified Free Tests
|
||||
* run on this exact tree lists count as passed (recorded as `from_run` with the
|
||||
* run receipt) and only the rest run; a refused run runs everything.
|
||||
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
|
||||
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
|
||||
*/
|
||||
@@ -36,6 +39,7 @@ import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { cargoModel, type CargoModel } from '../rust';
|
||||
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
|
||||
import { verifyFromRun } from '../../run-evidence';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
||||
|
||||
@@ -544,11 +548,32 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
if (sel.noLane.length && mode === 'strict') {
|
||||
return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) });
|
||||
}
|
||||
const result = withRemoved(await runSelected(ctx, sel, base));
|
||||
const reuse = ctx.fromRun ? fromRun(ctx, sel) : null;
|
||||
if (reuse) ctx.progress(reuse.line);
|
||||
const rest = reuse?.passed.length ? { ...sel, localTests: sel.localTests.filter(t => !reuse.passed.includes(t)) } : sel;
|
||||
const ran = await runSelected(ctx, rest, base);
|
||||
const reused = !reuse ? ran : { ...ran, lines: [reuse.line, ...(ran.lines ?? [])], ...(reuse.receipt ? { from_run: { receipt: reuse.receipt, files: reuse.passed }, detail: `${reuse.passed.length} of ${sel.localTests.length} local test file(s) passed in the --from-run run; ${ran.detail ?? ran.status}` } : {}) };
|
||||
const result = withRemoved(reused);
|
||||
if (!sel.noLane.length) return result;
|
||||
return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* `--from-run`: verify the run against the base branch's profiles for this machine's OS whose workflow holds a lane this
|
||||
* machine runs (not a platform lane), on exactly this tree; the selected local test files its tree.json lists are the
|
||||
* ones it already passed. Profiles are matched by workflow and OS, never by name, so none has to be named after a lane.
|
||||
*/
|
||||
function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: string[]; receipt?: string } {
|
||||
const { ref, check } = ctx.fromRun!;
|
||||
const os = ({ linux: 'Linux', darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[ctx.platform];
|
||||
const names = 'error' in check ? [] : Object.entries(check.profiles).filter(([, p]) => p.os === os && ctx.lanes.some(l => l.tier !== 'platform' && l.id.startsWith(`${p.workflow}/`))).map(([n]) => n);
|
||||
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
|
||||
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
|
||||
const ran = new Set(v.evidence.files);
|
||||
const passed = sel.localTests.filter(t => ran.has(t));
|
||||
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt };
|
||||
}
|
||||
|
||||
/**
|
||||
* The fix line for PREGATE_NO_LANE. When the branch edits `.gstack/pregate.json`
|
||||
* while the policy comes from elsewhere (the base copy, or the user-owned file),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The run-evidence inputs one consumer needs (GSTA-156): the base branch's run
|
||||
* profiles, this repository on GitHub, the verifier's gh- and git-backed
|
||||
* dependencies, and the remote gate's inventory command (a pin-file command,
|
||||
* so it runs only with --allow-repo-commands). Shared by `gstack-pregate
|
||||
* clear` and `gstack-pregate --from-run`, so both read profiles from the
|
||||
* base and nowhere else.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { ghRunDeps, runInventory } from '../run-evidence';
|
||||
import { wtreeFingerprint } from '../script-invocation';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { normalizeRemoteUrl } from '../ship-queue-train-record';
|
||||
import { REMOTE_GATE_LANE } from './checks/lanes';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase } from './config';
|
||||
import type { RunCheck } from './index';
|
||||
|
||||
/**
|
||||
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
|
||||
* recorded unverified only once the base shows it has none.
|
||||
*/
|
||||
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } {
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
|
||||
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
|
||||
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(origin.status === 0 ? origin.stdout.trim() : ''))?.[1];
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
inventory: (command, tree) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
|
||||
const listed = runInventory(command, root);
|
||||
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
|
||||
return listed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { PregateCheck } from '../headless-artifacts';
|
||||
import type { Registry } from '../regen';
|
||||
import type { RunSource } from '../run-evidence';
|
||||
import type { PregateConfig } from './config';
|
||||
import type { Lane } from './workflows';
|
||||
|
||||
@@ -61,6 +62,8 @@ export interface PregateContext {
|
||||
cores: number;
|
||||
testBackend: string;
|
||||
policyFrom: 'base' | 'head';
|
||||
/** `--from-run`: a Free Tests run whose tree.json files count as passed for this tree (lanes, lib/run-evidence.ts verifyFromRun), with the base branch's run check or why it could not be built. */
|
||||
fromRun?: { ref: string; check: RunSource | { error: string } };
|
||||
/** `--remote`: run the declared remote gate now, under this spend cap. */
|
||||
remote: { spendCapUsd: number } | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
+43
-5
@@ -22,7 +22,10 @@
|
||||
* not hide a failure in between); it finished within the profile's
|
||||
* max_age_hours (default 24); its runner OS matches when the profile names
|
||||
* one; and a second read of the run still shows the same successful attempt
|
||||
* (run_changed otherwise).
|
||||
* (run_changed otherwise). A pull_request run tested GitHub's merge commit,
|
||||
* not its head, so it counts only when its tree.json names that head and a
|
||||
* merge commit (sha) whose tree git computes equal to the head's tree: the
|
||||
* head already contained its base, so the merge tested the head tree.
|
||||
*
|
||||
* Profiles are the base branch's `.gstack/pregate.json` `run_profiles`, so a
|
||||
* pull request can tighten them but never relax them. The workflow a run
|
||||
@@ -65,6 +68,7 @@ export interface RunEvidence { run_id: number; run_attempt: number; head_sha: st
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os'];
|
||||
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
|
||||
@@ -96,7 +100,7 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
const v = p[k];
|
||||
if (!Array.isArray(v) || v.length === 0 || v.length > 32 || v.some(s => typeof s !== 'string' || !s.trim())) errors.push({ path: `${at}.${k}`, message: 'must be a non-empty array of up to 32 names' });
|
||||
}
|
||||
if (Array.isArray(p.events) && p.events.some(e => e !== 'push' && e !== 'workflow_dispatch')) errors.push({ path: `${at}.events`, message: 'events are push or workflow_dispatch (a pull_request run tests the merge commit, not the head tree)' });
|
||||
if (Array.isArray(p.events) && p.events.some(e => !RUN_EVENTS.includes(e))) errors.push({ path: `${at}.events`, message: 'events are push, workflow_dispatch or pull_request (a pull_request run counts only when its merge commit\'s tree is its head\'s tree)' });
|
||||
if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' });
|
||||
if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' });
|
||||
if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' });
|
||||
@@ -164,13 +168,21 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
catch (e: any) { return fail('artifact_missing', `${at} has no readable ${p.artifact} tree.json: ${String(e?.message ?? e).slice(0, 200)}`); }
|
||||
if (!rec || typeof rec !== 'object') return fail('artifact_mismatch', `${at}'s ${p.artifact} tree.json is not a JSON object`);
|
||||
if (rec.reused === true || rec.reuse === true) return fail('artifact_reused', `${at} reused another run; a reuse is never evidence`);
|
||||
const recHeads = rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const pr = run.event === 'pull_request';
|
||||
const recHeads = pr ? [['head_sha', rec.head_sha, run.head_sha] as const] : rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const wrong = ([['run_id', rec.run_id, id], ['run_attempt', rec.run_attempt, run.run_attempt], ['event', rec.event, run.event], ...recHeads] as const).filter(([, got, want]) => got !== want);
|
||||
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
|
||||
const headTree = deps.commitTree(run.head_sha);
|
||||
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
|
||||
if (rec.tree !== headTree) return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
|
||||
if (p.os && rec.os !== p.os) return fail('os_mismatch', `${at} ran on ${String(rec.os ?? 'an unrecorded OS')}; ${o.profileName} requires ${p.os}`);
|
||||
if (pr) {
|
||||
if (typeof rec.sha !== 'string' || !SHA.test(rec.sha)) return fail('artifact_mismatch', `${at}'s ${p.artifact} names no merge commit (sha) for its pull_request run`);
|
||||
const mergeTree = deps.commitTree(rec.sha);
|
||||
if (!mergeTree) return fail('api_error', `git cannot read the tree of merge commit ${rec.sha}`);
|
||||
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base`);
|
||||
}
|
||||
const oses = Array.isArray(rec.os) ? rec.os : [rec.os];
|
||||
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
|
||||
if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`);
|
||||
if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`);
|
||||
const files = ((rec.files ?? []) as string[]).map(normalPath);
|
||||
@@ -202,6 +214,29 @@ export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): st
|
||||
return inventory.filter(f => !ran.has(normalPath(f)));
|
||||
}
|
||||
|
||||
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
* `gstack-pregate --stage tests --from-run <run>`: the run as evidence for `tree` under the first profile in `names`
|
||||
* it verifies against (the base branch's profiles for this machine's OS whose workflow holds a local lane), with `head` as a further veto
|
||||
* head. The run must have tested exactly this tree (no stamp-only substitution) and list the files it ran.
|
||||
*/
|
||||
export function verifyFromRun(src: RunSource, ref: string, names: string[], tree: string, head: string): FromRunVerdict {
|
||||
if (!names.length) return { ok: false, reason: 'no_profile', detail: 'the base branch declares no run_profiles entry for this machine\'s OS whose workflow holds a lane this machine runs (gstack\'s ci-reuse/free-tests, for example)' };
|
||||
const refusals: Array<{ reason: string; detail: string }> = [];
|
||||
for (const name of names) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: ref, profileName: name, profile: src.profiles[name]!, vetoHeads: SHA.test(head) ? [head] : [], now: src.now });
|
||||
if (!v.ok) { refusals.push({ reason: v.reason, detail: `${name}: ${v.detail}` }); continue; }
|
||||
const e = v.evidence;
|
||||
if (e.tree !== tree) return { ok: false, reason: 'tree_mismatch', detail: `run ${e.run_id} tested tree ${e.tree.slice(0, 12)}, not this tree ${tree.slice(0, 12)}` };
|
||||
if (!e.files.length || e.tests === 0) return { ok: false, reason: 'no_tests', detail: `run ${e.run_id}'s tree.json ${e.files.length ? `records 0 tests for ${e.files.length} file(s)` : 'lists no test files'}` };
|
||||
return { ok: true, evidence: e, receipt: `run tree=${tree.slice(0, 12)} tests=${e.tests ?? e.files.length} ${runReceiptFields([e], src.now)}` };
|
||||
}
|
||||
return refusals.length === 1 ? { ok: false, ...refusals[0]! } : { ok: false, reason: 'no_verified_run', detail: refusals.map(r => `${r.detail} (${r.reason})`).join(' | ') };
|
||||
}
|
||||
|
||||
/** The receipt suffix a run clear records: runs, profiles and the verification time, read back by `parseRunReceipt`. */
|
||||
export function runReceiptFields(evidence: RunEvidence[], now: Date): string {
|
||||
return `runs=${evidence.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} profiles=${evidence.map(e => e.profile).join(',')} heads=${[...new Set(evidence.map(e => e.head_sha.slice(0, 12)))].join(',')} verified=${now.toISOString()}`;
|
||||
@@ -215,7 +250,7 @@ export function parseRunReceipt(receipt: string): { runs: string[]; profiles: st
|
||||
}
|
||||
|
||||
/** Egress receipts for the reads below (fail-open, like contributor-mode): only this repository's name, run ids and a commit sha leave the machine. */
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'user ran gstack-pregate clear --run (or --run-url on a profiled lane); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'a run verification the user or the repository\'s own workflow started (gstack-pregate clear --run or --from-run, the main-push reuse job); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFailed = (err: unknown) => process.stderr.write(`[run-evidence] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
|
||||
|
||||
function gh(args: string[], timeoutMs = 60_000): string {
|
||||
@@ -225,6 +260,9 @@ function gh(args: string[], timeoutMs = 60_000): string {
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** One `gh api` read on this repository's own data, receipted like every read here. */
|
||||
export function ghApiJson<T>(apiPath: string): T { return JSON.parse(gh(['api', apiPath])) as T; }
|
||||
|
||||
/** The gh- and git-backed dependencies for `repo`, reading trees in `repoRoot` (fetching a missing head from origin once). */
|
||||
export function ghRunDeps(repo: string, repoRoot: string): RunDeps {
|
||||
const json = <T>(p: string): T => JSON.parse(gh(['api', p]));
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.38",
|
||||
"version": "1.92.39",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* ci-tree-reuse — run each free suite once per tree (GSTA-156, docs/pregate.md#ci-reuse).
|
||||
*
|
||||
* bun run scripts/ci-tree-reuse.ts record --plan <plan.json> --results <dir> --os <OS>[,<OS>…] --out <tree.json>
|
||||
* The producer. A Free Tests or Windows Free Tests aggregate runs it only after its strict verification
|
||||
* passed, and uploads the file as the `tested-tree` artifact: run_id, run_attempt, event, sha (GITHUB_SHA),
|
||||
* head_sha (the pull request head for pull_request, else GITHUB_SHA), tree (git's tree of GITHUB_SHA),
|
||||
* workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan) and tests.
|
||||
*
|
||||
* bun run scripts/ci-tree-reuse.ts find --workflow <file.yml> --profile <run_profiles name>
|
||||
* The main-push consumer (the `reuse` job). A push to main whose tree a pull request already tested green
|
||||
* needs no second run: it finds the pull request whose merge commit is the pushed commit (same-repository
|
||||
* head, merged into main), requires that head's tree to equal the pushed tree, lists that head's runs of
|
||||
* this workflow (pull_request or workflow_dispatch, newest first, at most MAX_CANDIDATES), and accepts the
|
||||
* first one lib/run-evidence.ts verifyRun accepts under the profile in the pushed tree's
|
||||
* .gstack/pregate.json. A pull_request run counts only when its merge commit's tree is its head's tree
|
||||
* (verifyRun). It writes reused=true, run_id, run_url and tree to GITHUB_OUTPUT, or reused=false and a
|
||||
* reason, with one REUSE: line in the step summary. Every lookup failure is a refusal, never a pass: the
|
||||
* workflow then runs every job as before. Pull request and dispatch runs never consume.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ghApiJson, ghRunDeps, runProfileErrors, verifyRun, type ApiRun, type RunDeps, type RunProfile } from '../lib/run-evidence';
|
||||
|
||||
export const MAX_PULLS = 10;
|
||||
export const MAX_CANDIDATES = 10;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const OSES = ['Linux', 'macOS', 'Windows'];
|
||||
|
||||
export interface ApiPull { number: number; merged_at: string | null; merge_commit_sha: string | null; base: { ref: string; repo?: { full_name: string } | null }; head: { sha: string; repo: { full_name: string } | null } }
|
||||
export interface ReuseDeps extends RunDeps { pullsForCommit(sha: string): ApiPull[] }
|
||||
export type ReuseVerdict =
|
||||
| { reused: true; run_id: number; run_attempt: number; event: string; pr: number; head_sha: string; tree: string }
|
||||
| { reused: false; reason: string; detail: string };
|
||||
|
||||
const refuse = (reason: string, detail: string): ReuseVerdict => ({ reused: false, reason, detail });
|
||||
const startedAt = (r: ApiRun) => r.run_started_at ?? r.created_at ?? '';
|
||||
|
||||
/** The run a push of `pushSha` to `base` may reuse, or why none. Pure over `deps`, so every refusal has a fixture. */
|
||||
export function findReuse(deps: ReuseDeps, o: { repo: string; base: string; pushSha: string; workflow: string; profileName: string; profile: RunProfile; now: Date }): ReuseVerdict {
|
||||
if (!SHA.test(o.pushSha)) return refuse('usage', `${JSON.stringify(o.pushSha)} is not a commit sha`);
|
||||
if (o.profile.workflow !== o.workflow) return refuse('profile_mismatch', `profile ${o.profileName} names ${o.profile.workflow}, not ${o.workflow}`);
|
||||
try {
|
||||
const pulls = deps.pullsForCommit(o.pushSha).slice(0, MAX_PULLS);
|
||||
const merged = pulls.filter(p => p.merged_at && p.base.ref === o.base && p.merge_commit_sha === o.pushSha && (!p.base.repo || p.base.repo.full_name.toLowerCase() === o.repo.toLowerCase()));
|
||||
if (!merged.length) return refuse('no_pr', `no pull request merged into ${o.base} has ${o.pushSha.slice(0, 12)} as its merge commit (${pulls.length} associated)`);
|
||||
if (merged.length > 1) return refuse('ambiguous_pr', `${merged.length} merged pull requests name ${o.pushSha.slice(0, 12)}: ${merged.map(p => `#${p.number}`).join(', ')}`);
|
||||
const pr = merged[0]!;
|
||||
if ((pr.head.repo?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return refuse('fork_head', `#${pr.number}'s head is on ${pr.head.repo?.full_name ?? 'a deleted repository'}, not ${o.repo}`);
|
||||
if (!SHA.test(pr.head.sha)) return refuse('api_error', `#${pr.number} has no head commit`);
|
||||
const pushTree = deps.commitTree(o.pushSha);
|
||||
const headTree = deps.commitTree(pr.head.sha);
|
||||
if (!pushTree || !headTree) return refuse('api_error', `cannot read the tree of ${pushTree ? pr.head.sha : o.pushSha}`);
|
||||
if (headTree !== pushTree) return refuse('tree_mismatch', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has tree ${headTree.slice(0, 12)}, the push has ${pushTree.slice(0, 12)}`);
|
||||
const events: string[] = o.profile.events.filter(e => e === 'pull_request' || e === 'workflow_dispatch');
|
||||
const candidates = deps.runsForHead(o.workflow, pr.head.sha).filter(r => events.includes(r.event))
|
||||
.sort((a, b) => startedAt(b).localeCompare(startedAt(a)) || b.id - a.id).slice(0, MAX_CANDIDATES);
|
||||
if (!candidates.length) return refuse('no_candidate', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has no ${events.join(' or ') || 'pull_request or workflow_dispatch'} run of ${o.workflow}`);
|
||||
const refusals: string[] = [];
|
||||
for (const run of candidates) {
|
||||
const v = verifyRun(deps, { repo: o.repo, runRef: String(run.id), profileName: o.profileName, profile: o.profile, now: o.now });
|
||||
if (!v.ok) { refusals.push(`run ${run.id} (${run.event}) reason=${v.reason} ${v.detail}`); continue; }
|
||||
if (v.evidence.tree !== pushTree) { refusals.push(`run ${run.id} tested tree ${v.evidence.tree.slice(0, 12)}`); continue; }
|
||||
return { reused: true, run_id: v.evidence.run_id, run_attempt: v.evidence.run_attempt, event: v.evidence.event, pr: pr.number, head_sha: pr.head.sha, tree: pushTree };
|
||||
}
|
||||
return refuse('no_verified_run', refusals.join(' | '));
|
||||
} catch (e: any) {
|
||||
return refuse('api_error', String(e?.message ?? e).slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
/** tree.json for the `tested-tree` artifact; throws on any missing or malformed input, so a broken record fails the aggregate instead of uploading. */
|
||||
export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number } } }>; os: string[]; tree: string }) {
|
||||
const int = (name: string) => { const v = Number(env[name]); if (!Number.isSafeInteger(v) || v < 1) throw new Error(`${name} is not a positive integer`); return v; };
|
||||
const sha = env.GITHUB_SHA ?? '';
|
||||
const event = env.GITHUB_EVENT_NAME ?? '';
|
||||
const head = event === 'pull_request' ? env.PR_HEAD_SHA ?? '' : sha;
|
||||
if (!SHA.test(sha) || !SHA.test(head)) throw new Error(`GITHUB_SHA ${JSON.stringify(sha)} or the head ${JSON.stringify(head)} is not a commit sha (PR_HEAD_SHA is required for pull_request)`);
|
||||
if (!/^[a-z_]+$/.test(event)) throw new Error(`GITHUB_EVENT_NAME ${JSON.stringify(event)} is not an event name`);
|
||||
const workflow = /^[^/]+\/[^/]+\/(\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)@/.exec(env.GITHUB_WORKFLOW_REF ?? '')?.[1];
|
||||
if (!workflow) throw new Error(`GITHUB_WORKFLOW_REF ${JSON.stringify(env.GITHUB_WORKFLOW_REF)} names no workflow file`);
|
||||
if (!/^[0-9a-f]{40,64}$/.test(o.tree)) throw new Error(`${JSON.stringify(o.tree)} is not a tree id`);
|
||||
if (!o.os.length || o.os.some(x => !OSES.includes(x))) throw new Error(`--os must list some of ${OSES.join(', ')}`);
|
||||
const files = [...new Set(o.plan.shards.flatMap(s => s.files))].sort();
|
||||
if (!files.length) throw new Error('the plan lists no test files');
|
||||
const counts = o.results.map(r => r.outcome?.summary?.testsRan);
|
||||
const tests = o.results.length && counts.every(n => Number.isSafeInteger(n) && n! >= 0) ? counts.reduce((a, b) => a! + b!, 0)! : files.length;
|
||||
return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests };
|
||||
}
|
||||
|
||||
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
|
||||
function need(args: string[], name: string): string { const v = flag(args, name); if (!v || v.startsWith('--')) throw new Error(`${name} <value> is required`); return v; }
|
||||
|
||||
function record(args: string[]): number {
|
||||
const plan = JSON.parse(fs.readFileSync(need(args, '--plan'), 'utf8'));
|
||||
const dir = need(args, '--results');
|
||||
const results = fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
||||
const git = spawnSync('git', ['rev-parse', '--verify', `${process.env.GITHUB_SHA ?? ''}^{tree}`], { encoding: 'utf8', timeout: 30_000 });
|
||||
const rec = treeRecord(process.env, { plan, results, os: need(args, '--os').split(','), tree: git.status === 0 ? git.stdout.trim() : '' });
|
||||
const out = need(args, '--out');
|
||||
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
||||
fs.writeFileSync(out, JSON.stringify(rec, null, 2) + '\n');
|
||||
console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} os=${rec.os.join(',')}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The consumer's GITHUB_OUTPUT and step-summary text for a verdict. reused=true is written last, after the run it names. */
|
||||
export function reuseOutputs(v: ReuseVerdict, repo: string, server = 'https://github.com'): { line: string; output: string } {
|
||||
if (!v.reused) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every job runs`, output: `reused=false\nreason=${v.reason}\n` };
|
||||
const url = `${server}/${repo}/actions/runs/${v.run_id}`;
|
||||
return { line: `REUSE: accepted run ${v.run_id} (${v.event}, attempt ${v.run_attempt}) of #${v.pr} at ${v.head_sha.slice(0, 12)}, tree ${v.tree.slice(0, 12)}: ${url}`, output: `run_id=${v.run_id}\nrun_url=${url}\ntree=${v.tree}\nreused=true\n` };
|
||||
}
|
||||
|
||||
function find(args: string[]): number {
|
||||
const env = process.env;
|
||||
const workflow = need(args, '--workflow');
|
||||
const profileName = need(args, '--profile');
|
||||
const repo = env.GITHUB_REPOSITORY ?? '';
|
||||
const settle = (v: ReuseVerdict) => {
|
||||
const { line, output } = reuseOutputs(v, repo, env.GITHUB_SERVER_URL);
|
||||
console.log(line);
|
||||
if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${line}\n`);
|
||||
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, output);
|
||||
return 0;
|
||||
};
|
||||
if (env.GITHUB_EVENT_NAME !== 'push' || env.GITHUB_REF !== 'refs/heads/main') return settle(refuse('not_main_push', `${env.GITHUB_EVENT_NAME ?? 'no'} event on ${env.GITHUB_REF ?? 'no ref'}; only a push to main consumes`));
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return settle(refuse('usage', `GITHUB_REPOSITORY ${JSON.stringify(repo)} is not owner/name`));
|
||||
let profiles: Record<string, RunProfile>;
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync('.gstack/pregate.json', 'utf8'));
|
||||
const errors = runProfileErrors(config.run_profiles, undefined);
|
||||
if (errors.length) return settle(refuse('profile_invalid', errors.map(e => `${e.path}: ${e.message}`).join('; ')));
|
||||
profiles = config.run_profiles ?? {};
|
||||
} catch (e: any) { return settle(refuse('profile_invalid', `.gstack/pregate.json: ${String(e?.message ?? e).slice(0, 200)}`)); }
|
||||
const profile = profiles[profileName];
|
||||
if (!profile) return settle(refuse('profile_invalid', `.gstack/pregate.json declares no run profile ${profileName}`));
|
||||
const deps: ReuseDeps = {
|
||||
...ghRunDeps(repo, process.cwd()),
|
||||
commitTree: sha => (SHA.test(sha) ? ghApiJson<{ tree?: { sha?: string } }>(`repos/${repo}/git/commits/${sha}`).tree?.sha ?? null : null),
|
||||
pullsForCommit: sha => ghApiJson<ApiPull[]>(`repos/${repo}/commits/${sha}/pulls?per_page=${MAX_PULLS}`),
|
||||
};
|
||||
return settle(findReuse(deps, { repo, base: 'main', pushSha: env.GITHUB_SHA ?? '', workflow, profileName, profile, now: new Date() }));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
if (verb === 'record') process.exitCode = record(args);
|
||||
else if (verb === 'find') process.exitCode = find(args);
|
||||
else { console.error('usage: ci-tree-reuse.ts record --plan <f> --results <dir> --os <OS,…> --out <f> | find --workflow <f.yml> --profile <name>'); process.exitCode = 2; }
|
||||
} catch (e: any) {
|
||||
console.error(`ci-tree-reuse ${verb}: ${e?.message ?? e}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Main-push CI reuse wiring (GSTA-156 P3b) for free-tests.yml and windows-free-tests.yml. Pins the properties that
|
||||
* make reuse safe: only a push to main consumes, the reuse job alone reads Actions with a job-level actions: read,
|
||||
* a skipped or failed reuse job runs every job exactly as before (GitHub skips a job whose needed job was skipped
|
||||
* unless its condition uses a status function, so every dependent condition starts with !cancelled()), the
|
||||
* aggregate's reuse branch runs before any download and the strict path is otherwise unchanged, tested-tree is
|
||||
* uploaded only after strict verification, and the run profiles in .gstack/pregate.json name real jobs.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { parseWorkflow } from '../lib/pregate/workflows';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[] }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'] } };
|
||||
const REUSE_IF = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
|
||||
const SKIP = "steps.route.outputs.reused != 'true'";
|
||||
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record<string, { workflow: string; jobs: string[]; artifact: string; events: string[]; os: string }>;
|
||||
|
||||
/** Evaluate a job condition the way the runner would for one reuse outcome (status functions true: nothing cancelled). */
|
||||
function runs(cond: string, s: { result: string; reused: string; plan?: string }): boolean {
|
||||
const expr = cond.replace(/^\$\{\{\s*|\s*\}\}$/g, '')
|
||||
.replace(/!cancelled\(\)|always\(\)/g, 'true')
|
||||
.replace(/needs\.reuse\.result/g, JSON.stringify(s.result)).replace(/needs\.reuse\.outputs\.reused/g, JSON.stringify(s.reused))
|
||||
.replace(/needs\.(free|windows)-plan\.result/g, JSON.stringify(s.plan ?? 'success')).replace(/inputs\.record_durations/g, 'false');
|
||||
expect(expr).not.toMatch(/needs\.|inputs\.|github\.|steps\./);
|
||||
return new Function(`return (${expr});`)() as boolean;
|
||||
}
|
||||
|
||||
for (const [file, spec] of Object.entries(FILES)) {
|
||||
describe(`${file} main-push reuse wiring`, () => {
|
||||
const source = read(`.github/workflows/${file}`);
|
||||
const wf = Bun.YAML.parse(source) as any;
|
||||
const jobs = wf.jobs as Record<string, any>;
|
||||
const needs = (name: string): string[] => [jobs[name].needs ?? []].flat();
|
||||
const suite = Object.keys(jobs).filter(n => n !== 'reuse' && n !== spec.aggregate && !spec.exempt.includes(n));
|
||||
|
||||
test('only a push to main consumes, with job-level actions: read on the reuse job alone and no secrets', () => {
|
||||
expect(jobs.reuse.if).toBe(REUSE_IF);
|
||||
expect(jobs.reuse.permissions).toEqual({ contents: 'read', actions: 'read' });
|
||||
expect(wf.permissions).toEqual({ contents: 'read' });
|
||||
for (const name of Object.keys(jobs).filter(n => n !== 'reuse')) expect([name, jobs[name].permissions]).toEqual([name, undefined]);
|
||||
expect(source).not.toContain('secrets.');
|
||||
expect(source).not.toContain('pull_request_target');
|
||||
const find = jobs.reuse.steps.find((s: any) => s.id === 'find');
|
||||
expect(find.env).toEqual({ GH_TOKEN: '${{ github.token }}' });
|
||||
expect(find.run).toBe(`bun run scripts/ci-tree-reuse.ts find --workflow ${file} --profile ${spec.profile}`);
|
||||
expect(jobs.reuse.steps[0].with['persist-credentials']).toBe(false);
|
||||
expect(Object.keys(jobs.reuse.outputs).sort()).toEqual(['reused', 'run_id', 'run_url', 'tree']);
|
||||
});
|
||||
|
||||
test('every suite job needs reuse and runs unless reuse succeeded with reused=true; a skipped or failed reuse runs them all', () => {
|
||||
expect(suite.length).toBeGreaterThan(1);
|
||||
for (const name of suite) {
|
||||
expect([name, needs(name)]).toEqual([name, expect.arrayContaining(['reuse'])]);
|
||||
const cond = jobs[name].if as string;
|
||||
expect([name, cond]).toEqual([name, expect.stringMatching(/^\$\{\{ !cancelled\(\) && /)]);
|
||||
expect([name, runs(cond, { result: 'skipped', reused: '' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'success', reused: 'false' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'failure', reused: '' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'failure', reused: 'true' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'success', reused: 'true' })]).toEqual([name, false]);
|
||||
}
|
||||
for (const name of suite.filter(n => needs(n).includes(spec.plan))) expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', plan: 'failure' })]).toEqual([name, false]);
|
||||
expect(needs(spec.aggregate)).toContain('reuse');
|
||||
expect(runs(jobs[spec.aggregate].if, { result: 'success', reused: 'true' })).toBe(true);
|
||||
});
|
||||
|
||||
test('every job downstream of reuse uses a status function, so a skipped reuse never skips it', () => {
|
||||
const downstream = (name: string): boolean => needs(name).some(n => n === 'reuse' || downstream(n));
|
||||
for (const name of Object.keys(jobs).filter(downstream)) expect([name, jobs[name].if]).toEqual([name, expect.stringMatching(/!cancelled\(\)|always\(\)/)]);
|
||||
});
|
||||
|
||||
test('pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
|
||||
for (const lane of parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job))) expect([lane.id, lane.manual]).toEqual([lane.id, false]);
|
||||
});
|
||||
|
||||
test('the aggregate routes first, before any download; the strict path follows unchanged and alone uploads tested-tree after verification', () => {
|
||||
const steps = jobs[spec.aggregate].steps as any[];
|
||||
expect(steps[0].id).toBe('route');
|
||||
expect(steps[0].if).toBeUndefined();
|
||||
for (const step of steps.slice(1)) expect([step.name ?? step.uses, step.if]).toEqual([step.name ?? step.uses, SKIP]);
|
||||
const verify = steps.findIndex(s => s.run?.includes('--ci-verify'));
|
||||
const record = steps.findIndex(s => s.run?.includes('scripts/ci-tree-reuse.ts record'));
|
||||
const upload = steps.findIndex(s => s.with?.name === 'tested-tree');
|
||||
expect(steps.findIndex(s => s.uses?.startsWith('actions/download-artifact@'))).toBeGreaterThan(0);
|
||||
expect(verify).toBeGreaterThan(0);
|
||||
expect(record).toBe(verify + 1);
|
||||
expect(upload).toBe(record + 1);
|
||||
expect(steps.length).toBe(upload + 1);
|
||||
expect(steps[upload].uses).toStartWith('actions/upload-artifact@');
|
||||
expect(steps[upload].with['if-no-files-found']).toBe('error');
|
||||
expect(steps[record].env).toEqual({ PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}' });
|
||||
expect(steps[1].run).toMatch(/set -eu\n(test "\$[A-Z_]+_RESULT" = success\n)+$/);
|
||||
});
|
||||
|
||||
test.skipIf(!Bun.which('bash'))('the route step takes the reuse branch only for a well-formed reuse on a main push, and fails rather than passes on anything else claiming reuse', () => {
|
||||
const route = jobs[spec.aggregate].steps[0];
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reuse route '));
|
||||
const shell = (p: string) => p.split(path.sep).join('/');
|
||||
try {
|
||||
const go = (env: Record<string, string>) => {
|
||||
const out = path.join(dir, 'out'); const summary = path.join(dir, 'summary');
|
||||
fs.writeFileSync(out, ''); fs.writeFileSync(summary, '');
|
||||
const r = spawnSync('bash', ['-e', '-c', route.run], { env: { PATH: process.env.PATH ?? '', GITHUB_OUTPUT: shell(out), GITHUB_STEP_SUMMARY: shell(summary), GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SERVER_URL: 'https://github.com', GITHUB_REPOSITORY: 'garrytan/gstack', REUSE_RESULT: '', REUSED: '', REUSE_RUN_ID: '', REUSE_RUN_URL: '', REUSE_TREE: '', ...env }, encoding: 'utf8', timeout: 5000 });
|
||||
return { status: r.status, out: fs.readFileSync(out, 'utf8'), summary: fs.readFileSync(summary, 'utf8') };
|
||||
};
|
||||
const good = { REUSE_RESULT: 'success', REUSED: 'true', REUSE_RUN_ID: '123', REUSE_RUN_URL: 'https://github.com/garrytan/gstack/actions/runs/123', REUSE_TREE: 'a'.repeat(40) };
|
||||
expect(go({ REUSE_RESULT: 'skipped', GITHUB_EVENT_NAME: 'pull_request' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ REUSE_RESULT: 'success', REUSED: 'false' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ ...good, REUSE_RESULT: 'failure' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
const reused = go(good);
|
||||
expect([reused.status, reused.out]).toEqual([0, 'reused=true\n']);
|
||||
expect(reused.summary).toContain('Reused https://github.com/garrytan/gstack/actions/runs/123');
|
||||
for (const bad of <Record<string, string>[]>[{ REUSE_RUN_ID: '' }, { REUSE_RUN_ID: '12x' }, { REUSE_RUN_URL: 'https://github.com/other/repo/actions/runs/123' }, { REUSE_TREE: '' }, { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_REF: 'refs/heads/feature' }]) {
|
||||
const r = go({ ...good, ...bad });
|
||||
expect([JSON.stringify(bad), r.status === 0, r.out]).toEqual([JSON.stringify(bad), false, '']);
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('its run profile names this workflow, its real non-matrix jobs and the uploaded artifact, and is never named after a lane (lane clears keep their URL path until GST-555)', () => {
|
||||
const profiles = Object.entries(PROFILES).filter(([, p]) => p.workflow === file);
|
||||
expect(profiles.map(([n]) => n)).toContain(spec.profile);
|
||||
for (const [name, p] of profiles) {
|
||||
expect([name, p.events.sort()]).toEqual([name, ['pull_request', 'workflow_dispatch']]);
|
||||
expect([name, p.artifact]).toEqual([name, 'tested-tree']);
|
||||
for (const job of p.jobs) expect([name, job, !!jobs[job] && !jobs[job].strategy]).toEqual([name, job, true]);
|
||||
expect(p.jobs).toContain(spec.aggregate);
|
||||
expect([name, /\.ya?ml\//.test(name)]).toEqual([name, false]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('every workflow that calls one of these grants the calling job actions: read, which the reuse job needs even when it is skipped', () => {
|
||||
const dir = path.join(ROOT, '.github/workflows');
|
||||
const callers: string[] = [];
|
||||
for (const name of fs.readdirSync(dir).filter(f => /\.ya?ml$/.test(f))) {
|
||||
const jobs = (Bun.YAML.parse(read(`.github/workflows/${name}`)) as any)?.jobs ?? {};
|
||||
for (const [id, job] of Object.entries(jobs) as Array<[string, any]>) {
|
||||
const called = /^\.\/\.github\/workflows\/(.+)$/.exec(job?.uses ?? '')?.[1];
|
||||
if (!called || !(called in FILES)) continue;
|
||||
callers.push(`${name}/${id}`);
|
||||
expect([`${name}/${id}`, job.permissions?.actions]).toEqual([`${name}/${id}`, expect.stringMatching(/^(read|write)$/)]);
|
||||
}
|
||||
}
|
||||
expect(callers).toContain('free-durations-record.yml/windows');
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* scripts/ci-tree-reuse.ts (GSTA-156 P3b): the main-push consumer's candidate discovery and the aggregate's tree.json
|
||||
* producer. The GitHub API is a fake (ReuseDeps), so every refusal has a fixture and nothing here reaches GitHub.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import type { ApiJob, ApiRun, RunProfile, TreeRecord } from '../lib/run-evidence';
|
||||
import { MAX_CANDIDATES, findReuse, reuseOutputs, treeRecord, type ApiPull, type ReuseDeps } from '../scripts/ci-tree-reuse';
|
||||
|
||||
const REPO = 'garrytan/gstack';
|
||||
const PUSH = '1'.repeat(40);
|
||||
const HEAD = '2'.repeat(40);
|
||||
const MERGE = '3'.repeat(40);
|
||||
const TREE = '4'.repeat(40);
|
||||
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-plan', 'free-tests'], artifact: 'tested-tree', os: 'Linux' };
|
||||
const SCRIPT = path.resolve(import.meta.dir, '..', 'scripts', 'ci-tree-reuse.ts');
|
||||
|
||||
interface Fake { id: number; event?: string; conclusion?: string | null; status?: string; started?: string; head?: string; record?: Partial<TreeRecord> | Error; jobs?: ApiJob[] }
|
||||
interface World { pulls: ApiPull[] | Error; runs: Fake[]; trees: Record<string, string | null> }
|
||||
const pull = (over: Partial<ApiPull> = {}): ApiPull => ({ number: 42, merged_at: '2026-10-30T11:00:00Z', merge_commit_sha: PUSH, base: { ref: 'main', repo: { full_name: REPO } }, head: { sha: HEAD, repo: { full_name: REPO } }, ...over });
|
||||
function world(over: Partial<World> = {}): World {
|
||||
return { pulls: [pull()], runs: [{ id: 100 }], trees: { [PUSH]: TREE, [HEAD]: TREE, [MERGE]: TREE }, ...over };
|
||||
}
|
||||
function deps(w: World, reads: number[] = []): ReuseDeps {
|
||||
const api = (f: Fake): ApiRun => ({
|
||||
id: f.id, run_attempt: 1, status: f.status ?? 'completed', conclusion: f.conclusion === undefined ? 'success' : f.conclusion, event: f.event ?? 'workflow_dispatch', head_sha: f.head ?? HEAD,
|
||||
path: '.github/workflows/free-tests.yml', created_at: f.started ?? '2026-10-30T10:00:00Z', run_started_at: f.started ?? '2026-10-30T10:00:00Z', updated_at: '2026-10-30T10:30:00Z',
|
||||
repository: { full_name: REPO }, head_repository: { full_name: REPO },
|
||||
});
|
||||
const byId = (id: number) => { const f = w.runs.find(r => r.id === id); if (!f) throw new Error('HTTP 404'); return f; };
|
||||
return {
|
||||
pullsForCommit: () => { if (w.pulls instanceof Error) throw w.pulls; return w.pulls; },
|
||||
run: id => { reads.push(id); return api(byId(id)); },
|
||||
runsForHead: (wf, sha) => (wf === 'free-tests.yml' ? w.runs.filter(r => (r.head ?? HEAD) === sha).map(api) : []),
|
||||
jobs: id => byId(id).jobs ?? [{ name: 'free-plan', conclusion: 'success' }, { name: 'free-tests', conclusion: 'success' }],
|
||||
artifact: id => {
|
||||
const f = byId(id);
|
||||
if (f.record instanceof Error) throw f.record;
|
||||
const pr = (f.event ?? 'workflow_dispatch') === 'pull_request';
|
||||
return { run_id: id, run_attempt: 1, event: f.event ?? 'workflow_dispatch', sha: pr ? MERGE : f.head ?? HEAD, head_sha: f.head ?? HEAD, tree: TREE, os: ['Linux', 'macOS', 'Windows'], files: ['test/a.test.ts'], tests: 9, ...f.record };
|
||||
},
|
||||
commitTree: sha => w.trees[sha] ?? null,
|
||||
};
|
||||
}
|
||||
const find = (w: World, reads?: number[]) => findReuse(deps(w, reads), { repo: REPO, base: 'main', pushSha: PUSH, workflow: 'free-tests.yml', profileName: 'ci-reuse/free-tests', profile: PROFILE, now: NOW });
|
||||
const why = (w: World) => { const v = find(w); return v.reused ? 'reused' : v.reason; };
|
||||
|
||||
describe('findReuse: candidate discovery for a main push', () => {
|
||||
test('the merged pull request\'s green dispatch run of the pushed tree is reused', () => {
|
||||
expect(find(world())).toEqual({ reused: true, run_id: 100, run_attempt: 1, event: 'workflow_dispatch', pr: 42, head_sha: HEAD, tree: TREE });
|
||||
});
|
||||
|
||||
test('a green pull_request run counts when its merge commit tested the head tree, and not when the head was behind main', () => {
|
||||
expect(find(world({ runs: [{ id: 100, event: 'pull_request' }] }))).toMatchObject({ reused: true, run_id: 100, event: 'pull_request' });
|
||||
const behind = find(world({ runs: [{ id: 100, event: 'pull_request' }], trees: { [PUSH]: TREE, [HEAD]: TREE, [MERGE]: '5'.repeat(40) } }));
|
||||
expect(behind).toMatchObject({ reused: false, reason: 'no_verified_run', detail: expect.stringContaining('reason=tree_mismatch') });
|
||||
});
|
||||
|
||||
test('no pull request, an unmerged one, one merged elsewhere, another merge commit, or two candidates: no reuse', () => {
|
||||
expect(why(world({ pulls: [] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ merged_at: null })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ base: { ref: 'release', repo: { full_name: REPO } } })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ merge_commit_sha: MERGE })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull(), pull({ number: 43 })] }))).toBe('ambiguous_pr');
|
||||
});
|
||||
|
||||
test('a fork head is never reused', () => {
|
||||
expect(why(world({ pulls: [pull({ head: { sha: HEAD, repo: { full_name: 'someone/gstack' } } })] }))).toBe('fork_head');
|
||||
expect(why(world({ pulls: [pull({ head: { sha: HEAD, repo: null } })] }))).toBe('fork_head');
|
||||
});
|
||||
|
||||
test('a pull request head whose tree is not the pushed tree is refused before any run is read', () => {
|
||||
const reads: number[] = [];
|
||||
expect(find(world({ trees: { [PUSH]: TREE, [HEAD]: '6'.repeat(40) } }), reads)).toMatchObject({ reused: false, reason: 'tree_mismatch' });
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
test('a reuse record is never evidence: a run that itself reused another run is refused', () => {
|
||||
expect(find(world({ runs: [{ id: 100, record: { reused: true } }] }))).toMatchObject({ reused: false, reason: 'no_verified_run', detail: expect.stringContaining('reason=artifact_reused') });
|
||||
expect(find(world({ runs: [{ id: 100, record: new Error('no artifact named tested-tree') }] }))).toMatchObject({ reused: false, detail: expect.stringContaining('reason=artifact_missing') });
|
||||
});
|
||||
|
||||
test('a newer red run on the head vetoes an older green one', () => {
|
||||
const v = find(world({ runs: [{ id: 100, started: '2026-10-30T10:00:00Z' }, { id: 101, conclusion: 'failure', started: '2026-10-30T10:40:00Z' }] }));
|
||||
expect(v).toMatchObject({ reused: false, reason: 'no_verified_run' });
|
||||
expect(!v.reused && v.detail).toContain('run 101 (workflow_dispatch) reason=run_not_success');
|
||||
expect(!v.reused && v.detail).toContain('run 100 (workflow_dispatch) reason=newer_run_vetoes');
|
||||
expect(why(world({ runs: [{ id: 100, started: '2026-10-30T10:00:00Z' }, { id: 101, status: 'in_progress', conclusion: null, started: '2026-10-30T10:40:00Z' }] }))).toBe('no_verified_run');
|
||||
});
|
||||
|
||||
test('push runs are never candidates, and a red candidate with no older green one is refused', () => {
|
||||
expect(why(world({ runs: [{ id: 100, event: 'push' }] }))).toBe('no_candidate');
|
||||
expect(why(world({ runs: [{ id: 100, conclusion: 'cancelled' }] }))).toBe('no_verified_run');
|
||||
expect(why(world({ runs: [{ id: 100, jobs: [{ name: 'free-plan', conclusion: 'success' }, { name: 'free-tests', conclusion: 'skipped' }] }] }))).toBe('no_verified_run');
|
||||
});
|
||||
|
||||
test('candidates are bounded: at most MAX_CANDIDATES runs are verified, newest first', () => {
|
||||
const runs: Fake[] = Array.from({ length: MAX_CANDIDATES + 5 }, (_, i) => ({ id: 200 + i, conclusion: 'failure', started: `2026-10-30T10:${String(10 + i).padStart(2, '0')}:00Z` }));
|
||||
const reads: number[] = [];
|
||||
expect(find(world({ runs }), reads)).toMatchObject({ reused: false, reason: 'no_verified_run' });
|
||||
expect(new Set(reads).size).toBe(MAX_CANDIDATES);
|
||||
expect(reads[0]).toBe(200 + MAX_CANDIDATES + 4);
|
||||
});
|
||||
|
||||
test('an API failure is a refusal, never a pass', () => {
|
||||
expect(why(world({ pulls: new Error('HTTP 403') }))).toBe('api_error');
|
||||
expect(why(world({ trees: { [HEAD]: TREE } }))).toBe('api_error');
|
||||
});
|
||||
|
||||
test('outputs: reused=true is written last with the run link; a refusal writes reused=false and its reason', () => {
|
||||
const ok = reuseOutputs({ reused: true, run_id: 100, run_attempt: 2, event: 'pull_request', pr: 42, head_sha: HEAD, tree: TREE }, REPO);
|
||||
expect(ok.output).toBe(`run_id=100\nrun_url=https://github.com/${REPO}/actions/runs/100\ntree=${TREE}\nreused=true\n`);
|
||||
expect(ok.line).toStartWith('REUSE: accepted run 100 (pull_request, attempt 2) of #42');
|
||||
expect(reuseOutputs({ reused: false, reason: 'no_pr', detail: 'x' }, REPO)).toEqual({ line: 'REUSE: refused reason=no_pr — x; every job runs', output: 'reused=false\nreason=no_pr\n' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeRecord: the tested-tree producer', () => {
|
||||
const plan = { shards: [{ files: ['test/b.test.ts', 'test/a.test.ts'] }, { files: ['test/c.test.ts'] }] };
|
||||
const results = [{ outcome: { summary: { testsRan: 7 } } }, { outcome: { summary: { testsRan: 5 } } }];
|
||||
const env = (over: Record<string, string> = {}) => ({ GITHUB_RUN_ID: '300', GITHUB_RUN_ATTEMPT: '2', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_SHA: HEAD, GITHUB_WORKFLOW_REF: `${REPO}/.github/workflows/free-tests.yml@refs/heads/x`, ...over });
|
||||
test('a dispatch or push run records its own commit as the head; a pull_request run records the PR head beside the merge commit', () => {
|
||||
expect(treeRecord(env(), { plan, results, os: ['Linux', 'macOS', 'Windows'], tree: TREE })).toEqual({ run_id: 300, run_attempt: 2, event: 'workflow_dispatch', sha: HEAD, head_sha: HEAD, tree: TREE, workflow: '.github/workflows/free-tests.yml', os: ['Linux', 'macOS', 'Windows'], files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 12 });
|
||||
expect(treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE, PR_HEAD_SHA: HEAD }), { plan, results, os: ['Windows'], tree: TREE })).toMatchObject({ event: 'pull_request', sha: MERGE, head_sha: HEAD });
|
||||
expect(treeRecord(env(), { plan, results: [{ outcome: {} }], os: ['Linux'], tree: TREE }).tests).toBe(3);
|
||||
});
|
||||
test('missing or malformed inputs throw, so the aggregate fails instead of uploading a record', () => {
|
||||
expect(() => treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/PR_HEAD_SHA/);
|
||||
expect(() => treeRecord(env({ GITHUB_RUN_ID: '' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_RUN_ID/);
|
||||
expect(() => treeRecord(env({ GITHUB_WORKFLOW_REF: 'x' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_WORKFLOW_REF/);
|
||||
expect(() => treeRecord(env(), { plan, results, os: ['BeOS'], tree: TREE })).toThrow(/--os/);
|
||||
expect(() => treeRecord(env(), { plan, results, os: ['Linux'], tree: '' })).toThrow(/tree id/);
|
||||
expect(() => treeRecord(env(), { plan: { shards: [] }, results, os: ['Linux'], tree: TREE })).toThrow(/no test files/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the CLI', () => {
|
||||
const run = (args: string[], env: Record<string, string>, cwd: string) => spawnSync(process.execPath, [...bunIsolationArgs(), SCRIPT, ...args], { cwd, env: { PATH: process.env.PATH ?? '', HOME: cwd, ...env }, encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
test('find consumes only on a push to main: pull_request and dispatch runs write reused=false without reading anything', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-reuse-'));
|
||||
try {
|
||||
for (const [event, ref] of [['pull_request', 'refs/pull/1/merge'], ['workflow_dispatch', 'refs/heads/main'], ['push', 'refs/heads/feature']]) {
|
||||
const out = path.join(dir, 'out');
|
||||
fs.writeFileSync(out, '');
|
||||
const r = run(['find', '--workflow', 'free-tests.yml', '--profile', 'ci-reuse/free-tests'], { GITHUB_EVENT_NAME: event!, GITHUB_REF: ref!, GITHUB_REPOSITORY: REPO, GITHUB_SHA: PUSH, GITHUB_OUTPUT: out, PATH: '/nonexistent' }, dir);
|
||||
expect([event, r.status]).toEqual([event, 0]);
|
||||
expect(fs.readFileSync(out, 'utf8')).toBe('reused=false\nreason=not_main_push\n');
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('record writes tree.json from the checkout\'s own tree and the plan', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-reuse-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gitconfig-fixture'), '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n');
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(dir, '.gitconfig-fixture'), GIT_CONFIG_NOSYSTEM: '1' } }).stdout.trim();
|
||||
git('init', '-q');
|
||||
fs.writeFileSync(path.join(dir, 'f'), 'x\n');
|
||||
git('add', 'f', '.gitconfig-fixture');
|
||||
git('commit', '-qm', 'c');
|
||||
const sha = git('rev-parse', 'HEAD');
|
||||
fs.writeFileSync(path.join(dir, 'plan.json'), JSON.stringify({ shards: [{ files: ['test/a.test.ts'] }] }));
|
||||
fs.mkdirSync(path.join(dir, 'results'));
|
||||
fs.writeFileSync(path.join(dir, 'results', 'shard-1.json'), JSON.stringify({ outcome: { summary: { testsRan: 4 } } }));
|
||||
const r = run(['record', '--plan', 'plan.json', '--results', 'results', '--os', 'Linux', '--out', 'tt/tree.json'], { GITHUB_RUN_ID: '9', GITHUB_RUN_ATTEMPT: '1', GITHUB_EVENT_NAME: 'push', GITHUB_SHA: sha, GITHUB_WORKFLOW_REF: `${REPO}/.github/workflows/free-tests.yml@refs/heads/main` }, dir);
|
||||
expect([r.status, r.stderr]).toEqual([0, '']);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'tt', 'tree.json'), 'utf8'))).toMatchObject({ run_id: 9, event: 'push', sha, head_sha: sha, tree: git('rev-parse', 'HEAD^{tree}'), files: ['test/a.test.ts'], tests: 4, os: ['Linux'] });
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -408,8 +408,16 @@ describe('CSO runtime staging gates', () => {
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
const required = workflow.jobs['free-tests'];
|
||||
expect(required.if).toBe('always()');
|
||||
expect(required.needs).toEqual(['free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(required.steps[0].run).toContain('test "$CSO_DOCKER_RESULT" = success');
|
||||
expect(required.needs).toEqual(['reuse', 'free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(job.if).toBe("${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}");
|
||||
const route = required.steps[0];
|
||||
expect(route.id).toBe('route');
|
||||
expect(route.env.REUSE_RESULT).toBe('${{ needs.reuse.result }}');
|
||||
expect(route.run).toContain('if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then');
|
||||
const strict = required.steps.find((step: any) => step.run?.includes('test "$CSO_DOCKER_RESULT" = success'));
|
||||
expect(strict.env.CSO_DOCKER_RESULT).toBe('${{ needs.cso-docker-integration.result }}');
|
||||
expect(strict.if).toBe("steps.route.outputs.reused != 'true'");
|
||||
expect(required.steps.indexOf(strict)).toBe(1);
|
||||
for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) {
|
||||
if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test('Linux records on the free-suite runner image, turns red on any red file, a
|
||||
});
|
||||
|
||||
test('Windows records through the windows-free-tests record_durations path, and propose needs both complete recordings', () => {
|
||||
expect(recorder.jobs.windows).toEqual({ uses: './.github/workflows/windows-free-tests.yml', with: { record_durations: true } });
|
||||
expect(recorder.jobs.windows).toEqual({ uses: './.github/workflows/windows-free-tests.yml', permissions: { contents: 'read', actions: 'read' }, with: { record_durations: true } });
|
||||
expect(recorder.jobs.propose.needs).toEqual(['linux', 'windows']);
|
||||
expect(recorder.jobs.propose.if).toBe('${{ !cancelled() }}');
|
||||
const downloads = recorder.jobs.propose.steps.filter((step: any) => step.uses?.startsWith('actions/download-artifact@'));
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('free-tests workflow wiring', () => {
|
||||
const suite = workflow.jobs['free-suite'];
|
||||
const aggregate = workflow.jobs['free-tests'];
|
||||
expect(planner.steps.find((step: any) => step.id === 'plan').run).toContain('--ci-plan');
|
||||
expect(suite.needs).toBe('free-plan');
|
||||
expect(suite.needs).toEqual(['free-plan', 'reuse']);
|
||||
expect(suite.strategy.matrix).toBe('${{ fromJSON(needs.free-plan.outputs.matrix) }}');
|
||||
expect(suite.strategy['fail-fast']).toBe(false);
|
||||
expect(suite.strategy['max-parallel']).toBe(20);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* gstack-pregate --stage tests --from-run <run> (GSTA-156 P3b): the selected local test files a verified Free Tests
|
||||
* run on this exact tree lists count as passed and are recorded with its receipt; the rest still run. A refused run
|
||||
* prints its REUSE: refused line and runs everything. The run API is a fake; trees come from the fixture repository.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { buildContext } from '../lib/pregate/context';
|
||||
import { readPregateFile, runPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import type { ApiRun, RunProfile, RunSource } from '../lib/run-evidence';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
|
||||
const repos: FixtureRepo[] = [];
|
||||
const savedRoot = process.env.GSTACK_STATE_ROOT;
|
||||
afterEach(() => {
|
||||
while (repos.length) cleanup(repos.pop()!);
|
||||
if (savedRoot === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = savedRoot;
|
||||
});
|
||||
|
||||
function fixture(): { repo: FixtureRepo; parent: string; head: string } {
|
||||
const repo = makeFixtureRepo();
|
||||
repos.push(repo);
|
||||
process.env.GSTACK_STATE_ROOT = repo.home;
|
||||
const parent = repo.git('rev-parse', 'HEAD');
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
|
||||
return { repo, parent, head: repo.git('rev-parse', 'HEAD') };
|
||||
}
|
||||
|
||||
function source(repo: FixtureRepo, runHead: string, files: string[], profiles: Record<string, RunProfile> = { 'ci-reuse/free-tests': PROFILE }): RunSource {
|
||||
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: runHead, path: '.github/workflows/free-tests.yml', created_at: '2026-10-30T10:00:00Z', updated_at: '2026-10-30T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
return {
|
||||
repo: 'fx/fx', profiles, now: NOW,
|
||||
deps: {
|
||||
run: () => api, runsForHead: (_wf, sha) => (sha === runHead ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
|
||||
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: runHead, head_sha: runHead, tree: repo.git('rev-parse', `${runHead}^{tree}`), os: [LOCAL_OS], files, tests: files.length * 4 }),
|
||||
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function lanes(repo: FixtureRepo, fromRun?: { ref: string; check: RunSource | { error: string } }) {
|
||||
const progress: string[] = [];
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 16, testBackend: 'local' });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
if (fromRun) loaded.ctx.fromRun = fromRun;
|
||||
const file = await runPregate(loaded.ctx, { stage: 'tests' });
|
||||
return { file, check: file.checks.find(c => c.id === 'lanes')!, progress };
|
||||
}
|
||||
const ranLine = (c: PregateFile['checks'][number]) => (c.lines ?? []).find(l => l.startsWith('ran: ')) ?? '';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('--from-run: a verified Free Tests run covers part of the tests stage', () => {
|
||||
test('without --from-run every selected file runs (the baseline)', async () => {
|
||||
const { repo } = fixture();
|
||||
const { check } = await lanes(repo);
|
||||
expect(check.status).toBe('pass');
|
||||
expect(ranLine(check)).toContain('test/util.test.js');
|
||||
expect(ranLine(check)).toContain('test/retry.test.js');
|
||||
expect(check.from_run).toBeUndefined();
|
||||
}, 120_000);
|
||||
|
||||
test('the files the run lists count as passed and are recorded with its receipt; the rest still run, and pregate.json validates', async () => {
|
||||
const { repo, head } = fixture();
|
||||
const { file, check, progress } = await lanes(repo, { ref: '100', check: source(repo, head, ['test/util.test.js', 'test/unrelated.test.js']) });
|
||||
expect(check.status).toBe('pass');
|
||||
expect(check.from_run).toEqual({ receipt: expect.stringMatching(new RegExp(`^run tree=${file.tree.slice(0, 12)} tests=8 runs=100/1 profiles=ci-reuse/free-tests heads=${head.slice(0, 12)} verified=`)), files: ['test/util.test.js'] });
|
||||
expect(ranLine(check)).toContain('test/retry.test.js');
|
||||
expect(ranLine(check)).not.toContain('test/util.test.js');
|
||||
expect(check.detail).toStartWith('1 of 3 local test file(s) passed in the --from-run run; ');
|
||||
expect(progress).toContain(`REUSE: accepted 1 of 3 selected local test file(s) from ${check.from_run!.receipt}; 2 still run`);
|
||||
const out = path.join(repo.work, '.gstack', 'tmp', 'pregate.json');
|
||||
writePregateFile(repo.work, out, file);
|
||||
expect(readPregateFile(out).file?.checks.find(c => c.id === 'lanes')?.from_run?.files).toEqual(['test/util.test.js']);
|
||||
}, 120_000);
|
||||
|
||||
test('a run covering every selected file leaves nothing to run', async () => {
|
||||
const { repo, head } = fixture();
|
||||
const all = ['test/nightly/retry-heavy.test.js', 'test/retry.test.js', 'test/util.test.js'];
|
||||
const { check } = await lanes(repo, { ref: '100', check: source(repo, head, all) });
|
||||
expect([check.status, check.from_run?.files.sort(), ranLine(check)]).toEqual(['pass', all, '']);
|
||||
}, 120_000);
|
||||
|
||||
test('a refused run prints its REUSE line and runs everything: another tree, no profile for a local lane or this OS, unreadable profiles', async () => {
|
||||
const { repo, parent, head } = fixture();
|
||||
const cases: Array<[string, RunSource | { error: string }]> = [
|
||||
['tree_mismatch', source(repo, parent, ['test/util.test.js', 'test/retry.test.js'])],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'ci-reuse/windows': { ...PROFILE, workflow: 'windows.yml', os: 'Windows' } })],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'ci-reuse/free-tests': { ...PROFILE, os: 'Windows' } })],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'free-tests.yml/unit': { ...PROFILE, workflow: 'other.yml' } })],
|
||||
['profiles_unavailable', { error: 'origin is not a github.com repository' }],
|
||||
];
|
||||
for (const [reason, check] of cases) {
|
||||
const r = await lanes(repo, { ref: '100', check });
|
||||
expect([reason, r.progress.find(l => l.startsWith('REUSE: '))]).toEqual([reason, expect.stringMatching(new RegExp(`^REUSE: refused reason=${reason} — .*; every selected test runs here$`))]);
|
||||
expect([reason, r.check.from_run, ranLine(r.check).includes('test/util.test.js') && ranLine(r.check).includes('test/retry.test.js')]).toEqual([reason, undefined, true]);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
test('the CLI: --from-run is a tests-stage flag, and without a GitHub origin it refuses and runs everything', () => {
|
||||
const { repo } = fixture();
|
||||
const preflight = runBin(repo, 'gstack-pregate', ['--stage', 'preflight', '--from-run', '100']);
|
||||
expect([preflight.status, preflight.err]).toEqual([EXIT.usage, expect.stringContaining('--from-run applies to the tests stage')]);
|
||||
const tests = runBin(repo, 'gstack-pregate', ['--stage', 'tests', '--from-run', '100', '--allow-repo-commands']);
|
||||
expect(tests.err).toContain('REUSE: refused reason=profiles_unavailable — origin is not a github.com repository');
|
||||
expect(tests.status).toBe(EXIT.ok);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* the dependencies are fakes, so nothing here reaches GitHub.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseRunReceipt, parseRunRef, runInventory, runProfileErrors, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RunDeps, type RunProfile, type TreeRecord } from '../lib/run-evidence';
|
||||
import { parseRunReceipt, parseRunRef, runInventory, runProfileErrors, runReceiptFields, uncoveredFiles, verifyFromRun, verifyRun, type ApiJob, type ApiRun, type RunDeps, type RunProfile, type TreeRecord } from '../lib/run-evidence';
|
||||
|
||||
const REPO = 'garrytan/gbrain';
|
||||
const HEAD = 'a'.repeat(40);
|
||||
@@ -159,7 +159,7 @@ describe('runInventory', () => {
|
||||
describe('profiles, coverage and receipts', () => {
|
||||
test('runProfileErrors accepts a well-formed pair and names every malformed field', () => {
|
||||
expect(runProfileErrors({ test: PROFILE, e2e: { ...PROFILE, workflow: 'e2e.yml', jobs: ['e2e-full-suite'], artifact: 'e2e-full-suite-tree', os: 'Linux', max_age_hours: 12 } }, { profiles: ['test', 'e2e'], inventory: 'bash scripts/pregate-remote-gate.sh --list' })).toEqual([]);
|
||||
const errors = runProfileErrors({ bad: { workflow: '../x.yml', events: ['pull_request'], jobs: [], artifact: '', max_age_hours: 0, os: 'BeOS', extra: 1 } }, { profiles: ['missing'], inventory: 'a\nb' }).map(e => e.path);
|
||||
const errors = runProfileErrors({ bad: { workflow: '../x.yml', events: ['pull_request_target'], jobs: [], artifact: '', max_age_hours: 0, os: 'BeOS', extra: 1 } }, { profiles: ['missing'], inventory: 'a\nb' }).map(e => e.path);
|
||||
expect(errors).toEqual(expect.arrayContaining(['run_profiles["bad"].extra', 'run_profiles["bad"].workflow', 'run_profiles["bad"].events', 'run_profiles["bad"].jobs', 'run_profiles["bad"].artifact', 'run_profiles["bad"].max_age_hours', 'run_profiles["bad"].os', 'remote_gate_runs.profiles', 'remote_gate_runs.inventory']));
|
||||
expect(runProfileErrors({ test: PROFILE }, { profiles: ['test', 'test'], inventory: 'true' })).toEqual([{ path: 'remote_gate_runs.profiles', message: 'names each profile once' }]);
|
||||
});
|
||||
@@ -181,3 +181,69 @@ describe('profiles, coverage and receipts', () => {
|
||||
expect(parseRunRef('123', REPO)).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull_request runs (P3b)', () => {
|
||||
const MERGE = 'e'.repeat(40);
|
||||
const PR_PROFILE: RunProfile = { ...PROFILE, events: ['pull_request', 'workflow_dispatch'] };
|
||||
const pr = (over: Partial<TreeRecord> = {}, trees: Record<string, string | null> = { [HEAD]: TREE, [MERGE]: TREE }) => {
|
||||
const base = world();
|
||||
return world({ run: { ...base.run, event: 'pull_request' }, artifact: { ...(base.artifact as TreeRecord), event: 'pull_request', sha: MERGE, head_sha: HEAD, ...over }, trees });
|
||||
};
|
||||
const prReason = (w: World) => reason(w, { profile: PR_PROFILE });
|
||||
|
||||
test('a pull_request run counts when its merge commit tested its head tree', () => {
|
||||
const v = check(pr(), { profile: PR_PROFILE });
|
||||
expect(v).toMatchObject({ ok: true, evidence: { event: 'pull_request', head_sha: HEAD, tree: TREE } });
|
||||
});
|
||||
|
||||
test('a pull_request run is refused when the merge tree is not the head tree, or the record names no merge commit or another head', () => {
|
||||
expect(prReason(pr({}, { [HEAD]: TREE, [MERGE]: 'f'.repeat(40) }))).toBe('tree_mismatch');
|
||||
const behind = check(pr({}, { [HEAD]: TREE, [MERGE]: 'f'.repeat(40) }), { profile: PR_PROFILE });
|
||||
expect(!behind.ok && behind.detail).toContain('the pull request was behind its base');
|
||||
expect(prReason(pr({ sha: undefined }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ sha: 'not-a-sha' }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ head_sha: undefined }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ head_sha: MERGE }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({}, { [HEAD]: TREE }))).toBe('api_error');
|
||||
expect(prReason(pr({ tree: 'f'.repeat(40) }))).toBe('tree_mismatch');
|
||||
});
|
||||
|
||||
test('a profile without pull_request still refuses a pull_request run, and validation accepts pull_request but nothing else new', () => {
|
||||
expect(reason(pr())).toBe('event_mismatch');
|
||||
expect(runProfileErrors({ p: PR_PROFILE }, undefined)).toEqual([]);
|
||||
expect(runProfileErrors({ p: { ...PR_PROFILE, events: ['pull_request_target'] } }, undefined).map(e => e.path)).toEqual(['run_profiles["p"].events']);
|
||||
});
|
||||
|
||||
test('a record may name every OS its test jobs ran on; the profile OS must be one of them', () => {
|
||||
const base = world();
|
||||
const multi = (os: unknown) => world({ artifact: { ...(base.artifact as TreeRecord), os } });
|
||||
expect(reason(multi(['Linux', 'macOS', 'Windows']), { profile: { ...PROFILE, os: 'macOS' } })).toBe('ok');
|
||||
expect(reason(multi(['Linux']), { profile: { ...PROFILE, os: 'macOS' } })).toBe('os_mismatch');
|
||||
expect(reason(multi('Windows'), { profile: { ...PROFILE, os: 'Windows' } })).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyFromRun (P3b --from-run)', () => {
|
||||
const src = (w: World, profiles: Record<string, RunProfile> = { 'test.yml/unit': PROFILE }) => ({ repo: REPO, deps: deps(w), profiles, now: NOW });
|
||||
test('a run on exactly this tree is evidence with a run receipt naming its files', () => {
|
||||
const v = verifyFromRun(src(world()), '100', ['test.yml/unit'], TREE, HEAD);
|
||||
expect(v).toEqual({ ok: true, evidence: expect.objectContaining({ run_id: 100, files: ['test/a.test.ts', 'test/b.test.ts'] }), receipt: `run tree=${TREE.slice(0, 12)} tests=12 runs=100/1 profiles=test.yml/unit heads=${HEAD.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` });
|
||||
});
|
||||
test('another tree, no profile, an empty file list, zero tests and a failed run each refuse with their reason', () => {
|
||||
const why = (v: ReturnType<typeof verifyFromRun>) => (v.ok ? 'ok' : v.reason);
|
||||
const base = world();
|
||||
expect(why(verifyFromRun(src(world()), '100', ['test.yml/unit'], 'c'.repeat(40), HEAD))).toBe('tree_mismatch');
|
||||
expect(why(verifyFromRun(src(world()), '100', [], TREE, HEAD))).toBe('no_profile');
|
||||
expect(why(verifyFromRun(src(world({ artifact: { ...(base.artifact as TreeRecord), files: [] } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('no_tests');
|
||||
expect(why(verifyFromRun(src(world({ artifact: { ...(base.artifact as TreeRecord), tests: 0 } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('no_tests');
|
||||
expect(why(verifyFromRun(src(world({ run: { ...base.run, conclusion: 'failure' } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('run_not_success');
|
||||
const two = verifyFromRun(src(world({ run: { ...base.run, conclusion: 'failure' } }), { a: PROFILE, b: PROFILE }), '100', ['a', 'b'], TREE, HEAD);
|
||||
expect(two).toMatchObject({ ok: false, reason: 'no_verified_run', detail: expect.stringContaining('a: run 100') });
|
||||
});
|
||||
test('a newer red run on the consumer\'s own head vetoes', () => {
|
||||
const base = world();
|
||||
const OWN = 'c'.repeat(40);
|
||||
const red: ApiRun = { ...base.run, id: 101, head_sha: OWN, conclusion: 'failure', created_at: '2026-10-29T11:00:00Z' };
|
||||
expect(verifyFromRun(src(world({ others: [red] })), '100', ['test.yml/unit'], TREE, OWN)).toMatchObject({ ok: false, reason: 'newer_run_vetoes' });
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
|
||||
expect(planStep.env.GSTACK_FREE_TEST_DURATIONS).toBe('scripts/free-test-durations-windows.json');
|
||||
const shard = windows.jobs['windows-free-shard'];
|
||||
expect(shard['runs-on']).toBe('windows-latest');
|
||||
expect(shard.needs).toBe('windows-plan');
|
||||
expect(shard.needs).toEqual(['windows-plan', 'reuse']);
|
||||
expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' });
|
||||
const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard');
|
||||
expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result ');
|
||||
@@ -42,9 +42,9 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
|
||||
expect(shard.steps.find((step: any) => step.name === 'Upload strict shard result').if).toBe('always()');
|
||||
const aggregate = windows.jobs['windows-free-tests'];
|
||||
expect(aggregate.if).toBe('${{ always() && !inputs.record_durations }}');
|
||||
expect(plan.if).toBe('${{ !inputs.record_durations }}');
|
||||
expect(aggregate.needs).toEqual(['windows-plan', 'windows-free-shard']);
|
||||
expect(aggregate.steps.at(-1).run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
|
||||
expect(plan.if).toBe("${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}");
|
||||
expect(aggregate.needs).toEqual(['reuse', 'windows-plan', 'windows-free-shard']);
|
||||
expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
|
||||
});
|
||||
|
||||
test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => {
|
||||
|
||||
Reference in new issue
Block a user