fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json)

- ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2
  merge of main), as verify --train already did; a real edit stays STALE
- ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is
  missing or merges an older head; a finished screen that planned 0 cases is
  never followed, and train build seats that slot unscreened (GSTA-95 on #3431)
- gstack-ship-receipt result writes ship-run.json (a /ship record, not a run
  v1 manifest), validates --head, and prints status=incomplete on gh failures
- bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines
- tests for every state-matrix cell the review named; the train fixture's
  merge ref is a real merge commit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 02:07:23 +00:00
1 parent 268b0125a5
commit ec438fe8b1
17 files changed
+368 -44

No files matched your search

+6 -5
View File
@@ -2,19 +2,20 @@
## [Unreleased]
**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at every queue step, with no flag around it.
**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it.
### Changed
- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The informational `SHIP_RECEIPT:` line still prints in the block.
- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output).
- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull/<n>/merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen whose record says it planned 0 cases, and `train build` prints `SCREEN: #<n> run <id> planned 0 cases; counted as not-run` and seats that slot unscreened.
- **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp.
- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `<state root>/ship/runs/<utc>-<head12>/{run.json,ship-receipt.md}` and prints `GSTACK_RESULT: skill=ship status=complete run=<dir>`, or `status=refused run=-` with the receipt code, so a parent's merge check reads `run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections.
- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `<state root>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=<dir>`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections.
- **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body.
- **`INSTALL_UNVERIFIED` with no verdict names the directory to install from.** An installer run from the skill's own folder records a verdict for no project, so /ship launched from the project found none and the fix said only "run the installer on this machine" (GSTA-85). Both twins (`lib/install-verdict.ts`, `bin/gstack-install-verdict.sh`) now say `run … scripts/install.sh --for ship from <project>: the verdict is keyed by the project directory the installer runs in`.
- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt), the two codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt.
- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt; the receipt is a process gate, not an authenticity check), the three codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt.
- **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed.
### Tests
- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit. `test/gstack-ship-queue.test.ts` covers the per-PR READY block. `test/gstack-ship-receipt.test.ts` covers `result` from a PR and from the handoff file. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block.
- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull/<n>/merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt. `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block.
## [1.91.224.0] - 2026-10-25
+1
View File
@@ -279,6 +279,7 @@ gstack_result_anchor() {
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
APPROVAL_CLASSES_INVALID) printf '%s' 'approval-classes-invalid' ;;
+15 -7
View File
@@ -17,7 +17,9 @@
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
* and with --body makes it the first section of that PR body file.
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
* run record under <state>/ship/runs/ and prints GSTACK_RESULT: skill=ship.
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
@@ -42,7 +44,7 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
read --body <file> parse the block from a file
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
writes <state>/ship/runs/<utc>-<head12>/{run.json,ship-receipt.md} and prints
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
census --repo <r> --since <tag|date> [--cohort <file>]
the P7 adoption number over merged PRs (deduplicated by run and thread)
@@ -209,6 +211,12 @@ function cmdResult(rest: string[], json: boolean): number {
return EXIT.refused;
};
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
if (!/^[0-9a-f]{7,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
const incomplete = (why: string) => {
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
return EXIT.fail;
};
let body: string;
let source: string;
if (bodyFile) {
@@ -218,12 +226,12 @@ function cmdResult(rest: string[], json: boolean): number {
} else if (pr) {
const repo = flag(rest, '--repo');
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body']);
if (!r.ok) { process.stderr.write(`gstack-ship-receipt result: gh pr view failed: ${r.err}\n`); return EXIT.fail; }
try { body = JSON.parse(r.out).body ?? ''; } catch { process.stderr.write('gstack-ship-receipt result: gh returned no JSON body\n'); return EXIT.fail; }
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
try { body = JSON.parse(r.out).body ?? ''; } catch { return incomplete('gh returned no JSON body'); }
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
const bound = bindReceipt(body, pr ?? String(extractReceipt(body).receipt?.pr ?? '-'), [head]);
if (!bound.ok) return refused(bound.code, bound.message.replace(/^PR #-'s /, "the receipt's "));
const bound = bindReceipt(body, pr, [head]);
if (!bound.ok) return refused(bound.code, bound.message);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dir = flag(rest, '--out') ?? path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${head.slice(0, 12)}`);
fs.mkdirSync(dir, { recursive: true });
@@ -234,7 +242,7 @@ function cmdResult(rest: string[], json: boolean): number {
queue_mode: bound.receipt.queue_mode ?? null, session_kind: bound.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
receipt: receiptPath, receipt_source: source, branch: repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) ?? null : null, created_at: new Date().toISOString(),
};
fs.writeFileSync(path.join(dir, 'run.json'), JSON.stringify(run, null, 2) + '\n');
fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n');
process.stderr.write(bound.line + '\n');
process.stdout.write(json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
return EXIT.ok;
+1 -1
View File
@@ -648,7 +648,7 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
case "$SKILL_NAME" in
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/run.json and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body with the receipt through the pr-body section's existing-PR path (never pr-create, never a push); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body with the receipt through the pr-body section's existing-PR path (never pr-create, never a push); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
+21 -3
View File
@@ -332,7 +332,18 @@ slot. The queue enforces this at every step from the PR body alone:
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
on it: the receipt written for the pinned head stays the binding one. `verify
--train` accepts a receipt whose `head` is either the slot's pinned head or
the current head; any other head is `QUEUE_RECEIPT_STALE`.
the current head, and the re-READY after the stamp push (`ready` at the stamp
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
or (from slot 2 on) the pin merged with main plus stamp files; any other head
is `QUEUE_RECEIPT_STALE`.
This is a process gate, not an authenticity check: the receipt is a block in
the PR body, which the PR author writes, so the codes prove that /ship was run
and recorded for this head, not that nobody typed the block by hand. Fork heads
are already refused (`QUEUE_TRAIN_UNTRUSTED`). The `ship-run.json` that
`gstack-ship-receipt result` writes under the lane's state root is a
normalized copy of that same block bound to the head it was run at, not an
authenticity proof either.
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
explicit choice:
@@ -340,7 +351,7 @@ explicit choice:
```bash
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
```
Without a choice it prints the `SCREEN:` selection (with
@@ -356,7 +367,14 @@ dispatches `gate_workflow` with `screen_pr`, `screen_merge_sha` (the
the policy, so a repeat follows the same run and a broad screen never adopts
a narrow one. The READY block gains `screen: …`, and the receipt is written
locally and to the notes ref. `--dry-run` prints the dispatch without sending
it.
it. Before dispatching, `ready` fetches the merge ref and requires its second
parent to be the PR head: a missing or lagging merge ref (GitHub stops
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans
no cases and reports success. A finished screen whose record planned 0 cases
is never followed as a screen: `ready --screen` dispatches a fresh one and
`train build` prints `SCREEN: #<n> run <id> planned 0 cases; counted as
not-run` and seats the slot unscreened.
**2. Coordinator: build.** Name the pins exactly as READY reported them:
+22
View File
@@ -4053,6 +4053,28 @@ written for the pinned head is the binding one and `verify --train` accepts it.
`gstack-ship-queue ready --pr <n> --screen|--no-screen`. A lane never stamps
to fix this: the stamp comes only when the slot is called.
<a id="queue-screen-stale-ref"></a>
### `QUEUE_SCREEN_STALE_REF`
**Meaning.** `gstack-ship-queue ready --screen` dispatches the screen against
GitHub's `refs/pull/<n>/merge`, and that ref is missing or still merges an
older head than the PR's pushed head. GitHub stops recomputing the merge ref
while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
counts), so a screen dispatched there checks out the old merge, sees itself
superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431:
`ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares
the merge ref's second parent with the PR head before dispatching, and a
finished screen whose record says it planned 0 cases is never adopted as a
screen: `ready --screen` dispatches a fresh one and `train build` prints
`SCREEN: #<n> run <id> planned 0 cases; counted as not-run` and seats the slot
unscreened.
**Fix.** Resolve the conflict with the base branch (or wait for GitHub to
recompute the merge ref), push, and run `gstack-ship-queue ready --pr <n>
--screen` again. Under crew rule 14 a CHANGELOG-only conflict is left to the
train, so a lane that cannot get a fresh merge ref READYs `--no-screen` only
after its own focused paid cases passed (rule 6).
<a id="ready-screen-choice-required"></a>
### `READY_SCREEN_CHOICE_REQUIRED`
+10 -6
View File
@@ -83,15 +83,17 @@ Not every workflow writes this line or a run directory today:
- **`/ship`** ends with `gstack-ship-receipt result --pr <n>` (or `--body
.gstack/tmp/ship-receipt.md` after the handoff section). It binds the
receipt to the branch head, writes `<state root>/ship/runs/<utc>-<head12>/`
(`run.json` with `head`, `pr`, `base`, `version`, `queue_mode`,
(`ship-run.json` with `head`, `pr`, `base`, `version`, `queue_mode`,
`session_kind`, `receipt` as the path of the copied `ship-receipt.md`, and
`receipt_source`) and prints `GSTACK_RESULT: skill=ship status=complete
run=<dir>`. A receipt that is missing or names another head prints
`receipt_source`; it is a /ship record, not the `run` v1 manifest below, so
read it directly: `gstack-artifact validate` does not apply) and prints
`GSTACK_RESULT: skill=ship status=complete run=<dir>`. When gh cannot read
the PR it prints `GSTACK_RESULT: skill=ship status=incomplete run=-` (exit 1). A receipt that is missing or names another head prints
`GSTACK_RESULT: skill=ship status=refused run=-` with `QUEUE_RECEIPT_MISSING`
or `QUEUE_RECEIPT_STALE` on stderr (exit 3); a run that stops before
publication prints the same refused line itself. A parent's merge check is
then mechanical: take `run=` from the last result line, read
`run.json`, and compare its `head` to the PR head. `gstack-ship-queue ready`,
`ship-run.json`, and compare its `head` to the PR head. `gstack-ship-queue ready`,
`train build` and `verify --train` make the same comparison from the PR body
([ship-policy.md](ship-policy.md#ship-before-ready)), which is why an
unattended /ship may update the body of a PR that already exists: when an
@@ -150,8 +152,10 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
## How a parent consumes a run
1. **Decide whether the run finished.** `gstack-artifact validate <dir>/run.json`
is the deterministic completion check. It exits `0` and prints
1. **Decide whether the run finished.** For an `/autoplan` run directory,
`gstack-artifact validate <dir>/run.json`
is the deterministic completion check (a `/ship` `run=` directory holds
`ship-run.json`, read directly as described above). It exits `0` and prints
`ARTIFACT_VALID: run=<id> status=<status>` only when the manifest parses and
matches its schema, every required phase and each reviewer voice has a
terminal outcome, every listed artifact exists inside the run directory with
+5
View File
@@ -143,6 +143,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
},
QUEUE_SCREEN_STALE_REF: {
anchor: 'queue-screen-stale-ref',
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
fix: 'resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, then gstack-ship-queue ready --pr <n> --screen again; a lane never READYs on a screen that planned nothing',
},
READY_SCREEN_CHOICE_REQUIRED: {
anchor: 'ready-screen-choice-required',
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
+15
View File
@@ -69,6 +69,21 @@ export function downloadJson(ctx: QueueContext, slug: string, run: number, name:
}
/** The named check run on a commit (the newest when several); null when absent or unreadable. */
/**
* A finished screen run that planned no cases is not a screen: evals.yml plans
* 0 cases and reports success when the merge ref it was dispatched for was
* superseded (GSTA-95 on #3431). Returns the reason when the run is hollow,
* null when it is live, still running, or its census cannot be read.
*/
export function hollowScreen(ctx: QueueContext, slug: string, run: number, artifact: string, scratch: string): string | null {
const view = runView(ctx, slug, run);
if (!view || view.status !== 'completed') return null;
const rec = downloadJson(ctx, slug, run, artifact, scratch) as { census?: { planned?: { rule?: unknown; judge?: unknown } } } | null;
const planned = rec?.census?.planned;
if (!planned || typeof planned.rule !== 'number' || typeof planned.judge !== 'number') return null;
return planned.rule + planned.judge === 0 ? `run ${run} planned 0 cases (concluded ${view.conclusion ?? 'unknown'})` : null;
}
export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null {
const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>(
ctx.gh(['api', `repos/{owner}/{repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(name)}`]));
+11 -4
View File
@@ -11,12 +11,14 @@
* replay (lib/gate-cost.ts) and printed as pending until it lands.
*/
import { randomBytes } from 'node:crypto';
import * as os from 'node:os';
import * as path from 'node:path';
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { EXIT } from './headless-artifacts';
import { ready, type QueueContext } from './ship-queue';
import { classifyTrainHead } from './version-gate-train';
import { git } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import { execFor, hollowScreen, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
@@ -85,10 +87,11 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string,
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
const exec = execFor(env.ctx);
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
if (existing) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
const hollow = existing ? hollowScreen(env.ctx, slug, existing.databaseId, env.train.record_artifact, path.join(os.tmpdir(), 'gstack-screen-scratch')) : null;
if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}` };
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${hollow ? ` (not following ${hollow}: a screen that planned nothing is not a screen)` : ''}` };
}
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
@@ -114,7 +117,11 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
if (o.screen === 'screen') {
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
const mergeSha = ls.ok ? ls.out.split(/\s/)[0] ?? '' : '';
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_NOT_READY', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, `merge ${ctx.base} into the branch, push, then ${Q} ready --pr ${o.pr} --screen`, 'none');
const again = `resolve the conflict with ${ctx.base} (or wait for GitHub to recompute refs/pull/${o.pr}/merge), push, then ${Q} ready --pr ${o.pr} --screen; a screen dispatched on a stale merge ref plans no cases`;
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, again, 'none');
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
const mergeParent = fetched.ok ? git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${mergeSha}^2`]).out : '';
if (mergeParent !== head) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote}'s refs/pull/${o.pr}/merge (${mergeSha.slice(0, 12)}) merges ${mergeParent ? mergeParent.slice(0, 12) : 'no PR head'}, not the PR head ${head.slice(0, 12)}: GitHub has not recomputed it, which is what a conflicting PR looks like (CHANGELOG-only conflicts included)` }, lines, again, 'none');
const d = await dispatchScreen(env, o, mergeSha, baseSha);
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
choice = d.choice;
+12 -3
View File
@@ -15,6 +15,8 @@
* scripts/eval-bg.ts's nonce.
*/
import { randomBytes } from 'node:crypto';
import * as os from 'node:os';
import * as path from 'node:path';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import type { ResultStatus } from './headless-artifacts';
@@ -23,7 +25,7 @@ import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
import { bindReceipt } from './ship-receipt';
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
import { asRun, execFor, hollowScreen, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
import {
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
@@ -285,6 +287,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
if (!f.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `git fetch of the PR heads failed: ${f.err.slice(0, 160)}` }, [], 'none', 'none');
const slots: TrainSlot[] = [];
const lines: string[] = [];
let slug: string | null | undefined;
for (const p of pins) {
const info = prInfo(ctx, p.pr);
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, [], 'none', 'none');
@@ -298,9 +302,13 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
let screen = ready.screen;
if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx);
const hollow = screen.choice === 'screen' && screen.run && slug ? hollowScreen(ctx, slug, screen.run, env.train.record_artifact, path.join(os.tmpdir(), 'gstack-screen-scratch')) : null;
if (hollow) { lines.push(`SCREEN: #${p.pr} ${hollow}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen });
}
return { slots, lines: [] };
return { slots, lines };
}
/** Start a new train from --prs, or find the live train with the same pins and base. */
@@ -318,6 +326,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
if ('exit' in admitted) return admitted;
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
const lines = [
...admitted.lines,
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
];
+32 -2
View File
@@ -24,7 +24,7 @@ import * as path from 'node:path';
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
import { bindReceipt } from './ship-receipt';
import { bindReceipt, extractReceipt } from './ship-receipt';
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
import {
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
@@ -60,6 +60,36 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
}
/**
* The heads a READY accepts a /ship receipt for: HEAD, plus the receipt's own
* head when HEAD is that commit with only stamp files changed on top (the
* slot's stamp commit, docs/ship-policy.md#ship-before-ready), or, from slot
* 2 on, the stamp over a merge of the base into it: /ship ran on the pin, the
* stamp changes nothing it checked, and verify --train binds the same pair
* against the train's own prefix. Any other receipt head stays
* QUEUE_RECEIPT_STALE.
*/
function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: string, body: string): string[] {
const ex = extractReceipt(body);
const r = ex.receipt;
if (!r || ex.errors.length || typeof r.head !== 'string' || !/^[0-9a-f]{12,64}$/i.test(r.head) || head.startsWith(r.head)) return [head];
const root = ctx.repoRoot;
const full = git(root, ['rev-parse', '--verify', '--quiet', `${r.head}^{commit}`]);
if (!full.ok || !git(root, ['merge-base', '--is-ancestor', full.out, head]).ok) return [head];
const stampOnly = (from: string, to: string) => { const t = from === to ? null : treeReceipt(ctx, policy, from, to); return !t || (!('message' in t) && t.tree === 'same-modulo-stamps'); };
if (stampOnly(full.out, head)) return [full.out, head];
const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out;
const onBase = (sha: string) => !!sha && git(root, ['merge-base', '--is-ancestor', sha, baseSha]).ok;
for (const merge of [head, git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out]) {
if (!merge || !stampOnly(merge, head)) continue;
const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1);
if (parents.length !== 2) continue;
const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : '';
if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head];
}
return [head];
}
const CI_PAGE = 100;
const CI_MAX_PAGES = 10;
@@ -142,7 +172,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
}
const receipt = bindReceipt(info.body ?? '', o.pr, [head]);
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const lines = [
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
+7 -6
View File
@@ -63,21 +63,22 @@ export type ReceiptBinding =
* receipt written for the pin stays the binding one). `line` is the
* informational `SHIP_RECEIPT:` line the READY block prints either way.
*/
export function bindReceipt(body: string, pr: string, accepted: readonly string[]): ReceiptBinding {
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
const ex = extractReceipt(body);
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `PR #${pr}'s body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
if (!ex.receipt || ex.errors.length) {
const why = ex.errors[0] ? `${ex.errors[0].message} at ${ex.errors[0].path}` : 'invalid';
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `PR #${pr}'s ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
}
const r = ex.receipt;
const line = receiptSummaryLine(r);
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
if (!head) {
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `PR #${pr}'s ship receipt is for ${r.head.slice(0, 12)}, not the head ${want} being merged`, line };
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${r.head.slice(0, 12)}, not the head ${want} being merged`, line };
}
if (r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `PR #${pr}'s ship receipt names pr=${r.pr}, not this PR`, line };
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
return { ok: true, receipt: r, head, line };
}
@@ -112,7 +113,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
return `SHIP_RECEIPT: ${parts.join(' ')}`;
return `SHIP_RECEIPT: ${parts.join(' ').replace(/[\x00-\x1f\x7f]+/g, ' ')}`;
}
// ---------------------------------------------------------------------------
+37
View File
@@ -295,6 +295,43 @@ describe('ready', () => {
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n');
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
}, 180_000);
// Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then;
// fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt;
// why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only)
test('ready at the stamp commit accepts the receipt written for the pinned head; a real edit on top of the pin is stale', () => {
const repo = fixture();
const pin = makePr(repo, 'pr-s');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
const stamped = repo.git('rev-parse', 'HEAD');
const shim = (prHead: string, body: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body } }, checks: { [prHead]: green } });
const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped, shippedBody('1', pin)));
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.4.0`);
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${pin.slice(0, 12)} version=- base=main`);
repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves');
fs.writeFileSync(path.join(repo.work, 'other.txt'), 'another PR landed\n');
repo.git('add', 'other.txt');
repo.git('commit', '-qm', 'other PR');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', 'pr-s');
repo.git('merge', '-q', '--no-edit', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
repo.git('commit', '-qam', 'v1.2.5.0 stamp');
const slot2 = repo.git('rev-parse', 'HEAD');
const merged = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, slot2)], shim(slot2, shippedBody('1', pin)));
expect([merged.status, merged.err]).toEqual([EXIT.ok, '']);
expect(merged.out).toContain(`SHIP_RECEIPT: pr=1 head=${pin.slice(0, 12)} version=- base=main`);
repo.git('reset', '-q', '--hard', stamped);
fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change after /ship\n');
repo.git('commit', '-qam', 'more');
const edited = repo.git('rev-parse', 'HEAD');
const stale = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, edited)], shim(edited, shippedBody('1', pin)));
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for ${pin.slice(0, 12)}, not the head ${edited.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
}, 180_000);
});
describe('receipt digest', () => {
+94 -3
View File
@@ -6,9 +6,10 @@
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
import { bindReceipt, computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
@@ -165,7 +166,8 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
const dir = m![1]!;
expect(dir.startsWith(path.join(state, 'ship', 'runs'))).toBe(true);
expect(dir.endsWith(`-${head.slice(0, 12)}`)).toBe(true);
const run = JSON.parse(fs.readFileSync(path.join(dir, 'run.json'), 'utf8'));
const run = JSON.parse(fs.readFileSync(path.join(dir, 'ship-run.json'), 'utf8'));
expect(fs.existsSync(path.join(dir, 'run.json'))).toBe(false);
expect(run).toMatchObject({ schema_version: 1, skill: 'ship', status: 'complete', head, pr: 5, base: 'main', queue_mode: 'stamp-at-merge', receipt: path.join(dir, 'ship-receipt.md'), receipt_source: 'PR #5' });
expect(extractReceipt(fs.readFileSync(run.receipt, 'utf8')).receipt).toMatchObject({ pr: 5, head });
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main`);
@@ -184,11 +186,100 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
const handoff = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(repo.root, 'run-out')], env);
expect(handoff.status).toBe(EXIT.ok);
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'run-out')}`);
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
expect(fs.readdirSync(path.join(state, 'ship', 'runs')).length).toBe(1);
});
// Value: protects=a --body handoff refusal names "the receipt", never "PR #-", and a gh failure is EXIT.fail, not a refusal;
// fails_when=the PR #-'s rewrite stops matching or gh errors are mapped to status=refused; why_new=the result test covers
// only the --pr refusals, an absent file and a bound handoff; seam=none; no_claim=real gh (the shim stands in; unit only)
test('a --body file with no block or a stale head is refused in the receipt\'s own words, and a gh failure is a plain failure, not a refusal', () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const env = { ...ghShim(repo, {}), GSTACK_STATE_ROOT: path.join(repo.root, 'state') };
const dir = path.join(repo.work, '.gstack/tmp');
fs.mkdirSync(dir, { recursive: true });
const noBlock = path.join(dir, 'no-block.md');
fs.writeFileSync(noBlock, 'Handoff prose without a receipt.\n');
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--body', noBlock], env);
expect(missing.status).toBe(EXIT.refused);
expect(missing.out.trim()).toBe('GSTACK_RESULT: skill=ship status=refused run=-');
expect(missing.err).toContain("the receipt's body carries no ship receipt");
expect(missing.err).not.toContain('PR #-');
const staleFile = path.join(dir, 'stale.md');
fs.writeFileSync(staleFile, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
const stale = runBin(repo, 'gstack-ship-receipt', ['result', '--body', staleFile], env);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toContain(`the receipt's ship receipt is for cccccccccccc, not the head ${head.slice(0, 12)} being merged`);
const invalidFile = path.join(dir, 'invalid.md');
fs.writeFileSync(invalidFile, '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
const invalid = runBin(repo, 'gstack-ship-receipt', ['result', '--body', invalidFile], env);
expect([invalid.status, invalid.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(invalid.err).toMatch(/the receipt's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING/);
const otherPrEnv = { ...env, ...ghShim(repo, { 8: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) };
const otherPr = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '8'], otherPrEnv);
expect([otherPr.status, otherPr.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(otherPr.err).toContain("PR #8's ship receipt names pr=99, not this PR");
const pinned = path.join(dir, 'pinned.md');
fs.writeFileSync(pinned, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
const override = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'c'.repeat(40), '--out', path.join(repo.root, 'pinned-out')], env);
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'pinned-out')}`]);
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
expect(badHead.err).toContain('--head must be a hex commit id');
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
fs.rmSync(noGit, { recursive: true, force: true });
const ghDown = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
expect(ghDown.status).toBe(EXIT.fail);
expect(ghDown.err).toContain('gh pr view failed');
expect(ghDown.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(JSON.parse(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9', '--json'], env).out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
expect(fs.existsSync(path.join(repo.root, 'state', 'ship', 'runs'))).toBe(false);
});
});
describe('bindReceipt head matching (rule 22)', () => {
// Value: protects=a receipt binds only to a full or 12+ character prefix of an accepted head, and names the PR it was written for;
// fails_when=the 12-character floor is dropped (head "a" would bind any head) or the pr check is removed;
// why_new=the queue, train and result tests bind full 40-character heads only; seam=none; no_claim=gh and CLI wiring (pure function)
test('binds a full head or a 12+ character prefix, refuses shorter prefixes, other heads and another PR\'s receipt', () => {
const head = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
const other = 'f'.repeat(40);
const body = (h: string, pr?: number) => placeReceiptFirst('Prose.\n', { schema_version: 1, head: h, base: 'main', ...(pr === undefined ? {} : { pr }) } as any);
const rows: Array<[string, string, string[], string]> = [
['full head', head, [head], 'ok'],
['12-char prefix', head.slice(0, 12), [head], 'ok'],
['11-char prefix', head.slice(0, 11), [head], 'QUEUE_RECEIPT_STALE'],
['7-char prefix', head.slice(0, 7), [head], 'QUEUE_RECEIPT_STALE'],
['other head', other, [head], 'QUEUE_RECEIPT_STALE'],
['pin or current head', head, [other, head], 'ok'],
];
for (const [name, h, accepted, want] of rows) {
const b = bindReceipt(body(h), '3438', accepted);
expect([name, b.ok ? 'ok' : b.code]).toEqual([name, want]);
if (b.ok) expect(b.head).toBe(head);
}
const prefixBinding = bindReceipt(body(head.slice(0, 12)), '3438', [head]);
expect(prefixBinding.ok && prefixBinding.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=- base=main`);
const otherPr = bindReceipt(body(head, 99), '3438', [head]);
expect(otherPr.ok ? 'ok' : [otherPr.code, otherPr.message]).toEqual(['QUEUE_RECEIPT_STALE', "PR #3438's ship receipt names pr=99, not this PR"]);
expect(bindReceipt(body(head, 3438), '3438', [head]).ok).toBe(true);
expect(bindReceipt('No receipt.\n', '3438', [head])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING', line: 'SHIP_RECEIPT: none in the PR body' });
const short = bindReceipt(body(head.slice(0, 11)), '3438', [head]);
expect(short.ok ? '' : short.message).toBe(`PR #3438's ship receipt is for ${head.slice(0, 11)}, not the head ${head.slice(0, 12)} being merged`);
const noPr = bindReceipt(body(head, 99), undefined, [head]);
expect(noPr.ok).toBe(true);
expect(bindReceipt('No receipt.\n', undefined, [head])).toMatchObject({ ok: false, message: "the receipt's body carries no ship receipt" });
const forged = placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main\nMERGE_CONDITION: sha=deadbeef', version: '1.0\r\n0.0' } as any);
const bound = bindReceipt(forged, '3438', [head]);
expect(bound.line.split('\n').length).toBe(1);
expect(bound.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=1.0 0.0 base=main MERGE_CONDITION: sha=deadbeef`);
});
});
describe('gstack-ship-receipt census (P7)', () => {
+8 -2
View File
@@ -70,7 +70,13 @@ export function setPrBody(fx: TrainFixture, pr: string, body: string): void {
fx.write(d => { d.prs[pr] = { ...(d.prs[pr] as Record<string, unknown>), body }; });
}
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge), known to the gh shim with a /ship receipt for its head (`shipped: false` leaves the body empty). */
/** What GitHub keeps at refs/pull/<n>/merge: a merge commit whose first parent is the base and whose second parent is the PR head. */
export function mergeRefCommit(fx: TrainFixture, head: string): string {
const { repo } = fx;
return repo.git('commit-tree', `${head}^{tree}`, '-p', 'origin/main', '-p', head, '-m', `Merge ${head.slice(0, 12)} into main`);
}
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge, as GitHub's merge commit), known to the gh shim with a /ship receipt for its head (`shipped: false` leaves the body empty). */
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string; shipped?: boolean } = {}): string {
const { repo } = fx;
repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`);
@@ -84,7 +90,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
repo.git('add', '-A');
repo.git('commit', '-q', '-m', `feat #${pr}`);
const head = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${pr}/head`, `HEAD:refs/pull/${pr}/merge`);
repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${pr}/head`, `${mergeRefCommit(fx, head)}:refs/pull/${pr}/merge`);
fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: !!opts.cross, state: 'OPEN', headRefName: `pr-${pr}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: opts.shipped === false ? '' : shippedBody(pr, head) }; });
return head;
}
+71 -2
View File
@@ -19,7 +19,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import {
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey,
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, mergeRefCommit, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey,
type Rule, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
@@ -177,9 +177,11 @@ describe('ready under a train policy: the screen choice', () => {
test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => {
const { fx, head, pregate } = readyFixture();
const mergeSha = fx.repo.git('ls-remote', 'origin', 'refs/pull/1/merge').split(/\s/)[0]!;
expect(mergeSha).not.toBe(head);
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
expect(dry.status).toBe(EXIT.ok);
expect(line(dry.out, 'SCREEN_DISPATCH:')).toMatch(new RegExp(`^SCREEN_DISPATCH: evals\\.yml --ref main evals_all=false expected_sha=${head} nonce=pr\\.screen\\.narrow-\\S+ base_ref=main base_sha=[0-9a-f]{40} screen_pr=1 screen_merge_sha=${head} screen_policy=narrow \\(dry run: not dispatched\\)$`));
expect(line(dry.out, 'SCREEN_DISPATCH:')).toMatch(new RegExp(`^SCREEN_DISPATCH: evals\\.yml --ref main evals_all=false expected_sha=${mergeSha} nonce=pr\\.screen\\.narrow-\\S+ base_ref=main base_sha=[0-9a-f]{40} screen_pr=1 screen_merge_sha=${mergeSha} screen_policy=narrow \\(dry run: not dispatched\\)$`));
expect(dispatches(fx)).toEqual([]);
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
@@ -192,6 +194,52 @@ describe('ready under a train policy: the screen choice', () => {
expect(dispatches(fx).map(d => (d.inputs as any).screen_policy)).toEqual(['narrow', 'broad']);
expect(queue(fx, ['ready', '--pr', '1', '--broad']).status).toBe(EXIT.usage);
}, 120_000);
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success);
// fails_when=the merge ref's second parent is not compared with the PR head, or a missing merge ref keeps the old QUEUE_NOT_READY code; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
test('--screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge lags the PR head or is missing, dispatching nothing; a recomputed merge ref admits it', () => {
const { fx, head, pregate } = readyFixture();
const { repo } = fx;
fs.appendFileSync(path.join(repo.work, 'pr-1.txt'), 'pushed after the merge ref was computed\n');
repo.git('commit', '-qam', 'more');
const moved = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/pull/1/head');
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
const pregate2 = path.join(repo.root, 'pregate2.json');
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toMatch(new RegExp(`refs/pull/1/merge \\([0-9a-f]{12}\\) merges ${head.slice(0, 12)}, not the PR head ${moved.slice(0, 12)}.*\\(QUEUE_SCREEN_STALE_REF\\)`));
expect(line(stale.out, 'NEXT:')).toBe('NEXT: resolve the conflict with main (or wait for GitHub to recompute refs/pull/1/merge), push, then gstack-ship-queue ready --pr 1 --screen; a screen dispatched on a stale merge ref plans no cases');
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(missing.status).toBe(EXIT.refused);
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
expect(dispatches(fx)).toEqual([]);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
const fresh = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([fresh.status, fresh.err]).toEqual([EXIT.ok, '']);
expect(line(fresh.out, 'SCREEN_DISPATCH:')).toContain('(dry run: not dispatched)');
}, 120_000);
// Value: protects=a finished screen that planned 0 cases never counts as a screen: ready dispatches a fresh one instead of adopting it and train build seats the slot unscreened with a SCREEN: line;
// fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', record: { verdict: 'GREEN', census: { complete: true, planned: { rule: 0, judge: 0 }, executed: { rule: 0, judge: 0 } }, panels: [] } }; });
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(first.status).toBe(EXIT.ok);
expect(line(first.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 100 /);
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(again.status).toBe(EXIT.ok);
expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .* \(not following run 100 planned 0 cases \(concluded success\): a screen that planned nothing is not a screen\)$/);
expect(dispatches(fx, 'evals.yml').length).toBe(2);
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect(build.status).toBe(EXIT.ok);
expect(line(build.out, 'SCREEN:')).toBe('SCREEN: #1 run 101 planned 0 cases (concluded success); counted as not-run, the slot rides unscreened');
expect(line(build.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened)');
}, 120_000);
});
describe('train build', () => {
@@ -353,6 +401,10 @@ describe('train build', () => {
const stale = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toMatch(new RegExp(`PR #2's ship receipt is for aaaaaaaaaaaa, not the head ${h2.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
setPrBody(fx, '2', '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
expect(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']).err).toMatch(/PR #2's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/);
setPrBody(fx, '2', shippedBody('1', h2));
expect(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']).err).toMatch(/PR #2's ship receipt names pr=1, not this PR.*\(QUEUE_RECEIPT_STALE\)/);
expect(remoteRefs(fx)).toBe(refsBefore);
expect(dispatches(fx)).toEqual([]);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains'))).toBe(false);
@@ -675,8 +727,25 @@ describe('verify, freeze, repair and clear', () => {
expect(trainJson(fx, nonce).merged).toEqual([]);
setPrBody(fx, '1', '');
expect(v().err).toContain('(QUEUE_RECEIPT_MISSING)');
setPrBody(fx, '1', '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
const invalid = v();
expect(invalid.err).toMatch(/PR #1's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/);
expect(invalid.out).not.toContain('MERGE_CONDITION:');
setPrBody(fx, '1', shippedBody('2', h1));
const otherPr = v();
expect(otherPr.err).toMatch(/PR #1's ship receipt names pr=2, not this PR.*\(QUEUE_RECEIPT_STALE\)/);
expect(otherPr.out).not.toContain('MERGE_CONDITION:');
expect(trainJson(fx, nonce).merged).toEqual([]);
setPrBody(fx, '1', shippedBody('1', stamped));
expect(v().out).toContain(`SHIP_RECEIPT: pr=1 head=${stamped.slice(0, 12)} version=- base=main (binds the current head)`);
const saved = fx.read().prs['1'];
fx.write(d => { delete d.prs['1']; });
const unreadable = v();
expect(unreadable.status).toBe(EXIT.refused);
expect(unreadable.err).toContain('(QUEUE_PROOF_MISSING)');
expect(unreadable.out).not.toContain('MERGE_CONDITION:');
fx.write(d => { d.prs['1'] = saved; });
expect(trainJson(fx, nonce).merged).toEqual([]);
}, 300_000);
test('from slot 2 on, a head that merges main into the pin verifies when its tree is the slot\'s prefix modulo stamps', () => {