mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
feat(evals): R2 verdict reuse across main moves; review fixes (newer-run classification, receipt parser, manual acceptance)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f15a34dc35
commit
00f5b5e2c9
9 files changed
+506
-175
No files matched your search
+31
-30
@@ -220,14 +220,16 @@ jobs:
|
||||
--store /tmp/gstack-eval-input-cache --store-key "$STORE_KEY" --mode "$RECOVERY_MODE" --budget-seconds 60 >> "$GITHUB_STEP_SUMMARY" \
|
||||
|| echo "- Receipt recovery crashed; reuse stays off this run" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# R2a (GSTA-48), a narrow gate edit: base-ref code proposes reusing the
|
||||
# newest executed GREEN gate of this PR when this head differs from it
|
||||
# only by stamp files (TREE: same-modulo-stamps from the tree receipt
|
||||
# under the base's ship policy) and main has not moved (same base SHA).
|
||||
# plan-slices confirms the selection is identical. Anything else (a red
|
||||
# or partial gate, a moved base, a non-stamp byte, no receipt, a gh or
|
||||
# git error, evals-fresh) leaves no candidate: the full gate runs.
|
||||
- name: Find a restamp-only gate to reuse (R2a, base-ref code)
|
||||
# Verdict reuse (GSTA-48), a gate edit: base-ref code proposes reusing
|
||||
# the newest executed GREEN gate of this PR when this head differs from
|
||||
# it only by stamp files on the same base (R2a: TREE same-modulo-stamps
|
||||
# from the tree receipt under the base's ship policy), or when main
|
||||
# moved and the PR's own diff from its merge-base, stamp files excluded,
|
||||
# is byte-identical (R2). plan-slices confirms the selection is
|
||||
# identical. Anything else (a red, partial or cancelled gate, a changed
|
||||
# own diff, a non-stamp byte, no binding or receipt, a gh or git error,
|
||||
# evals-fresh) leaves no candidate: the full gate runs.
|
||||
- name: Find a gate verdict to reuse (R2a/R2, base-ref code)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -235,18 +237,17 @@ jobs:
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ ! -f scripts/gate-reuse.ts ]; then
|
||||
echo "- Restamp-only gate reuse unavailable: the base ref has no scripts/gate-reuse.ts; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Gate verdict reuse unavailable: the base ref has no scripts/gate-reuse.ts; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
bun --no-install run scripts/gate-reuse.ts find --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --head-repo "$HEAD_REPO" \
|
||||
--branch "$HEAD_BRANCH" --run-id "$GITHUB_RUN_ID" --head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" \
|
||||
--branch "$HEAD_BRANCH" --run-id "$GITHUB_RUN_ID" --head-sha "$HEAD_SHA" --tested-sha "$GITHUB_SHA" \
|
||||
--out /tmp/gate-reuse --budget-seconds 90 >> "$GITHUB_STEP_SUMMARY" \
|
||||
|| echo "- Restamp-only gate reuse crashed; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
|
||||
|| echo "- Gate verdict reuse crashed; the full gate runs" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Hand a restamp-only reuse candidate to plan-slices
|
||||
- name: Hand a gate reuse candidate to plan-slices
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
@@ -346,9 +347,9 @@ jobs:
|
||||
EVALS_BASE: ${{ inputs.base_sha || (inputs.base_ref != '' && format('origin/{0}', inputs.base_ref)) || '' }}
|
||||
run: EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slice-budget 420 --jobs 2 --max-parallel 20
|
||||
|
||||
# R2a: reuse holds only when this head's own plan selects exactly the
|
||||
# R2a/R2: reuse holds only when this head's own plan selects exactly the
|
||||
# cases and trial plans the reused gate ran (selection fingerprint).
|
||||
- name: Download a restamp-only reuse candidate
|
||||
- name: Download a gate reuse candidate
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
@@ -356,7 +357,7 @@ jobs:
|
||||
name: gate-reuse-candidate
|
||||
path: /tmp/gate-reuse
|
||||
|
||||
- name: Confirm restamp-only gate reuse (R2a)
|
||||
- name: Confirm gate verdict reuse (R2a/R2)
|
||||
id: gate_reuse
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -400,7 +401,7 @@ jobs:
|
||||
# !cancelled(), not always(): still runs when build-image was skipped
|
||||
# (image already published), but a newer push's cancel-in-progress stops
|
||||
# it instead of letting a superseded run finish its paid slices first.
|
||||
# A confirmed restamp-only reuse (R2a) runs no paid slice.
|
||||
# A confirmed verdict reuse (R2a/R2) runs no paid slice.
|
||||
if: ${{ !cancelled() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' && needs.plan-slices.outputs.gate_reuse != 'true' }}
|
||||
# The planner packs ~7 minutes of recorded work per slice (EVALS_JOBS=2 x
|
||||
# EVALS_CONCURRENCY=2 per runner, never the old 40-way per-row fan-out
|
||||
@@ -612,10 +613,10 @@ jobs:
|
||||
# (caught by the ship review army; the wiring test now pins this).
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# R2a: the planner confirmed a restamp-only reuse, so no slice ran; the
|
||||
# verdict is the reused gate's, printed with its receipt. A missing or
|
||||
# malformed reuse record exits 1 (fail-closed).
|
||||
- name: Report the reused restamp-only verdict (R2a)
|
||||
# R2a/R2: the planner confirmed a reuse, so no slice ran; the verdict is
|
||||
# the reused gate's, printed with which run, which head and why. A
|
||||
# missing or malformed reuse record exits 1 (fail-closed).
|
||||
- name: Report the reused gate verdict (R2a/R2)
|
||||
id: reused
|
||||
if: needs.plan-slices.outputs.gate_reuse == 'true'
|
||||
run: |
|
||||
@@ -667,19 +668,19 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
# R2a: what this executed run proved, read by a later restamp-only push.
|
||||
# Reused runs bind nothing, so reuse never chains off a reuse.
|
||||
- name: Bind this run's verdict for restamp-only reuse (R2a)
|
||||
# R2a/R2: what this executed run proved, read by a later push. The base
|
||||
# is the tested merge commit's first parent. Reused runs bind nothing,
|
||||
# so reuse never chains off a reuse.
|
||||
- name: Bind this run's verdict for reuse (R2a/R2)
|
||||
if: always() && github.event_name == 'pull_request' && needs.plan-slices.outputs.gate_reuse != 'true'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
RECONCILE_EXIT: ${{ steps.reconcile.outputs.exit }}
|
||||
run: |
|
||||
bun --no-install run scripts/gate-reuse.ts bind /tmp/paid-report --out /tmp/gate-binding --repo "$GITHUB_REPOSITORY" \
|
||||
--pr "$PR_NUMBER" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" --head-sha "$HEAD_SHA" \
|
||||
--base-sha "$BASE_SHA" --profile "$EVALS_PROFILE" --reconcile-exit "$RECONCILE_EXIT" >> "$GITHUB_STEP_SUMMARY"
|
||||
--tested-sha "$GITHUB_SHA" --profile "$EVALS_PROFILE" --reconcile-exit "$RECONCILE_EXIT" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload the gate binding
|
||||
if: always() && github.event_name == 'pull_request' && needs.plan-slices.outputs.gate_reuse != 'true'
|
||||
@@ -740,12 +741,12 @@ jobs:
|
||||
RECONCILE_EXIT: ${{ needs.slices-report.outputs.reconcile-exit }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086,SC2059
|
||||
# R2a: a confirmed restamp-only reuse ran no case; say so, with the
|
||||
# reused run, the receipt and the identical selection.
|
||||
# R2a/R2: a confirmed verdict reuse ran no case; say which run,
|
||||
# which head and why, with the receipt or own-diff signature.
|
||||
REUSE=/tmp/paid-report/gate-reuse.json
|
||||
if [ "${RECONCILE_EXIT:-1}" = "0" ] && [ -f "$REUSE" ] \
|
||||
&& jq -e '.schema == 1 and .tree == "same-modulo-stamps" and (.stamp_files | length > 0)' "$REUSE" >/dev/null 2>&1; then
|
||||
BODY=$(jq -r --arg repo "$GITHUB_REPOSITORY" '"## E2E Evals: ✅ PASS (reused: restamp-only, R2a)\n\nNo paid case ran on `\(.current_head)`. It differs from `\(.gated_head)`, which passed the full gate in [run \(.reused_run_id)](https://github.com/\($repo)/actions/runs/\(.reused_run_id)), only by stamp files (\(.stamp_files | join(", "))), and main has not moved since (base `\(.base_sha)`).\n\n```\nTREE: same-modulo-stamps receipt=\(.receipt_sha256)\nselection: \(.selection_sha256) (identical to the reused gate)\nversion: \(.old_version // "-") -> \(.new_version // "-")\n```\n\n---\n*Restamp-only reuse (R2a): any non-stamp byte, a moved base or a missing receipt runs the full gate; the evals-fresh label opts out.*"' "$REUSE")
|
||||
&& jq -e '.schema == 1 and (.kind == "restamp-only" or .kind == "main-moved")' "$REUSE" >/dev/null 2>&1; then
|
||||
BODY=$(jq -r --arg repo "$GITHUB_REPOSITORY" '"## E2E Evals: ✅ PASS (reused: \(if .kind == "restamp-only" then "restamp-only, R2a" else "main moved, R2" end))\n\nNo paid case ran on `\(.current_head)`. It carries the GREEN verdict of [run \(.reused_run_id)](https://github.com/\($repo)/actions/runs/\(.reused_run_id)) at `\(.gated_head)`, because \(.why).\n\n```\n\(if .kind == "restamp-only" then "TREE: same-modulo-stamps receipt=\(.receipt_sha256)" else "OWN DIFF: identical sha256=\(.own_diff_sha256) (merge-base \(.gated_merge_base) -> \(.merge_base))" end)\nbase: \(.gated_base) -> \(.base_sha)\nselection: \(.selection_sha256) (identical to the reused gate)\n```\n\n---\n*Verdict reuse (R2a restamp-only, R2 main moved): a changed own diff, a non-stamp byte, a changed selection, a newer red or unbound gate, or a missing binding or receipt runs the full gate; the evals-fresh label opts out. Free Tests and every other check still ran on this head.*"' "$REUSE")
|
||||
else
|
||||
TOTAL=0; PASSED=0; FAILED=0; MANUAL=0; EXECUTED=0; REUSED=0; COST="0"
|
||||
SUITE_LINES=""
|
||||
|
||||
+6
-2
@@ -2,17 +2,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**The paid PR gate now starts only after a PR head's free tests pass and the PR has been quiet for 10 minutes, and a version-only restamp reuses the green gate it already has.** Over Oct 3 to 17, paid work on heads whose free tests failed, plus runs cancelled by a push within the window, cost about $2,990, about $1,390 a week (GSTA-47 estimate). The same heads still get the same selection and verdict rules; only the start time moves. A restamp whose tree equals the last green head modulo stamps, on an unchanged base, reports that verdict instead of paying again. The PR receipt store, which never restored once, now does.
|
||||
**The paid PR gate now starts only after a PR head's free tests pass and the PR has been quiet for 10 minutes, and a head whose own diff already passed the gate reuses that verdict instead of paying again.** Re-measured over Oct 3 to 17 (505 runs, $21,449) with GSTA-47's scripts: the readiness wait alone would have saved $2,986 (about $1,380 a week), and verdict reuse up to $601 more (restamp-only $97, main moved $504). Together that is up to $3,587, about $1,660 a week; how much of the main-moved share survives the selection check is between $146 and $390 of the $601 by approximation. The same heads still get the same selection and verdict rules; only the start time moves. The PR receipt store, which never restored once, now does.
|
||||
|
||||
### Changed
|
||||
- **Readiness-gated dispatch (R1).** The `readiness` job in `evals.yml` replaces the 90-second push-burst debounce. It sleeps out the quiet window (repository variable `EVALS_QUIET_MINUTES`, default 10; `evals-no-debounce` sets it to 0), then waits for Free Tests to pass on the same head SHA, for up to 80 minutes. Free Tests red on the head fails the run with no paid work. A newer push still cancels the run. Dispatches never wait, and Version Gate is not an input, so a stamp push always gets its gate.
|
||||
- **The receipt store restores.** `slices-report` now runs on a GitHub-hosted runner like `recover-receipts`. On Ubicloud its `actions/cache` save went to Ubicloud's transparent cache, which the GitHub-hosted restore never sees: GitHub's cache held no `eval-input` entry at all, and #3253's restamp gate restored nothing 3 minutes after its green run saved 56 receipts.
|
||||
|
||||
### Added
|
||||
- **Restamp-only gate reuse (R2a), a narrow gate edit.** A PR head reuses the newest executed gate's GREEN verdict only when that gate ran on the same base SHA, the tree receipt prints `TREE: same-modulo-stamps`, every differing file is VERSION, package.json, CHANGELOG.md or the agents digest, and this head's own plan has the same selection fingerprint. Base-ref code proposes the reuse (`scripts/gate-reuse.ts find`), the planner confirms the selection, no slice runs, and the PR comment says `PASS (reused: restamp-only, R2a)` with the reused run and receipt digest. A non-stamp byte (CHANGELOG entry text included), a moved base, a missing binding or receipt, a red or partial gate, or `evals-fresh` runs the full gate. Reuse across a main move (R2) is not enabled.
|
||||
- **Paid-gate verdict reuse (R2a restamp-only, R2 main moved), a gate edit.** A PR head reuses the newest executed gate's GREEN verdict only when every newer run of the PR provably ran no paid slice, the gate had reconcile exit 0 and no manually accepted case, this head's own plan has the same selection fingerprint, and either (R2a) the base is unchanged and the tree receipt prints `TREE: same-modulo-stamps` with only VERSION, package.json, CHANGELOG.md or the agents digest differing, or (R2) main moved and the PR's own diff from its merge-base, stamp files excluded, is byte-identical. Base-ref code proposes the reuse (`scripts/gate-reuse.ts find`), the planner confirms the selection, no slice runs, and the PR comment says `PASS (reused: restamp-only, R2a)` or `PASS (reused: main moved, R2)` with the reused run, its head, why, and the receipt digest or own-diff signature. Free Tests and every other check still run on the new head. Anything else (a changed own diff, a non-stamp byte, a newer red or partial run, a missing binding, receipt or merge-base, `evals-fresh`) runs the full gate.
|
||||
|
||||
### Fixed
|
||||
- **The tree receipt no longer drops diff lines that start with `--` or `++`.** Its unified-diff parser treated `---`/`+++` inside a hunk as file headers, so a release-output line beginning `++` or `--` could pass as a stamp. It now reads headers only before a file's first hunk, and a file mode change is never a stamp.
|
||||
|
||||
### If you…
|
||||
- **…want a restamp to reuse its gate:** put the CHANGELOG entry in the gated push (under `## [Unreleased]` or the tentative version), so the stamp only re-heads it.
|
||||
- **…want a main merge to reuse its gate:** merge or rebase without touching the PR's own files; if main changed a file your PR also changes, the full gate runs.
|
||||
- **…see "Free Tests run N concluded failure" on E2E Evals:** fix and push, or re-run Free Tests and then re-run E2E Evals.
|
||||
|
||||
## [1.91.133.0] - 2026-10-18
|
||||
|
||||
+39
-21
@@ -1126,7 +1126,7 @@ and `test:health` both say so.
|
||||
through today's selector and its shadow, and exits 1 on any MISS: a failed
|
||||
gate case the shadow would not run. `test/exercised-map.test.ts` pins zero.
|
||||
|
||||
### PR receipts, recovery, readiness and restamp-only reuse
|
||||
### PR receipts, recovery, readiness and verdict reuse
|
||||
|
||||
Passes and failures of single-trial shards are written as receipts at the
|
||||
execution boundary, so a cancelled run's finished work survives. The
|
||||
@@ -1156,28 +1156,46 @@ nothing. Selection, trials and verdict rules do not change. Version Gate is not
|
||||
an input, so a stamp push waits only for its own Free Tests and always gets a
|
||||
gate. Dispatches (`eval:bg`, the census) never wait.
|
||||
|
||||
**Restamp-only reuse (R2a, a gate edit).** A PR head reuses the verdict of the
|
||||
newest executed gate of the same PR instead of running one only when all of
|
||||
these hold: that gate is GREEN with reconcile exit 0 and a successful run; it
|
||||
ran the PR profile on the same base SHA as this run (main has not moved; reuse
|
||||
across a main move is R2 and is not enabled); the heads differ; the tree
|
||||
receipt (`lib/tree-receipt.ts` under the base's ship policy) prints
|
||||
`TREE: same-modulo-stamps` and every differing file is the version file,
|
||||
`package.json`, the CHANGELOG or a release output (the agents digest); and this
|
||||
head's own plan has the same selection fingerprint (cases, statuses, trial
|
||||
plans, profile). `recover-receipts` proposes the candidate from base-ref code,
|
||||
`plan-slices` confirms the selection, `eval-slices` is skipped, and the report and
|
||||
PR comment say `PASS (reused: restamp-only, R2a)` with the reused run, the
|
||||
receipt digest and the stamp files. Executed runs upload a `gate-binding-a<attempt>`
|
||||
artifact; reused runs bind nothing, so reuse never chains off a reuse. Any
|
||||
other case (a red, partial or cancelled gate, a moved base, a non-stamp byte
|
||||
including CHANGELOG entry text, a missing binding or receipt, a gh or git
|
||||
error) runs the full gate. For a restamp to qualify, write the CHANGELOG entry
|
||||
before the gated push (under `## [Unreleased]` or the tentative version) so the
|
||||
stamp only re-heads it.
|
||||
**Verdict reuse (R2a restamp-only, R2 main moved; a gate edit).** A PR head
|
||||
reuses the verdict of the newest executed gate of the same PR instead of
|
||||
running one only when all of these hold. Every newer run of the PR either bound
|
||||
a verdict or provably ran no paid slice (superseded, readiness-blocked, a reuse,
|
||||
or cancelled before a slice finished); a newer run that is still running,
|
||||
finished a slice, or holds a `paid-slice-*` artifact without a bound verdict
|
||||
blocks reuse, so a newer red or partial result is never skipped. The gate is
|
||||
GREEN with reconcile exit 0, a successful run and no manually accepted
|
||||
(unscored) case; it ran the PR profile on a different head. Then either:
|
||||
|
||||
- **R2a, restamp-only:** it ran on the same base (the tested merge commit's
|
||||
first parent), the tree receipt (`lib/tree-receipt.ts` under the base's ship
|
||||
policy) prints `TREE: same-modulo-stamps`, and every differing file is the
|
||||
version file, `package.json`, the CHANGELOG or a release output (the agents
|
||||
digest); or
|
||||
- **R2, main moved:** the base moved, and the PR's own diff from its
|
||||
merge-base, excluding stamp files, is byte-identical to the gated head's
|
||||
(`git diff --binary --full-index`, so pre-image blob ids are compared and a
|
||||
main change to a file the PR also changes is a difference; `package.json`
|
||||
compared without its version; the CHANGELOG by the multiset of section bodies
|
||||
the PR added, headings excluded).
|
||||
|
||||
Finally this head's own plan must have the same selection fingerprint (cases,
|
||||
statuses, trial plans, profile), so a main move that changes what the PR gate
|
||||
selects runs the full gate. `recover-receipts` proposes the candidate from
|
||||
base-ref code (`scripts/gate-reuse.ts find`), `plan-slices` confirms the
|
||||
selection, `eval-slices` is skipped, and the report and PR comment say
|
||||
`PASS (reused: restamp-only, R2a)` or `PASS (reused: main moved, R2)` with
|
||||
the reused run, its head, why, and the receipt digest or own-diff signature.
|
||||
Free Tests and every other check still run on the new head. Executed runs
|
||||
upload a `gate-binding-a<attempt>` artifact (base, verdict, reconcile exit,
|
||||
manual count, selection); reused runs bind nothing, so reuse never chains off a
|
||||
reuse. Any other case (a changed own diff, a non-stamp byte including CHANGELOG
|
||||
entry text or a file mode, a missing binding, receipt or merge-base, a gh or git
|
||||
error, `evals-fresh`) runs the full gate. For a restamp to qualify, write the
|
||||
CHANGELOG entry before the gated push (under `## [Unreleased]` or the
|
||||
tentative version) so the stamp only re-heads it.
|
||||
|
||||
PR labels: `evals-no-debounce` sets the quiet window to 0 (Free Tests is still
|
||||
required) and `evals-fresh` turns receipt reuse and restamp-only reuse off. Labels
|
||||
required) and `evals-fresh` turns receipt reuse and verdict reuse off. Labels
|
||||
apply from the next push, because adding a label does not start `evals.yml`.
|
||||
|
||||
## Free suite runner, judge reuse and engine skips
|
||||
|
||||
+9
-4
@@ -209,9 +209,13 @@ function allowedOutput(plan: Plan, root: string, rel: string): boolean {
|
||||
}
|
||||
|
||||
interface Hunk { removed: string[]; added: string[]; at: number }
|
||||
export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R' }
|
||||
export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R'; modeChanged?: boolean }
|
||||
|
||||
/** Parse `git diff --unified=0` into per-file hunks of removed/added lines (shared with lib/tree-receipt.ts). */
|
||||
/**
|
||||
* Parse `git diff --unified=0` into per-file hunks of removed/added lines (shared with lib/tree-receipt.ts).
|
||||
* File headers come only before a file's first hunk: inside a hunk every `-`/`+` line is content, so a
|
||||
* body line that begins `--` or `++` (`---`/`+++` in the diff) is never dropped as a header.
|
||||
*/
|
||||
export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
const files: FileDiff[] = [];
|
||||
let cur: FileDiff | null = null;
|
||||
@@ -225,11 +229,12 @@ export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
} else if (!cur) continue;
|
||||
else if (line.startsWith('new file mode')) cur.status = 'A';
|
||||
else if (line.startsWith('deleted file mode')) cur.status = 'D';
|
||||
else if (!hunk && (line.startsWith('old mode ') || line.startsWith('new mode '))) cur.modeChanged = true;
|
||||
else if (line.startsWith('rename to ')) cur.status = 'R';
|
||||
else if (line.startsWith('Binary files')) cur.binary = true;
|
||||
else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0) }; cur.hunks.push(hunk); }
|
||||
else if (hunk && line.startsWith('-') && !line.startsWith('---')) hunk.removed.push(line.slice(1));
|
||||
else if (hunk && line.startsWith('+') && !line.startsWith('+++')) hunk.added.push(line.slice(1));
|
||||
else if (hunk && line.startsWith('-')) hunk.removed.push(line.slice(1));
|
||||
else if (hunk && line.startsWith('+')) hunk.added.push(line.slice(1));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: str
|
||||
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
|
||||
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
|
||||
if (f.binary) return changed('binary', 'binary content differs');
|
||||
if (f.modeChanged) return changed('mode', 'file mode changed');
|
||||
if (f.status !== 'M') return changed('path', `${f.status === 'A' ? 'added' : f.status === 'D' ? 'deleted' : 'renamed'}`);
|
||||
if (policy.stamp_files.length && !matchesAny(policy.stamp_files, f.path)) return changed('stamp_files', 'not in the repo\'s stamp_files allowlist');
|
||||
const before = blob(repoRoot, gated, f.path);
|
||||
|
||||
+235
-91
@@ -1,39 +1,47 @@
|
||||
/**
|
||||
* Restamp-only gate reuse for the PR paid lane (R2a, GSTA-48). A gate edit:
|
||||
* a head may report the verdict of an earlier full paid gate instead of
|
||||
* running one, only when every condition below holds; anything else runs the
|
||||
* full gate.
|
||||
* Paid-gate verdict reuse for the PR lane (GSTA-48). A gate edit: a head may
|
||||
* report the verdict of an earlier full paid gate instead of running one only
|
||||
* when every condition below holds; anything else runs the full gate.
|
||||
*
|
||||
* bind (slices-report, every executed pull_request run) records what the
|
||||
* run proved: PR, head, the base SHA the merge ref was built on,
|
||||
* the reconcile exit, the report verdict and the selection
|
||||
* fingerprint of its manifest. Reused runs bind nothing.
|
||||
* run proved: PR, head, the base its merge commit was built on
|
||||
* (the tested commit's first parent), the reconcile exit, the
|
||||
* report verdict and its plan's selection fingerprint. Reused runs
|
||||
* bind nothing, so reuse never chains off a reuse.
|
||||
* find (recover-receipts, BASE-REF code, actions: read) takes the newest
|
||||
* earlier completed run of this PR that bound a verdict. It must be
|
||||
* GREEN with reconcile exit 0 and a successful conclusion, on the
|
||||
* same base SHA as this run (main did not move; reuse across a
|
||||
* main move is R2, not this), on a different head, and the tree
|
||||
* receipt (lib/tree-receipt.ts, the base's ship policy) must say
|
||||
* `TREE: same-modulo-stamps` with every differing file one of
|
||||
* the policy's stamp files: the version file, package.json, the
|
||||
* CHANGELOG and the release outputs. It writes candidate.json.
|
||||
* confirm (plan-slices, after this run's own plan) requires this run's
|
||||
* GREEN with reconcile exit 0 and a successful conclusion, ran the
|
||||
* PR profile, and gated a different head. Then one of:
|
||||
* restamp-only (R2a): same base; the tree receipt
|
||||
* (lib/tree-receipt.ts, the base's ship policy) says
|
||||
* `TREE: same-modulo-stamps` and every differing file is a stamp
|
||||
* file (the version file, package.json, the CHANGELOG, the
|
||||
* release outputs).
|
||||
* main-moved (R2): the base moved; the PR's own diff from its
|
||||
* merge-base, excluding stamp files, is byte-identical to the
|
||||
* gated head's (package.json compared without its version,
|
||||
* the CHANGELOG by the PR's own section bodies).
|
||||
* It writes candidate.json.
|
||||
* confirm (plan-slices, after this run's own plan) requires this head's
|
||||
* selection fingerprint to equal the bound one, so the reused
|
||||
* verdict covers exactly the cases and trial plans this head would
|
||||
* run. It writes gate-reuse.json and `reuse=true`.
|
||||
* report (slices-report) prints the reused verdict and its evidence.
|
||||
* run. It writes gate-reuse.json and `gate_reuse=true`.
|
||||
* report (slices-report) prints the reused verdict: which run, which head,
|
||||
* and why.
|
||||
*
|
||||
* Every failure (no binding, a red or partial verdict, a moved base, a
|
||||
* non-stamp byte, a missing receipt, gh or git errors, a selection change)
|
||||
* leaves no candidate, and the run executes the full gate.
|
||||
* Every failure (no binding, a red, partial or cancelled gate, a non-stamp
|
||||
* byte, a missing receipt or merge-base, gh or git errors, a selection change)
|
||||
* leaves no candidate, and the run executes the full gate. The free suite and
|
||||
* every other check run on the new head regardless.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
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 { parseChangelog } from '../lib/changelog-check';
|
||||
import { receiptDigest } from '../lib/ship-queue';
|
||||
import { isUnavailable, loadPolicyFromBase } from '../lib/ship-policy';
|
||||
import { isUnavailable, loadPolicyFromBase, type ShipPolicy } from '../lib/ship-policy';
|
||||
import { buildReceipt } from '../lib/tree-receipt';
|
||||
import { acceptedRun, ghFailureReason, runGh, type GhRunner, type WorkflowRun } from './recover-receipts';
|
||||
import type { PaidRunManifest } from './lib/paid-plan';
|
||||
@@ -42,6 +50,7 @@ export const BINDING_FILE = 'gate-binding.json';
|
||||
export const CANDIDATE_FILE = 'candidate.json';
|
||||
export const REUSE_FILE = 'gate-reuse.json';
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface GateBinding {
|
||||
schema: 1;
|
||||
@@ -50,27 +59,38 @@ export interface GateBinding {
|
||||
run_id: number;
|
||||
run_attempt: number;
|
||||
head_sha: string;
|
||||
/** The tested merge commit's first parent: the main the gate actually ran against. */
|
||||
base_sha: string;
|
||||
profile: string;
|
||||
reconcile_exit: number;
|
||||
verdict: string;
|
||||
/** Cases accepted manually (unscored); a gate with any is never reused as a clean pass. */
|
||||
manual_accepted: number;
|
||||
selection_sha256: string;
|
||||
}
|
||||
|
||||
export interface GateReuse {
|
||||
schema: 1;
|
||||
kind: 'restamp-only' | 'main-moved';
|
||||
pr: number;
|
||||
reused_run_id: number;
|
||||
reused_run_attempt: number;
|
||||
gated_head: string;
|
||||
current_head: string;
|
||||
gated_base: string;
|
||||
base_sha: string;
|
||||
tree: 'same-modulo-stamps';
|
||||
receipt_sha256: string;
|
||||
stamp_files: string[];
|
||||
old_version: string | null;
|
||||
new_version: string | null;
|
||||
selection_sha256: string;
|
||||
/** One sentence: why this head may carry that verdict. */
|
||||
why: string;
|
||||
/** restamp-only: the tree receipt. */
|
||||
receipt_sha256?: string;
|
||||
stamp_files?: string[];
|
||||
old_version?: string | null;
|
||||
new_version?: string | null;
|
||||
/** main-moved: the merge-bases and the identical own-diff signature. */
|
||||
gated_merge_base?: string;
|
||||
merge_base?: string;
|
||||
own_diff_sha256?: string;
|
||||
}
|
||||
|
||||
/** The cases, trial plans and verdict-relevant plan fields a run executes; slice packing and receipts are excluded. */
|
||||
@@ -91,30 +111,118 @@ export function parseBinding(value: unknown): GateBinding | null {
|
||||
const b = value as GateBinding | null;
|
||||
if (!b || b.schema !== 1 || typeof b.repository !== 'string' || !Number.isSafeInteger(b.pr) || !Number.isSafeInteger(b.run_id)
|
||||
|| !Number.isSafeInteger(b.run_attempt) || !SHA.test(b.head_sha ?? '') || !SHA.test(b.base_sha ?? '') || typeof b.profile !== 'string'
|
||||
|| !Number.isSafeInteger(b.reconcile_exit) || typeof b.verdict !== 'string' || !/^[0-9a-f]{64}$/.test(b.selection_sha256 ?? '')) return null;
|
||||
|| !Number.isSafeInteger(b.reconcile_exit) || typeof b.verdict !== 'string' || !Number.isSafeInteger(b.manual_accepted) || b.manual_accepted < 0 || !SHA256.test(b.selection_sha256 ?? '')) return null;
|
||||
return b;
|
||||
}
|
||||
|
||||
export function parseReuse(value: unknown): GateReuse | null {
|
||||
const r = value as GateReuse | null;
|
||||
if (!r || r.schema !== 1 || r.tree !== 'same-modulo-stamps' || !Number.isSafeInteger(r.pr) || !Number.isSafeInteger(r.reused_run_id)
|
||||
|| !SHA.test(r.gated_head ?? '') || !SHA.test(r.current_head ?? '') || !SHA.test(r.base_sha ?? '') || r.gated_head === r.current_head
|
||||
|| !/^[0-9a-f]{64}$/.test(r.receipt_sha256 ?? '') || !/^[0-9a-f]{64}$/.test(r.selection_sha256 ?? '')
|
||||
|| !Array.isArray(r.stamp_files) || r.stamp_files.length === 0) return null;
|
||||
return r;
|
||||
if (!r || r.schema !== 1 || !Number.isSafeInteger(r.pr) || !Number.isSafeInteger(r.reused_run_id) || !Number.isSafeInteger(r.reused_run_attempt)
|
||||
|| !SHA.test(r.gated_head ?? '') || !SHA.test(r.current_head ?? '') || r.gated_head === r.current_head
|
||||
|| !SHA.test(r.gated_base ?? '') || !SHA.test(r.base_sha ?? '') || !SHA256.test(r.selection_sha256 ?? '') || typeof r.why !== 'string') return null;
|
||||
if (r.kind === 'restamp-only') {
|
||||
return r.gated_base === r.base_sha && SHA256.test(r.receipt_sha256 ?? '') && Array.isArray(r.stamp_files) && r.stamp_files.length > 0 ? r : null;
|
||||
}
|
||||
if (r.kind === 'main-moved') {
|
||||
return r.gated_base !== r.base_sha && SHA.test(r.gated_merge_base ?? '') && SHA.test(r.merge_base ?? '') && SHA256.test(r.own_diff_sha256 ?? '') ? r : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Git = (args: string[]) => { ok: boolean; out: string };
|
||||
const gitIn = (cwd: string): Git => (args) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? { ok: true, out: r.stdout ?? '' } : { ok: false, out: (r.stderr || String(r.error ?? '')).trim() };
|
||||
};
|
||||
|
||||
/** A merge commit's parents from its own object, so a depth-1 checkout can read them. */
|
||||
export function commitParents(git: Git, commit: string): string[] | null {
|
||||
const shown = git(['cat-file', '-p', commit]);
|
||||
if (!shown.ok) return null;
|
||||
return shown.out.split('\n').filter(line => line.startsWith('parent ')).map(line => line.slice(7).trim());
|
||||
}
|
||||
|
||||
/** The tested commit of a pull_request run is the merge of the head into the base; returns that base. */
|
||||
export function testedBase(git: Git, tested: string, head: string): { base: string } | { error: string } {
|
||||
const parents = commitParents(git, tested);
|
||||
if (!parents) return { error: `the tested commit ${tested.slice(0, 12)} is not readable` };
|
||||
if (parents.length !== 2 || parents[1] !== head || !SHA.test(parents[0]!)) {
|
||||
return { error: `the tested commit ${tested.slice(0, 12)} is not the merge of ${head.slice(0, 12)} into a base` };
|
||||
}
|
||||
return { base: parents[0]! };
|
||||
}
|
||||
|
||||
/** The files whose differences count as stamps under the base's policy. */
|
||||
function stampSet(policy: ShipPolicy): { version: string; changelog: string | null; releaseOutputs: string[] } {
|
||||
return { version: 'VERSION', changelog: policy.changelog ?? null, releaseOutputs: policy.release_outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* The PR's own diff from its merge-base with stamp files taken out: the full
|
||||
* binary patch of every other path (pre-image blob ids included, so a file
|
||||
* main also changed under the PR is a difference), package.json without its
|
||||
* version, and the CHANGELOG sections the PR added, without their headings.
|
||||
*/
|
||||
export function ownDiffSignature(git: Git, mergeBase: string, head: string, stamps: ReturnType<typeof stampSet>): { sha256: string } | { error: string } {
|
||||
const excluded = [stamps.version, 'package.json', ...(stamps.changelog ? [stamps.changelog] : []), ...stamps.releaseOutputs];
|
||||
const patch = git(['diff', '--no-ext-diff', '--no-renames', '--no-color', '--binary', '--full-index', mergeBase, head, '--', '.',
|
||||
...excluded.map(file => `:(exclude)${file}`)]);
|
||||
if (!patch.ok) return { error: `git diff ${mergeBase.slice(0, 12)} ${head.slice(0, 12)} failed (${patch.out.split('\n')[0]})` };
|
||||
const blob = (rev: string, file: string) => { const r = git(['show', `${rev}:${file}`]); return r.ok ? r.out : null; };
|
||||
const manifest = (text: string | null) => {
|
||||
if (text === null) return null;
|
||||
try { const parsed = JSON.parse(text); delete parsed.version; return JSON.stringify(parsed); } catch { return text; }
|
||||
};
|
||||
const [pkgBase, pkgHead] = [manifest(blob(mergeBase, 'package.json')), manifest(blob(head, 'package.json'))];
|
||||
const sections = (text: string | null) => text === null ? [] : parseChangelog(text).sections.map(s => s.lines.slice(1).join('\n').replace(/\n+$/, ''));
|
||||
const own: string[] = [];
|
||||
if (stamps.changelog) {
|
||||
const before = sections(blob(mergeBase, stamps.changelog));
|
||||
for (const body of sections(blob(head, stamps.changelog))) {
|
||||
const i = before.indexOf(body);
|
||||
if (i >= 0) before.splice(i, 1);
|
||||
else own.push(body);
|
||||
}
|
||||
}
|
||||
const body = { patch: patch.out, package: pkgBase === pkgHead ? null : [pkgBase, pkgHead], changelog: own.sort() };
|
||||
return { sha256: createHash('sha256').update(JSON.stringify(body)).digest('hex') };
|
||||
}
|
||||
|
||||
/** slices-report: what this executed run proved, or why it binds nothing. */
|
||||
export function buildBinding(o: { repository: string; pr: number; runId: number; runAttempt: number; headSha: string; baseSha: string;
|
||||
export function buildBinding(o: { repository: string; pr: number; runId: number; runAttempt: number; headSha: string; baseSha: string | null;
|
||||
profile: string; reconcileExit: number; reportDir: string }): { binding: GateBinding | null; reason: string } {
|
||||
if (fs.existsSync(path.join(o.reportDir, REUSE_FILE))) return { binding: null, reason: 'this run reused a verdict; only executed gates bind' };
|
||||
if (!o.baseSha) return { binding: null, reason: 'the tested base is unknown' };
|
||||
const manifest = readJson<PaidRunManifest>(path.join(o.reportDir, 'manifest.json'));
|
||||
if (!manifest?.entries) return { binding: null, reason: 'no readable manifest' };
|
||||
const outcomes = readJson<{ verdict?: { verdict?: string } }>(path.join(o.reportDir, 'collector-outcomes.json'));
|
||||
const outcomes = readJson<{ verdict?: { verdict?: string }; totals?: { manual_accepted?: number } }>(path.join(o.reportDir, 'collector-outcomes.json'));
|
||||
const binding: GateBinding = { schema: 1, repository: o.repository, pr: o.pr, run_id: o.runId, run_attempt: o.runAttempt,
|
||||
head_sha: o.headSha, base_sha: o.baseSha, profile: o.profile, reconcile_exit: o.reconcileExit,
|
||||
verdict: outcomes?.verdict?.verdict ?? 'MISSING', selection_sha256: selectionFingerprint(manifest) };
|
||||
return parseBinding(binding) ? { binding, reason: `bound ${binding.verdict} (reconcile exit ${binding.reconcile_exit})` } : { binding: null, reason: 'binding fields are incomplete' };
|
||||
verdict: outcomes?.verdict?.verdict ?? 'MISSING', manual_accepted: outcomes?.totals?.manual_accepted ?? -1, selection_sha256: selectionFingerprint(manifest) };
|
||||
return parseBinding(binding)
|
||||
? { binding, reason: `bound ${binding.verdict} at ${binding.head_sha.slice(0, 12)} on base ${binding.base_sha.slice(0, 12)} (reconcile exit ${binding.reconcile_exit})` }
|
||||
: { binding: null, reason: 'binding fields are incomplete' };
|
||||
}
|
||||
|
||||
/**
|
||||
* A newer completed run with no binding is passed over only when no paid
|
||||
* slice of it finished: superseded, readiness-blocked, a reuse, or cancelled
|
||||
* before or during its slices. A slice that finished (success, failure,
|
||||
* timed out) without a bound verdict blocks reuse of an older GREEN, so a
|
||||
* newer red or partial result is never skipped.
|
||||
*/
|
||||
export function unboundRunVerdict(gh: GhRunner, repo: string, run: WorkflowRun, timeoutMs: number): string | null {
|
||||
const listed = gh(['api', '-X', 'GET', `repos/${repo}/actions/runs/${run.id}/jobs`, '-f', 'filter=latest', '-f', 'per_page=100'], timeoutMs);
|
||||
if (listed.status !== 0) return `cannot list run ${run.id}'s jobs (${ghFailureReason(listed.stderr)})`;
|
||||
let slices: Array<{ name: string; conclusion: string | null }>;
|
||||
try {
|
||||
slices = ((JSON.parse(listed.stdout).jobs ?? []) as Array<{ name: string; conclusion: string | null }>)
|
||||
.filter(job => job.name === 'eval-slices' || job.name.startsWith('eval-slices '));
|
||||
} catch { return `run ${run.id}'s job list is unreadable`; }
|
||||
const finished = slices.find(job => job.conclusion !== null && job.conclusion !== 'skipped' && job.conclusion !== 'cancelled');
|
||||
return finished
|
||||
? `newer run ${run.id} (${run.head_sha.slice(0, 12)}) finished a paid slice (${finished.conclusion}) but bound no verdict (conclusion ${run.conclusion})`
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface FindOptions {
|
||||
@@ -124,22 +232,18 @@ export interface FindOptions {
|
||||
branch: string;
|
||||
selfRunId: number;
|
||||
headSha: string;
|
||||
baseSha: string;
|
||||
/** The base-ref checkout the receipt runs in (its HEAD must be baseSha). */
|
||||
/** This run's tested merge commit (github.sha); its first parent is the base. */
|
||||
testedSha: string;
|
||||
/** A base-ref checkout to fetch into and run the receipt in. */
|
||||
repoRoot: string;
|
||||
out: string;
|
||||
budgetMs?: number;
|
||||
maxRuns?: number;
|
||||
gh?: GhRunner;
|
||||
git?: (args: string[]) => { ok: boolean; out: string };
|
||||
git?: Git;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const gitIn = (cwd: string) => (args: string[]) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim() || (r.stderr ?? '').trim() };
|
||||
};
|
||||
|
||||
/** Never throws for an expected refusal: no candidate means the full gate runs, with the reason named. */
|
||||
export function findReusableGate(o: FindOptions): { candidate: GateReuse | null; lines: string[] } {
|
||||
const gh = o.gh ?? runGh;
|
||||
@@ -148,15 +252,15 @@ export function findReusableGate(o: FindOptions): { candidate: GateReuse | null;
|
||||
const deadline = now() + (o.budgetMs ?? 90_000);
|
||||
const left = () => Math.max(1_000, deadline - now());
|
||||
const lines: string[] = [];
|
||||
const refuse = (reason: string) => { lines.push(`no restamp-only reuse: ${reason}; the full gate runs`); return { candidate: null, lines }; };
|
||||
if (!SHA.test(o.headSha) || !SHA.test(o.baseSha)) return refuse('this run has no exact head and base SHA');
|
||||
const refuse = (reason: string) => { lines.push(`no gate reuse: ${reason}; the full gate runs`); return { candidate: null, lines }; };
|
||||
if (!SHA.test(o.headSha) || !SHA.test(o.testedSha)) return refuse('this run has no exact head and tested commit');
|
||||
|
||||
const listed = gh(['api', '-X', 'GET', `repos/${o.repo}/actions/workflows/evals.yml/runs`, '-f', 'event=pull_request', '-f', `branch=${o.branch}`, '-f', 'per_page=50'], left());
|
||||
if (listed.status !== 0) return refuse(ghFailureReason(listed.stderr));
|
||||
let runs: WorkflowRun[];
|
||||
try { runs = (JSON.parse(listed.stdout).workflow_runs ?? []) as WorkflowRun[]; } catch { return refuse('gh returned an unreadable run list'); }
|
||||
const scope = { pr: o.pr, headRepo: o.headRepo, selfRunId: o.selfRunId };
|
||||
const older = runs.filter(run => acceptedRun(run, scope) && run.id < o.selfRunId && run.status === 'completed')
|
||||
const older = runs.filter(run => acceptedRun(run, scope) && run.id < o.selfRunId)
|
||||
.sort((a, b) => b.id - a.id).slice(0, o.maxRuns ?? 10);
|
||||
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-reuse-'));
|
||||
@@ -166,12 +270,19 @@ export function findReusableGate(o: FindOptions): { candidate: GateReuse | null;
|
||||
const names = gh(['api', '-X', 'GET', `repos/${o.repo}/actions/runs/${run.id}/artifacts`, '-f', 'per_page=100'], left());
|
||||
if (names.status !== 0) return refuse(`cannot list run ${run.id}'s artifacts (${ghFailureReason(names.stderr)})`);
|
||||
let artifact: string | undefined;
|
||||
let sliceEvidence: string | undefined;
|
||||
try {
|
||||
artifact = (JSON.parse(names.stdout).artifacts ?? [])
|
||||
.filter((a: { name: string; expired?: boolean }) => !a.expired && /^gate-binding-a\d+$/.test(a.name))
|
||||
.map((a: { name: string }) => a.name).sort((a: string, b: string) => Number(b.slice(14)) - Number(a.slice(14)))[0];
|
||||
const listedNames = ((JSON.parse(names.stdout).artifacts ?? []) as Array<{ name: string; expired?: boolean }>).map(a => a.name);
|
||||
artifact = listedNames.filter(name => /^gate-binding-a\d+$/.test(name)).sort((a, b) => Number(b.slice(14)) - Number(a.slice(14)))[0];
|
||||
sliceEvidence = listedNames.find(name => name.startsWith('paid-slice-'));
|
||||
} catch { return refuse(`run ${run.id}'s artifact list is unreadable`); }
|
||||
if (!artifact) continue;
|
||||
if (run.status !== 'completed') return refuse(`newer run ${run.id} (${run.head_sha.slice(0, 12)}) is still ${run.status}`);
|
||||
if (!artifact) {
|
||||
if (sliceEvidence) return refuse(`newer run ${run.id} (${run.head_sha.slice(0, 12)}) holds paid slice results (${sliceEvidence}) but bound no verdict`);
|
||||
const unbound = unboundRunVerdict(gh, o.repo, run, left());
|
||||
if (unbound) return refuse(unbound);
|
||||
continue;
|
||||
}
|
||||
const dir = path.join(work, String(run.id));
|
||||
const got = gh(['run', 'download', String(run.id), '-R', o.repo, '-n', artifact, '-D', dir], left());
|
||||
if (got.status !== 0) return refuse(`cannot download ${artifact} of run ${run.id} (${ghFailureReason(got.stderr)})`);
|
||||
@@ -187,37 +298,63 @@ export function findReusableGate(o: FindOptions): { candidate: GateReuse | null;
|
||||
if (run.conclusion !== 'success' || binding.verdict !== 'GREEN' || binding.reconcile_exit !== 0) {
|
||||
return refuse(`the newest executed gate (run ${run.id}, ${binding.head_sha.slice(0, 12)}) is ${binding.verdict}, reconcile exit ${binding.reconcile_exit}, conclusion ${run.conclusion}`);
|
||||
}
|
||||
if (binding.profile !== 'pr') return refuse(`run ${run.id} ran the ${binding.profile} profile, not the PR gate`);
|
||||
if (binding.base_sha !== o.baseSha) {
|
||||
return refuse(`main moved: run ${run.id} gated on base ${binding.base_sha.slice(0, 12)}, this run's base is ${o.baseSha.slice(0, 12)} (reuse across a main move is R2, which is not enabled)`);
|
||||
if (binding.manual_accepted !== 0) {
|
||||
return refuse(`run ${run.id}'s GREEN includes ${binding.manual_accepted} manually accepted (unscored) case(s), which reuse would present as a clean pass`);
|
||||
}
|
||||
if (binding.profile !== 'pr') return refuse(`run ${run.id} ran the ${binding.profile} profile, not the PR gate`);
|
||||
if (binding.head_sha === o.headSha) return refuse(`run ${run.id} gated this same head; a re-run executes fresh`);
|
||||
|
||||
const head = git(['rev-parse', 'HEAD']);
|
||||
if (!head.ok || head.out !== o.baseSha) return refuse(`the receipt checkout is ${head.out.slice(0, 12) || 'unreadable'}, not the base ${o.baseSha.slice(0, 12)}`);
|
||||
const fetched = git(['fetch', '--no-tags', '--depth=1', 'origin', binding.head_sha, o.headSha]);
|
||||
if (!fetched.ok) return refuse(`cannot fetch ${binding.head_sha.slice(0, 12)} and ${o.headSha.slice(0, 12)} (${fetched.out.split('\n')[0]})`);
|
||||
const pinned = git(['update-ref', 'refs/remotes/origin/gate-reuse-base', o.baseSha]);
|
||||
if (!pinned.ok) return refuse('cannot pin the base ref for the policy');
|
||||
const fetched = git(['fetch', '--no-tags', '--depth=1', 'origin', binding.head_sha, o.headSha, o.testedSha]);
|
||||
if (!fetched.ok) return refuse(`cannot fetch the gated and current commits (${fetched.out.split('\n')[0]})`);
|
||||
const tested = testedBase(git, o.testedSha, o.headSha);
|
||||
if ('error' in tested) return refuse(tested.error);
|
||||
const baseSha = tested.base;
|
||||
const fetchedBase = git(['fetch', '--no-tags', '--depth=1', 'origin', baseSha]);
|
||||
const pinned = fetchedBase.ok && git(['update-ref', 'refs/remotes/origin/gate-reuse-base', baseSha]).ok;
|
||||
if (!pinned) return refuse(`cannot pin this run's base ${baseSha.slice(0, 12)} for the policy`);
|
||||
const policy = loadPolicyFromBase(o.repoRoot, 'gate-reuse-base');
|
||||
if (isUnavailable(policy)) return refuse(`the base's ship policy is unavailable (${policy.detail})`);
|
||||
if (policy.errors.length) return refuse(`the base's ship policy is invalid (${policy.errors[0]!.message})`);
|
||||
const receipt = buildReceipt({ repoRoot: o.repoRoot, gated: binding.head_sha, current: o.headSha, policy: policy.policy, policyLabel: policy.source.label });
|
||||
if ('error' in receipt) return refuse(`no tree receipt: ${receipt.error}`);
|
||||
lines.push(`TREE: ${receipt.tree === 'changed' ? `changed (${receipt.residual.join(', ')})` : 'same-modulo-stamps'} (${binding.head_sha.slice(0, 12)} -> ${o.headSha.slice(0, 12)}, policy ${receipt.policy})`);
|
||||
if (receipt.tree !== 'same-modulo-stamps') return refuse(`${o.headSha.slice(0, 12)} differs from the gated head beyond stamps: ${receipt.residual.join(', ')}`);
|
||||
const stampFiles = receipt.files.map(file => file.path);
|
||||
const allowed = new Set([receipt.files.find(f => f.rule === 'version-file')?.path ?? 'VERSION', 'package.json', policy.policy.changelog ?? '']);
|
||||
const outside = stampFiles.filter(file => !allowed.has(file) && !policy.policy.release_outputs.includes(file));
|
||||
if (outside.length) return refuse(`stamp-classified files outside the version file, package.json, CHANGELOG and release outputs: ${outside.join(', ')}`);
|
||||
if (!stampFiles.length) return refuse('the heads differ by no stamp file');
|
||||
const candidate: GateReuse = { schema: 1, pr: o.pr, reused_run_id: run.id, reused_run_attempt: binding.run_attempt,
|
||||
gated_head: binding.head_sha, current_head: o.headSha, base_sha: o.baseSha, tree: 'same-modulo-stamps',
|
||||
receipt_sha256: receiptDigest(receipt), stamp_files: stampFiles, old_version: receipt.old_version, new_version: receipt.new_version,
|
||||
selection_sha256: binding.selection_sha256 };
|
||||
const common = { schema: 1 as const, pr: o.pr, reused_run_id: run.id, reused_run_attempt: binding.run_attempt, gated_head: binding.head_sha,
|
||||
current_head: o.headSha, gated_base: binding.base_sha, base_sha: baseSha, selection_sha256: binding.selection_sha256 };
|
||||
|
||||
let candidate: GateReuse;
|
||||
if (binding.base_sha === baseSha) {
|
||||
const receipt = buildReceipt({ repoRoot: o.repoRoot, gated: binding.head_sha, current: o.headSha, policy: policy.policy, policyLabel: policy.source.label });
|
||||
if ('error' in receipt) return refuse(`no tree receipt: ${receipt.error}`);
|
||||
lines.push(`TREE: ${receipt.tree === 'changed' ? `changed (${receipt.residual.join(', ')})` : 'same-modulo-stamps'} (${binding.head_sha.slice(0, 12)} -> ${o.headSha.slice(0, 12)}, policy ${receipt.policy})`);
|
||||
if (receipt.tree !== 'same-modulo-stamps') return refuse(`${o.headSha.slice(0, 12)} differs from the gated head beyond stamps: ${receipt.residual.join(', ')}`);
|
||||
const stampFiles = receipt.files.map(file => file.path);
|
||||
const stamps = stampSet(policy.policy);
|
||||
const allowed = new Set([stamps.version, 'package.json', ...(stamps.changelog ? [stamps.changelog] : []), ...stamps.releaseOutputs]);
|
||||
const outside = stampFiles.filter(file => !allowed.has(file));
|
||||
if (outside.length) return refuse(`stamp-classified files outside the version file, package.json, CHANGELOG and release outputs: ${outside.join(', ')}`);
|
||||
if (!stampFiles.length) return refuse('the heads differ by no stamp file');
|
||||
candidate = { ...common, kind: 'restamp-only', receipt_sha256: receiptDigest(receipt), stamp_files: stampFiles,
|
||||
old_version: receipt.old_version, new_version: receipt.new_version,
|
||||
why: `main has not moved (base ${baseSha.slice(0, 12)}) and ${o.headSha.slice(0, 12)} differs from ${binding.head_sha.slice(0, 12)} only by stamp files (${stampFiles.join(', ')}): TREE same-modulo-stamps` };
|
||||
} else {
|
||||
const mergeBase = (base: string, head: string) => {
|
||||
const r = gh(['api', `repos/${o.repo}/compare/${base}...${head}`, '--jq', '.merge_base_commit.sha'], left());
|
||||
const sha = r.stdout.trim();
|
||||
return r.status === 0 && SHA.test(sha) ? sha : null;
|
||||
};
|
||||
const [gatedMb, currentMb] = [mergeBase(binding.base_sha, binding.head_sha), mergeBase(baseSha, o.headSha)];
|
||||
if (!gatedMb || !currentMb) return refuse('a merge-base is unavailable, so the own diffs cannot be compared');
|
||||
const fetchedMb = git(['fetch', '--no-tags', '--depth=1', 'origin', gatedMb, currentMb]);
|
||||
if (!fetchedMb.ok) return refuse(`cannot fetch the merge-bases (${fetchedMb.out.split('\n')[0]})`);
|
||||
const stamps = stampSet(policy.policy);
|
||||
const [gatedDiff, currentDiff] = [ownDiffSignature(git, gatedMb, binding.head_sha, stamps), ownDiffSignature(git, currentMb, o.headSha, stamps)];
|
||||
if ('error' in gatedDiff) return refuse(gatedDiff.error);
|
||||
if ('error' in currentDiff) return refuse(currentDiff.error);
|
||||
lines.push(`OWN DIFF: ${gatedDiff.sha256 === currentDiff.sha256 ? 'identical' : 'changed'} (${binding.head_sha.slice(0, 12)} from ${gatedMb.slice(0, 12)}: ${gatedDiff.sha256.slice(0, 12)}; ${o.headSha.slice(0, 12)} from ${currentMb.slice(0, 12)}: ${currentDiff.sha256.slice(0, 12)})`);
|
||||
if (gatedDiff.sha256 !== currentDiff.sha256) return refuse(`the PR's own diff (excluding stamp files) differs from the gated head's`);
|
||||
candidate = { ...common, kind: 'main-moved', gated_merge_base: gatedMb, merge_base: currentMb, own_diff_sha256: currentDiff.sha256,
|
||||
why: `main moved (${binding.base_sha.slice(0, 12)} -> ${baseSha.slice(0, 12)}) and the PR's own diff from its merge-base, excluding stamp files, is byte-identical to ${binding.head_sha.slice(0, 12)}'s (sha256 ${currentDiff.sha256.slice(0, 12)})` };
|
||||
}
|
||||
fs.mkdirSync(o.out, { recursive: true });
|
||||
fs.writeFileSync(path.join(o.out, CANDIDATE_FILE), `${JSON.stringify(candidate, null, 2)}\n`);
|
||||
lines.push(`restamp-only candidate: run ${run.id} passed the full gate at ${binding.head_sha.slice(0, 12)} on base ${o.baseSha.slice(0, 12)}; stamp files ${stampFiles.join(', ')}; receipt ${candidate.receipt_sha256}; plan-slices confirms the selection`);
|
||||
lines.push(`${candidate.kind} candidate: run ${run.id} passed the full gate at ${binding.head_sha.slice(0, 12)}; ${candidate.why}; plan-slices confirms the selection`);
|
||||
return { candidate, lines };
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
@@ -227,22 +364,25 @@ export function findReusableGate(o: FindOptions): { candidate: GateReuse | null;
|
||||
/** plan-slices: the reuse holds only when this head's own plan selects exactly what the reused gate ran. */
|
||||
export function confirmReuse(candidateDir: string, manifest: PaidRunManifest | null): { reuse: GateReuse | null; reason: string } {
|
||||
const candidate = parseReuse(readJson(path.join(candidateDir, CANDIDATE_FILE)));
|
||||
if (!candidate) return { reuse: null, reason: 'no restamp-only candidate; the full gate runs' };
|
||||
if (!candidate) return { reuse: null, reason: 'no reuse candidate; the full gate runs' };
|
||||
if (!manifest?.entries) return { reuse: null, reason: 'this run has no readable plan; the full gate runs' };
|
||||
const fingerprint = selectionFingerprint(manifest);
|
||||
if (fingerprint !== candidate.selection_sha256) {
|
||||
return { reuse: null, reason: `this head's selection ${fingerprint.slice(0, 12)} differs from the reused gate's ${candidate.selection_sha256.slice(0, 12)}; the full gate runs` };
|
||||
}
|
||||
return { reuse: candidate, reason: `reusing run ${candidate.reused_run_id}'s GREEN verdict at ${candidate.gated_head.slice(0, 12)}: TREE same-modulo-stamps (${candidate.stamp_files.join(', ')}), same base ${candidate.base_sha.slice(0, 12)}, same selection ${fingerprint.slice(0, 12)}` };
|
||||
return { reuse: candidate, reason: `reusing run ${candidate.reused_run_id}'s GREEN verdict at ${candidate.gated_head.slice(0, 12)} (${candidate.kind}): ${candidate.why}; same selection ${fingerprint.slice(0, 12)}` };
|
||||
}
|
||||
|
||||
export function reuseReport(reuse: GateReuse, repo: string): string {
|
||||
const evidence = reuse.kind === 'restamp-only'
|
||||
? [`TREE: same-modulo-stamps receipt=${reuse.receipt_sha256}`, `stamp files: ${reuse.stamp_files!.join(', ')} (${reuse.old_version ?? '-'} -> ${reuse.new_version ?? '-'})`]
|
||||
: [`OWN DIFF: identical sha256=${reuse.own_diff_sha256} (merge-base ${reuse.gated_merge_base} -> ${reuse.merge_base}; stamp files excluded)`];
|
||||
return [
|
||||
`GATE REUSED (R2a, restamp-only): no paid case ran on ${reuse.current_head}`,
|
||||
`GATE REUSED (${reuse.kind === 'restamp-only' ? 'R2a, restamp-only' : 'R2, main moved'}): no paid case ran on ${reuse.current_head}`,
|
||||
`reused verdict: GREEN from run ${reuse.reused_run_id} attempt ${reuse.reused_run_attempt} at ${reuse.gated_head} (https://github.com/${repo}/actions/runs/${reuse.reused_run_id})`,
|
||||
`base: ${reuse.base_sha} (unchanged since that gate)`,
|
||||
`TREE: same-modulo-stamps receipt=${reuse.receipt_sha256}`,
|
||||
`stamp files: ${reuse.stamp_files.join(', ')} (${reuse.old_version ?? '-'} -> ${reuse.new_version ?? '-'})`,
|
||||
`why: ${reuse.why}`,
|
||||
`base: ${reuse.gated_base} -> ${reuse.base_sha}`,
|
||||
...evidence,
|
||||
`selection: ${reuse.selection_sha256} (identical to the reused gate)`,
|
||||
'cost: $0 (no trials executed)',
|
||||
].join('\n');
|
||||
@@ -250,9 +390,9 @@ export function reuseReport(reuse: GateReuse, repo: string): string {
|
||||
|
||||
const USAGE = `usage:
|
||||
bun run scripts/gate-reuse.ts bind <report dir> --out <dir> --repo <owner/name> --pr <n> --run-id <id> --run-attempt <n>
|
||||
--head-sha <sha> --base-sha <sha> --profile <pr|full> --reconcile-exit <n>
|
||||
--head-sha <sha> --tested-sha <merge sha> --profile <pr|full> --reconcile-exit <n>
|
||||
bun run scripts/gate-reuse.ts find --repo <owner/name> --pr <n> --head-repo <owner/name> --branch <head branch> --run-id <id>
|
||||
--head-sha <sha> --base-sha <sha> --out <dir> [--budget-seconds 90]
|
||||
--head-sha <sha> --tested-sha <merge sha> --out <dir> [--budget-seconds 90]
|
||||
bun run scripts/gate-reuse.ts confirm <candidate dir> <manifest.json> --out <paid-plan dir> [--github-output <file>]
|
||||
bun run scripts/gate-reuse.ts report <gate-reuse.json> --repo <owner/name>
|
||||
bind and find never fail the run; no candidate means the full gate runs.`;
|
||||
@@ -263,21 +403,25 @@ if (import.meta.main) {
|
||||
const int = (name: string) => Number(flag(name));
|
||||
const usage = () => { console.error(USAGE); process.exit(2); };
|
||||
if (command === 'bind') {
|
||||
const [reportDir, out, repository, headSha, baseSha, profile] = [rest[0], flag('out'), flag('repo'), flag('head-sha'), flag('base-sha'), flag('profile')];
|
||||
if (!reportDir || !out || !repository || !headSha || !baseSha || !profile) usage();
|
||||
const [reportDir, out, repository, headSha, testedSha, profile] = [rest[0], flag('out'), flag('repo'), flag('head-sha'), flag('tested-sha'), flag('profile')];
|
||||
if (!reportDir || !out || !repository || !headSha || !testedSha || !profile) usage();
|
||||
const git = gitIn(process.cwd());
|
||||
if (!commitParents(git, testedSha!)) git(['fetch', '--no-tags', '--depth=1', 'origin', testedSha!]);
|
||||
const tested = testedBase(git, testedSha!, headSha!);
|
||||
const { binding, reason } = buildBinding({ repository: repository!, pr: int('pr'), runId: int('run-id'), runAttempt: int('run-attempt'),
|
||||
headSha: headSha!, baseSha: baseSha!, profile: profile!, reconcileExit: /^\d+$/.test(flag('reconcile-exit') ?? '') ? int('reconcile-exit') : -1, reportDir: reportDir! });
|
||||
headSha: headSha!, baseSha: 'base' in tested ? tested.base : null, profile: profile!,
|
||||
reconcileExit: /^\d+$/.test(flag('reconcile-exit') ?? '') ? int('reconcile-exit') : -1, reportDir: reportDir! });
|
||||
if (binding) {
|
||||
fs.mkdirSync(out!, { recursive: true });
|
||||
fs.writeFileSync(path.join(out!, BINDING_FILE), `${JSON.stringify(binding, null, 2)}\n`);
|
||||
}
|
||||
console.log(`- Gate binding: ${reason}`);
|
||||
console.log(`- Gate binding: ${reason}${'error' in tested ? ` (${tested.error})` : ''}`);
|
||||
} else if (command === 'find') {
|
||||
const [repo, headRepo, branch, headSha, baseSha, out] = [flag('repo'), flag('head-repo'), flag('branch'), flag('head-sha'), flag('base-sha'), flag('out')];
|
||||
if (!repo || !headRepo || !branch || !headSha || !baseSha || !out || !Number.isSafeInteger(int('pr')) || !Number.isSafeInteger(int('run-id'))) usage();
|
||||
const [repo, headRepo, branch, headSha, testedSha, out] = [flag('repo'), flag('head-repo'), flag('branch'), flag('head-sha'), flag('tested-sha'), flag('out')];
|
||||
if (!repo || !headRepo || !branch || !headSha || !testedSha || !out || !Number.isSafeInteger(int('pr')) || !Number.isSafeInteger(int('run-id'))) usage();
|
||||
const { lines } = findReusableGate({ repo: repo!, pr: int('pr'), headRepo: headRepo!, branch: branch!, selfRunId: int('run-id'), headSha: headSha!,
|
||||
baseSha: baseSha!, repoRoot: process.cwd(), out: out!, budgetMs: Number(flag('budget-seconds') ?? '90') * 1000 });
|
||||
console.log('### Restamp-only gate reuse (R2a)');
|
||||
testedSha: testedSha!, repoRoot: process.cwd(), out: out!, budgetMs: Number(flag('budget-seconds') ?? '90') * 1000 });
|
||||
console.log('### Paid-gate verdict reuse (R2a restamp-only, R2 main moved)');
|
||||
for (const line of lines) console.log(`- ${line}`);
|
||||
} else if (command === 'confirm') {
|
||||
const [dir, manifestPath, out] = [rest[0], rest[1], flag('out')];
|
||||
@@ -289,7 +433,7 @@ if (import.meta.main) {
|
||||
}
|
||||
const output = flag('github-output');
|
||||
if (output) fs.appendFileSync(output, `gate_reuse=${reuse ? 'true' : 'false'}\n`);
|
||||
console.log(`- Restamp-only gate reuse (R2a): ${reason}`);
|
||||
console.log(`- Paid-gate verdict reuse: ${reason}`);
|
||||
} else if (command === 'report') {
|
||||
const reuse = parseReuse(readJson(rest[0] ?? ''));
|
||||
const repo = flag('repo');
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('paid CI coordination stays off the eval image', () => {
|
||||
expect(executor.needs).toEqual(['build-image', 'plan-slices']);
|
||||
expect(JSON.stringify(executor.container)).toContain('needs.build-image.outputs.image-tag');
|
||||
if (name === 'evals.yml') {
|
||||
// A confirmed restamp-only reuse (R2a) is the one PR run that executes no slice.
|
||||
// A confirmed verdict reuse (R2a/R2) is the one PR run that executes no slice.
|
||||
expect(executor.if).toBe("${{ !cancelled() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' && needs.plan-slices.outputs.gate_reuse != 'true' }}");
|
||||
} else {
|
||||
expect(executor.if).toBeUndefined();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* PR paid-lane workflow pins (W1, CEO-13/14/34, ENG-7/11/12, DX-7, W8f):
|
||||
* coverage summaries, cancellation recovery off PR code, readiness-gated
|
||||
* dispatch (R1), restamp-only reuse wiring (R2a), dispatch inputs, and the
|
||||
* dispatch (R1), verdict reuse wiring (R2a/R2), dispatch inputs, and the
|
||||
* absence of the old inline subset program.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
@@ -111,22 +111,26 @@ describe('evals.yml readiness-gated dispatch (R1, GSTA-48)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('evals.yml restamp-only gate reuse wiring (R2a, GSTA-48)', () => {
|
||||
describe('evals.yml gate verdict reuse wiring (R2a restamp-only, R2 main moved; GSTA-48)', () => {
|
||||
test('base-ref recovery proposes a candidate; it never fails the run', () => {
|
||||
const find = step('recover-receipts', 'Find a restamp-only gate to reuse (R2a, base-ref code)')!;
|
||||
const find = step('recover-receipts', 'Find a gate verdict to reuse (R2a/R2, base-ref code)')!;
|
||||
expect(find.if).toBe("github.event_name == 'pull_request'");
|
||||
expect(find.env).toMatchObject({ GH_TOKEN: '${{ github.token }}', HEAD_SHA: '${{ github.event.pull_request.head.sha }}', BASE_SHA: '${{ github.event.pull_request.base.sha }}' });
|
||||
expect(find.env).toMatchObject({ GH_TOKEN: '${{ github.token }}', HEAD_SHA: '${{ github.event.pull_request.head.sha }}' });
|
||||
expect(find.env?.BASE_SHA).toBeUndefined();
|
||||
expect(find.run).toContain('scripts/gate-reuse.ts find');
|
||||
// The base is the tested merge commit's first parent, never the event's base.sha.
|
||||
expect(find.run).toContain('--tested-sha "$GITHUB_SHA"');
|
||||
expect(find.run).not.toContain('--base-sha');
|
||||
expect(find.run).toContain('exit 0');
|
||||
expect(find.run).toContain('|| echo');
|
||||
expect(step('recover-receipts', 'Hand a restamp-only reuse candidate to plan-slices')?.with).toMatchObject({ name: 'gate-reuse-candidate', path: '/tmp/gate-reuse' });
|
||||
expect(step('recover-receipts', 'Hand a gate reuse candidate to plan-slices')?.with).toMatchObject({ name: 'gate-reuse-candidate', path: '/tmp/gate-reuse' });
|
||||
});
|
||||
|
||||
test('the planner confirms the selection after its own plan, and a confirmed reuse runs no slice', () => {
|
||||
const planner = workflow.jobs['plan-slices']!;
|
||||
const names = planner.steps.map(s => s.name);
|
||||
expect(names.indexOf('Confirm restamp-only gate reuse (R2a)')).toBeGreaterThan(names.indexOf('Emit run manifest'));
|
||||
expect(step('plan-slices', 'Confirm restamp-only gate reuse (R2a)')!.run).toContain('gate-reuse.ts confirm /tmp/gate-reuse /tmp/paid-plan/manifest.json --out /tmp/paid-plan');
|
||||
expect(names.indexOf('Confirm gate verdict reuse (R2a/R2)')).toBeGreaterThan(names.indexOf('Emit run manifest'));
|
||||
expect(step('plan-slices', 'Confirm gate verdict reuse (R2a/R2)')!.run).toContain('gate-reuse.ts confirm /tmp/gate-reuse /tmp/paid-plan/manifest.json --out /tmp/paid-plan');
|
||||
expect(planner.outputs?.gate_reuse).toBe('${{ steps.gate_reuse.outputs.gate_reuse }}');
|
||||
expect(workflow.jobs['eval-slices']!.if).toContain("needs.plan-slices.outputs.gate_reuse != 'true'");
|
||||
});
|
||||
@@ -134,14 +138,16 @@ describe('evals.yml restamp-only gate reuse wiring (R2a, GSTA-48)', () => {
|
||||
test('the report prints a reused verdict fail-closed and only executed runs bind a verdict', () => {
|
||||
const report = workflow.jobs['slices-report']!;
|
||||
expect(step('slices-report', 'Reconcile slices against the manifest (fail-closed)')!.if).toBe("needs.plan-slices.outputs.gate_reuse != 'true'");
|
||||
const reused = step('slices-report', 'Report the reused restamp-only verdict (R2a)')!;
|
||||
const reused = step('slices-report', 'Report the reused gate verdict (R2a/R2)')!;
|
||||
expect(reused.if).toBe("needs.plan-slices.outputs.gate_reuse == 'true'");
|
||||
expect(reused.run).toContain('echo "exit=$code"');
|
||||
expect(report.outputs?.['reconcile-exit']).toBe('${{ steps.reconcile.outputs.exit || steps.reused.outputs.exit }}');
|
||||
expect(step('slices-report', 'Fail the workflow when reconciliation failed')!.if).toBe("(steps.reconcile.outputs.exit || steps.reused.outputs.exit) != '0'");
|
||||
const bind = step('slices-report', "Bind this run's verdict for restamp-only reuse (R2a)")!;
|
||||
const bind = step('slices-report', "Bind this run's verdict for reuse (R2a/R2)")!;
|
||||
expect(bind.if).toBe("always() && github.event_name == 'pull_request' && needs.plan-slices.outputs.gate_reuse != 'true'");
|
||||
expect(bind.env?.RECONCILE_EXIT).toBe('${{ steps.reconcile.outputs.exit }}');
|
||||
expect(bind.run).toContain('--tested-sha "$GITHUB_SHA"');
|
||||
expect(bind.env?.BASE_SHA).toBeUndefined();
|
||||
expect(step('slices-report', 'Upload the gate binding')?.with).toMatchObject({ name: 'gate-binding-a${{ github.run_attempt }}', path: '/tmp/gate-binding/gate-binding.json' });
|
||||
});
|
||||
});
|
||||
|
||||
+169
-17
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Restamp-only gate reuse (R2a, GSTA-48): a gate edit, so every refusal is a
|
||||
* negative control. Real git fixtures (a bare origin, the base-ref receipt
|
||||
* Paid-gate verdict reuse (R2a restamp-only, R2 main moved; GSTA-48): a gate
|
||||
* edit, so every refusal is a negative control. Real git fixtures (a bare origin, the base-ref receipt
|
||||
* checkout) and scripted gh responses: no network, no API spend.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
@@ -73,6 +73,28 @@ beforeAll(() => {
|
||||
sha.lockStamp = commit(work, 'lock-stamp', sha.gatedLock, { ...files('1.0.2.0'), 'CHANGELOG.md': changelog(['## [1.0.2.0] - 2026-10-03', '', '- the feature', '']),
|
||||
'bun.lock': lock('1.0.2.0') }, 'restamp that also moves the lockfile root version');
|
||||
sha.moved = commit(work, 'main', sha.base, { 'README.md': 'main moved\n' }, 'main moves');
|
||||
git(work, 'checkout', '-q', '-B', 'merged', sha.gated);
|
||||
git(work, 'merge', '-q', '--no-edit', sha.moved);
|
||||
git(work, 'push', '-q', 'origin', 'merged');
|
||||
sha.merged = git(work, 'rev-parse', 'HEAD');
|
||||
sha.rebased = commit(work, 'rebased', sha.moved, { ...files('1.0.1.0'), 'CHANGELOG.md': changelog(entry) }, 'the feature, rebased onto moved main');
|
||||
sha.rebasedCode = commit(work, 'rebased-code', sha.rebased, { 'src/feature.ts': 'export const feature = 3;\n' }, 'rebased plus a code byte');
|
||||
sha.rebasedEntry = commit(work, 'rebased-entry', sha.rebased, { 'CHANGELOG.md': changelog(['## [1.0.1.0] - 2026-10-02', '', '- the feature, reworded', '']) }, 'rebased plus entry text');
|
||||
sha.rebasedPkg = commit(work, 'rebased-pkg', sha.rebased, { 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.1.0', scripts: { test: 'bun test --bail' } }, null, 2) + '\n' }, 'rebased plus a package script');
|
||||
sha.mainTouch = commit(work, 'main-touch', sha.base, { 'src/feature.ts': 'export const feature = 0;\n// main note\n' }, 'main edits the file the PR edits');
|
||||
sha.touchRebased = commit(work, 'touch-rebased', sha.mainTouch, { ...files('1.0.1.0', { 'src/feature.ts': 'export const feature = 1;\n// main note\n' }), 'CHANGELOG.md': changelog(entry) },
|
||||
'the feature, rebased onto a main that edited its file');
|
||||
const digest = (version: string, body: string) => ({ 'agents-digest/gstack-AGENTS.md': '# gstack digest v' + version + ' — regenerate after upgrading\n\nbody\n' + body });
|
||||
const restampLog = { 'CHANGELOG.md': changelog(['## [1.0.2.0] - 2026-10-03', '', '- the feature', '']) };
|
||||
sha.gatedDash = commit(work, 'gated-dash', sha.base, { ...files('1.0.1.0', digest('1.0.1.0', '-- old banner\n')), 'CHANGELOG.md': changelog(entry) }, 'a digest line that looks like a diff header');
|
||||
sha.dashReplace = commit(work, 'dash-replace', sha.gatedDash, { ...files('1.0.2.0', digest('1.0.2.0', '++ new banner\n')), ...restampLog }, 'restamp that replaces a -- line with a ++ line');
|
||||
sha.dashRemove = commit(work, 'dash-remove', sha.gatedDash, { ...files('1.0.2.0', digest('1.0.2.0', '')), ...restampLog }, 'restamp that removes a -- line');
|
||||
sha.dashAdd = commit(work, 'dash-add', sha.gated, { ...files('1.0.2.0', digest('1.0.2.0', '++ ordinary non-version body text\n')), ...restampLog }, 'restamp that adds a ++ line');
|
||||
git(work, 'checkout', '-q', '-B', 'mode', sha.restamp);
|
||||
git(work, 'update-index', '--chmod=+x', 'agents-digest/gstack-AGENTS.md');
|
||||
git(work, 'commit', '-q', '-m', 'restamp plus a digest mode change');
|
||||
git(work, 'push', '-q', 'origin', 'mode');
|
||||
sha.mode = git(work, 'rev-parse', 'HEAD');
|
||||
receipt = path.join(scratch, 'receipt');
|
||||
git(scratch, 'clone', '-q', '--depth=1', 'file://' + origin, receipt);
|
||||
git(receipt, 'fetch', '-q', '--depth=1', 'origin', sha.base);
|
||||
@@ -80,16 +102,33 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
const binding = (over: Partial<GateBinding> = {}): GateBinding => ({ schema: 1, repository: REPO, pr: 42, run_id: 300, run_attempt: 1,
|
||||
head_sha: sha.gated!, base_sha: sha.base!, profile: 'pr', reconcile_exit: 0, verdict: 'GREEN', selection_sha256: 'f'.repeat(64), ...over });
|
||||
head_sha: sha.gated!, base_sha: sha.base!, profile: 'pr', reconcile_exit: 0, verdict: 'GREEN', manual_accepted: 0, selection_sha256: 'f'.repeat(64), ...over });
|
||||
const run = (id: number, head: string, conclusion: string | null = 'success', over: Partial<WorkflowRun> = {}): WorkflowRun => ({
|
||||
id, status: 'completed', conclusion, event: 'pull_request', head_sha: head, run_attempt: 1, head_repository: { full_name: REPO },
|
||||
pull_requests: [{ number: 42 }], ...over });
|
||||
|
||||
/** gh: the run list, each run's artifact list, and downloads that write the scripted binding (or raw text). */
|
||||
function fakeGh(runs: WorkflowRun[], bindings: Record<number, GateBinding | string>, failList = ''): GhRunner {
|
||||
type Jobs = Record<number, Array<{ name: string; conclusion: string | null }>>;
|
||||
|
||||
/**
|
||||
* gh: the run list, each run's artifact and job lists (a bound run ran its
|
||||
* slices; an unbound one ran none unless scripted), downloads that write the
|
||||
* scripted binding (or raw text), and merge-bases from the fixture repo.
|
||||
*/
|
||||
function fakeGh(runs: WorkflowRun[], bindings: Record<number, GateBinding | string>, failList = '', jobs: Jobs = {}, failCompare = false): GhRunner {
|
||||
return (args) => {
|
||||
const ok = (body: unknown) => ({ status: 0, stdout: JSON.stringify(body), stderr: '' });
|
||||
if (args.some(a => a.endsWith('/workflows/evals.yml/runs'))) return failList ? { status: 1, stdout: '', stderr: failList } : ok({ workflow_runs: runs });
|
||||
const jobList = args.find(a => /\/runs\/\d+\/jobs$/.test(a));
|
||||
if (jobList) {
|
||||
const id = Number(/runs\/(\d+)\//.exec(jobList)![1]);
|
||||
return ok({ jobs: [{ name: 'plan-slices', conclusion: 'success' }, ...(jobs[id] ?? (id in bindings ? [{ name: 'eval-slices (1)', conclusion: 'success' }] : []))] });
|
||||
}
|
||||
const compare = args.find(a => /\/compare\/[0-9a-f]{40}\.\.\.[0-9a-f]{40}$/.test(a));
|
||||
if (compare) {
|
||||
if (failCompare) return { status: 1, stdout: '', stderr: 'HTTP 404: Not Found' };
|
||||
const [, a, b] = /compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/.exec(compare)!;
|
||||
return { status: 0, stdout: git(path.join(scratch, 'work'), 'merge-base', a!, b!) + '\n', stderr: '' };
|
||||
}
|
||||
const listed = args.find(a => /\/runs\/\d+\/artifacts$/.test(a));
|
||||
if (listed) {
|
||||
const id = Number(/runs\/(\d+)\//.exec(listed)![1]);
|
||||
@@ -106,11 +145,26 @@ function fakeGh(runs: WorkflowRun[], bindings: Record<number, GateBinding | stri
|
||||
};
|
||||
}
|
||||
|
||||
/** The tested commit of a pull_request run: the merge of the head into the base (first parent), pushed like refs/pull/N/merge. */
|
||||
const merges = new Map<string, string>();
|
||||
function tested(base: string, head: string): string {
|
||||
const key = base + head;
|
||||
if (!merges.has(key)) {
|
||||
const work = path.join(scratch, 'work');
|
||||
const merge = git(work, 'commit-tree', head + '^{tree}', '-p', base, '-p', head, '-m', 'merge');
|
||||
git(work, 'push', '-q', 'origin', merge + ':refs/heads/pull-merge-' + merges.size);
|
||||
merges.set(key, merge);
|
||||
}
|
||||
return merges.get(key)!;
|
||||
}
|
||||
|
||||
let outCount = 0;
|
||||
function find(head: string, runs: WorkflowRun[], bindings: Record<number, GateBinding | string>, over: { baseSha?: string; failList?: string } = {}) {
|
||||
function find(head: string, runs: WorkflowRun[], bindings: Record<number, GateBinding | string>,
|
||||
over: { baseSha?: string; failList?: string; jobs?: Jobs; failCompare?: boolean; testedSha?: string } = {}) {
|
||||
const out = path.join(scratch, 'out-' + ++outCount);
|
||||
const result = findReusableGate({ repo: REPO, pr: 42, headRepo: REPO, branch: 'feature', selfRunId: 500, headSha: head,
|
||||
baseSha: over.baseSha ?? sha.base!, repoRoot: receipt, out, gh: fakeGh(runs, bindings, over.failList) });
|
||||
testedSha: over.testedSha ?? tested(over.baseSha ?? sha.base!, head), repoRoot: receipt, out,
|
||||
gh: fakeGh(runs, bindings, over.failList, over.jobs, over.failCompare) });
|
||||
return { ...result, out, text: result.lines.join('\n') };
|
||||
}
|
||||
|
||||
@@ -119,19 +173,42 @@ describe('find: the positive case', () => {
|
||||
const { candidate, out, text } = find(sha.restamp!, [run(300, sha.gated!)], { 300: binding() });
|
||||
expect(text).toContain('TREE: same-modulo-stamps');
|
||||
expect(candidate).not.toBeNull();
|
||||
expect(candidate!.stamp_files.sort()).toEqual(['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'package.json']);
|
||||
expect(candidate!.kind).toBe('restamp-only');
|
||||
expect(candidate!.stamp_files!.sort()).toEqual(['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'package.json']);
|
||||
expect(candidate!).toMatchObject({ reused_run_id: 300, gated_head: sha.gated, current_head: sha.restamp, base_sha: sha.base,
|
||||
old_version: '1.0.1.0', new_version: '1.0.2.0', selection_sha256: 'f'.repeat(64) });
|
||||
expect(candidate!.receipt_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(parseReuse(JSON.parse(fs.readFileSync(path.join(out, CANDIDATE_FILE), 'utf8')))).toEqual(candidate);
|
||||
});
|
||||
|
||||
test('cancelled or skipped runs that bound nothing are passed over to the newest executed gate', () => {
|
||||
const { candidate } = find(sha.restamp!, [run(320, sha.gated!, 'cancelled'), run(310, sha.gated!, 'success'), run(300, sha.gated!)], { 300: binding() });
|
||||
test('newer runs whose paid slices never finished are passed over to the newest executed gate', () => {
|
||||
const { candidate } = find(sha.restamp!, [run(330, sha.gated!, 'cancelled'), run(320, sha.gated!, 'cancelled'), run(310, sha.gated!, 'success'),
|
||||
run(305, sha.gated!, 'failure'), run(300, sha.gated!)], { 300: binding() },
|
||||
{ jobs: { 330: [{ name: 'eval-slices (1)', conclusion: 'cancelled' }, { name: 'eval-slices (2)', conclusion: 'skipped' }],
|
||||
310: [{ name: 'eval-slices', conclusion: 'skipped' }], 305: [{ name: 'readiness', conclusion: 'failure' }] } });
|
||||
expect(candidate?.reused_run_id).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('find: main moved (R2)', () => {
|
||||
test('a head merged with the moved main, or rebased onto it, reuses the gate when its own diff is byte-identical', () => {
|
||||
for (const head of [sha.merged!, sha.rebased!]) {
|
||||
const { candidate, text } = find(head, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved });
|
||||
expect(text).toContain('OWN DIFF: identical');
|
||||
expect(candidate).toMatchObject({ kind: 'main-moved', reused_run_id: 300, gated_head: sha.gated, current_head: head,
|
||||
gated_base: sha.base, base_sha: sha.moved, gated_merge_base: sha.base });
|
||||
expect(candidate!.why).toContain('main moved');
|
||||
expect(candidate!.own_diff_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(parseReuse(candidate)).toEqual(candidate);
|
||||
}
|
||||
});
|
||||
|
||||
test('a restamp on a moved main: stamp files are outside the own-diff compare', () => {
|
||||
const { candidate } = find(sha.restamp!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved });
|
||||
expect(candidate?.kind).toBe('main-moved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('find: negative controls (each forces the full gate)', () => {
|
||||
const refused = (r: { candidate: GateReuse | null; out: string; text: string }, reason: string | RegExp) => {
|
||||
expect(r.candidate).toBeNull();
|
||||
@@ -156,8 +233,65 @@ describe('find: negative controls (each forces the full gate)', () => {
|
||||
'stamp-classified files outside the version file, package.json, CHANGELOG and release outputs: bun.lock');
|
||||
});
|
||||
|
||||
test('a different base: main moved between the gate and this run', () => {
|
||||
refused(find(sha.restamp!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }), 'main moved');
|
||||
test('main moved and the own diff changed: a code byte, CHANGELOG entry text, a package.json field', () => {
|
||||
for (const head of [sha.rebasedCode!, sha.rebasedEntry!, sha.rebasedPkg!]) {
|
||||
refused(find(head, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }), "the PR's own diff (excluding stamp files) differs");
|
||||
}
|
||||
});
|
||||
|
||||
test('main moved and changed a file the PR also changes: the pre-image differs, so the own diff is not byte-identical', () => {
|
||||
refused(find(sha.touchRebased!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.mainTouch }), 'OWN DIFF: changed');
|
||||
});
|
||||
|
||||
test('main moved and a merge-base or the tested merge commit is unavailable', () => {
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved, failCompare: true }), 'a merge-base is unavailable');
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!)], { 300: binding() }, { testedSha: sha.rebased }), 'is not the merge of');
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!)], { 300: binding() }, { testedSha: 'd'.repeat(40) }), /cannot fetch|not readable/);
|
||||
});
|
||||
|
||||
test('main moved and the newest executed gate is red, partial or a different profile', () => {
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!)], { 300: binding({ verdict: 'RED' }) }, { baseSha: sha.moved }), 'is RED');
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!, 'cancelled')], { 300: binding() }, { baseSha: sha.moved }), 'conclusion cancelled');
|
||||
refused(find(sha.rebased!, [run(300, sha.gated!)], { 300: binding({ profile: 'full' }) }, { baseSha: sha.moved }), 'not the PR gate');
|
||||
});
|
||||
|
||||
test('a newer run that finished a paid slice but bound no verdict blocks an older GREEN (never skipped)', () => {
|
||||
for (const conclusion of ['failure', 'timed_out', 'success']) {
|
||||
for (const runConclusion of ['failure', 'cancelled']) {
|
||||
refused(find(sha.rebased!, [run(310, sha.merged!, runConclusion), run(300, sha.gated!)], { 300: binding() },
|
||||
{ baseSha: sha.moved, jobs: { 310: [{ name: 'eval-slices (1)', conclusion }, { name: 'eval-slices (2)', conclusion: 'cancelled' }] } }),
|
||||
'newer run 310');
|
||||
}
|
||||
}
|
||||
refused(find(sha.restamp!, [run(310, sha.gated!, 'success'), run(300, sha.gated!)], { 300: binding() },
|
||||
{ jobs: { 310: [{ name: 'eval-slices (1)', conclusion: 'success' }] } }), 'finished a paid slice (success) but bound no verdict');
|
||||
});
|
||||
|
||||
test('release-output lines that look like diff headers are content, not headers (receipt parser)', () => {
|
||||
for (const head of [sha.dashReplace!, sha.dashRemove!]) {
|
||||
refused(find(head, [run(300, sha.gatedDash!)], { 300: binding({ head_sha: sha.gatedDash }) }), 'beyond stamps: agents-digest/gstack-AGENTS.md');
|
||||
}
|
||||
refused(find(sha.dashAdd!, [run(300, sha.gated!)], { 300: binding() }), 'beyond stamps: agents-digest/gstack-AGENTS.md');
|
||||
});
|
||||
|
||||
test('a file mode change beside a restamp', () => {
|
||||
refused(find(sha.mode!, [run(300, sha.gated!)], { 300: binding() }), 'beyond stamps: agents-digest/gstack-AGENTS.md');
|
||||
});
|
||||
|
||||
test('a GREEN that includes manually accepted (unscored) cases', () => {
|
||||
refused(find(sha.restamp!, [run(300, sha.gated!)], { 300: binding({ manual_accepted: 1 }) }), 'manually accepted (unscored)');
|
||||
refused(find(sha.restamp!, [run(300, sha.gated!)], { 300: binding({ manual_accepted: -1 }) }), 'missing or malformed');
|
||||
});
|
||||
|
||||
test('a newer run that holds slice results, or is still running, blocks an older GREEN', () => {
|
||||
const gh = fakeGh([run(310, sha.gated!, 'cancelled'), run(300, sha.gated!)], { 300: binding() });
|
||||
const withSlice: GhRunner = (args, t) => args.some(a => a.endsWith('/runs/310/artifacts'))
|
||||
? { status: 0, stdout: JSON.stringify({ artifacts: [{ name: 'paid-plan' }, { name: 'paid-slice-1-a1' }] }), stderr: '' } : gh(args, t);
|
||||
const out = path.join(scratch, 'out-slice');
|
||||
const r = findReusableGate({ repo: REPO, pr: 42, headRepo: REPO, branch: 'feature', selfRunId: 500, headSha: sha.restamp!,
|
||||
testedSha: tested(sha.base!, sha.restamp!), repoRoot: receipt, out, gh: withSlice });
|
||||
refused({ ...r, out, text: r.lines.join('\n') }, 'holds paid slice results (paid-slice-1-a1) but bound no verdict');
|
||||
refused(find(sha.restamp!, [run(310, sha.gated!, null, { status: 'in_progress' }), run(300, sha.gated!)], { 300: binding() }), 'is still in_progress');
|
||||
});
|
||||
|
||||
test('a missing receipt: no executed gate bound a verdict', () => {
|
||||
@@ -239,7 +373,7 @@ describe('bind and report', () => {
|
||||
const dir = path.join(scratch, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest()));
|
||||
fs.writeFileSync(path.join(dir, 'collector-outcomes.json'), JSON.stringify({ verdict: { verdict: 'GREEN' } }));
|
||||
fs.writeFileSync(path.join(dir, 'collector-outcomes.json'), JSON.stringify({ verdict: { verdict: 'GREEN' }, totals: { manual_accepted: 0 } }));
|
||||
if (withReuse) fs.writeFileSync(path.join(dir, REUSE_FILE), '{}');
|
||||
return dir;
|
||||
};
|
||||
@@ -256,11 +390,16 @@ describe('bind and report', () => {
|
||||
|
||||
test('the CLI binds a missing reconcile exit as -1, never as a pass', () => {
|
||||
const out = path.join(scratch, 'bind-cli');
|
||||
const r = spawnSync(process.execPath, ['run', path.join(ROOT, 'scripts/gate-reuse.ts'), 'bind', reportDir('bind-cli-report'), '--out', out,
|
||||
'--repo', REPO, '--pr', '42', '--run-id', '300', '--run-attempt', '1', '--head-sha', 'a'.repeat(40), '--base-sha', 'b'.repeat(40),
|
||||
'--profile', 'pr', '--reconcile-exit', ''], { encoding: 'utf8', timeout: 30_000 });
|
||||
const bind = (head: string, testedSha: string, dir: string) => spawnSync(process.execPath, ['run', path.join(ROOT, 'scripts/gate-reuse.ts'), 'bind',
|
||||
reportDir('bind-cli-report'), '--out', dir, '--repo', REPO, '--pr', '42', '--run-id', '300', '--run-attempt', '1', '--head-sha', head,
|
||||
'--tested-sha', testedSha, '--profile', 'pr', '--reconcile-exit', ''], { cwd: receipt, encoding: 'utf8', timeout: 30_000 });
|
||||
const r = bind(sha.gated!, tested(sha.moved!, sha.gated!), out);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, BINDING_FILE), 'utf8')).reconcile_exit).toBe(-1);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, BINDING_FILE), 'utf8'))).toMatchObject({ reconcile_exit: -1, base_sha: sha.moved, head_sha: sha.gated });
|
||||
// A tested commit that is not the merge of this head binds nothing.
|
||||
const none = path.join(scratch, 'bind-cli-none');
|
||||
expect(bind(sha.restamp!, tested(sha.moved!, sha.gated!), none).status).toBe(0);
|
||||
expect(fs.existsSync(path.join(none, BINDING_FILE))).toBe(false);
|
||||
});
|
||||
|
||||
test('report prints the reused verdict with its receipt, and fails closed without a valid record', () => {
|
||||
@@ -270,6 +409,12 @@ describe('bind and report', () => {
|
||||
expect(text).toContain('GATE REUSED (R2a, restamp-only)');
|
||||
expect(text).toContain('TREE: same-modulo-stamps receipt=' + reuse.receipt_sha256);
|
||||
expect(text).toContain('https://github.com/garrytan/gstack/actions/runs/300');
|
||||
const moved = find(sha.rebased!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }).candidate!;
|
||||
const movedText = reuseReport(moved, REPO);
|
||||
expect(movedText).toContain('GATE REUSED (R2, main moved)');
|
||||
expect(movedText).toContain('OWN DIFF: identical sha256=' + moved.own_diff_sha256);
|
||||
expect(movedText).toContain('why: main moved');
|
||||
expect(movedText).toContain('at ' + sha.gated);
|
||||
const bad = spawnSync(process.execPath, ['run', path.join(ROOT, 'scripts/gate-reuse.ts'), 'report', path.join(scratch, 'missing.json'), '--repo', REPO],
|
||||
{ encoding: 'utf8', timeout: 30_000 });
|
||||
expect(bad.status).toBe(1);
|
||||
@@ -301,6 +446,13 @@ describe.skipIf(!Bun.which('jq') || !Bun.which('bash'))('the PR comment for a re
|
||||
expect(reused.stdout).toContain('## E2E Evals: ✅ PASS (reused: restamp-only, R2a)');
|
||||
expect(reused.stdout).toContain('TREE: same-modulo-stamps receipt=');
|
||||
expect(reused.stdout).toContain('actions/runs/300');
|
||||
const moved = find(sha.rebased!, [run(300, sha.gated!)], { 300: binding() }, { baseSha: sha.moved }).candidate!;
|
||||
fs.writeFileSync(path.join(paidDir, REUSE_FILE), JSON.stringify(moved));
|
||||
const movedComment = exec('0');
|
||||
expect(movedComment.status, movedComment.stderr).toBe(0);
|
||||
expect(movedComment.stdout).toContain('## E2E Evals: ✅ PASS (reused: main moved, R2)');
|
||||
expect(movedComment.stdout).toContain('OWN DIFF: identical sha256=' + moved.own_diff_sha256);
|
||||
expect(movedComment.stdout).toContain('because main moved');
|
||||
const failed = exec('1');
|
||||
expect(failed.status, failed.stderr).toBe(0);
|
||||
expect(failed.stdout).not.toContain('PASS (reused');
|
||||
|
||||
Reference in new issue
Block a user