Files
gstack/bin/gstack-tree-receipt
T
Garry Tanandcapy-ai[bot] 7552362196 v1.91.73.0 feat(ship): repo ship policy, gstack-restamp stamp-at-merge, tree receipt, evidence bundle, ship receipt (multi-agent wave C-stamp) (#3118)
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.73.0 feat(ship): repo ship policy, gstack-restamp stamp-at-merge, tree receipt, evidence bundle, ship receipt

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ci): result-codes is selection data, receipt counts come from files, C-stamp tests leave the Windows subset

- scripts/pr-dependencies.ts: lib/result-codes.ts joins SELECTION_DATA_MODULES; its fix: prose names commands a person reruns, not files the module consumes (test/pr-dependencies.test.ts traced review cases through it to bin/gstack-next-version)
- ship/sections/pr-body.md.tmpl: gstack-ship-receipt write reads --gate/--ci from agent-written .gstack/tmp/ship-*.json files and --spend-usd from the allowlisted <spend-usd> placeholder (generated-bash lint: no free text in commands)
- scripts/lib/windows-curation.ts: the five C-stamp test files spawn bin/ shebang scripts through test/helpers/restamp-fixture.ts, which the source scan misses

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): the receipt step names where each input comes from

The workflow judge (eval-slices 3, median 3/4) found pr-body's gstack-ship-receipt command demanding a tree receipt, CI counts and a spend figure no earlier step produces, with RECEIPT_INVALID halting publication. The command now takes only Step 16 stage 4's lane counts (file-written JSON); --receipt is added only when Step 16 wrote .gstack/tmp/tree-receipt.json; RECEIPT_INVALID says to fix the named input file and rerun. The unused <spend-usd> allowlist entry is removed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-11 01:01:42 +00:00

87 lines
5.2 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-tree-receipt — the tree-equality receipt (plan C3).
*
* gstack-tree-receipt --gated <sha> [--current <sha>] [--predecessor <sha>] [--predecessor-pr <n>] [--predecessor-ref <ref>]
* gstack-tree-receipt --from .gstack/tmp/gate-ahead.json [--current <sha>]
* [--base <branch>] [--policy-from base|head] [--bundle <evidence-bundle.json>] [--rerun-command <cmd>] [--json]
*
* Prints gated head, current head, both tree ids, the diff limited to stamp
* files (classified hunk by hunk), the predecessor head the gate assumed,
* each gate's reusability, `TREE: same-modulo-stamps | changed (<paths>)` and
* `gate-reuse: eligible | not-eligible (<what>) — fix: ... (GATE_REUSE_NOT_ELIGIBLE)`.
* Exit 0 when eligible, 1 otherwise, 2 usage, 3 when the policy is unreadable.
* Logic: lib/tree-receipt.ts, lib/evidence-bundle.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { GATE_AHEAD_REL } from '../lib/restamp';
import { resultLine } from '../lib/result-codes';
import { isUnavailable, loadPolicyFromBase, loadPolicyFromHead, policyUnavailableLine, resolveBaseBranch, type LoadedPolicy } from '../lib/ship-policy';
import { buildReceipt, fromGateAhead, renderReceipt } from '../lib/tree-receipt';
const HELP = `gstack-tree-receipt — tree equality modulo stamps, per-gate reuse (docs/ship-policy.md#tree-receipt)
--gated <sha> the head the gate ran on (committed; fetch it first)
--from <gate-ahead.json> read gated head, predecessor and PR from gstack-restamp --after's record (default ${GATE_AHEAD_REL} when present)
--current <sha> the head to compare (default HEAD)
--predecessor <sha> the predecessor head the gate assumed
--predecessor-pr <n> check the predecessor PR's current head through gh (PREDECESSOR MOVED)
--predecessor-ref <ref> check the predecessor's current head from a git ref (fetched)
--bundle <file> gstack-evidence bundle produced on the gated head (the execution identity)
--base <branch> base branch for the policy (default origin/HEAD, else main)
--policy-from base|head reviewed base copy (default) or the unreviewed working tree
--rerun-command <cmd> what the fix clause names for a rerun
--json the receipt as one JSON object
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const json = argv.includes('--json');
const rootProbe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
if (rootProbe.status !== 0) { process.stderr.write('gstack-tree-receipt: not inside a git repository\n'); return EXIT.usage; }
const repoRoot = rootProbe.stdout.trim();
let gated = flag(argv, '--gated');
let predecessor: { pr: string | null; recordedHead: string | null; ref?: string | null } | undefined;
const from = flag(argv, '--from') ?? (!gated && fs.existsSync(path.join(repoRoot, GATE_AHEAD_REL)) ? path.join(repoRoot, GATE_AHEAD_REL) : undefined);
if (from) {
const rec = fromGateAhead(from);
gated = gated ?? rec.gated_head;
predecessor = { pr: rec.predecessor_pr, recordedHead: rec.predecessor_head };
}
if (!gated) { process.stderr.write(`gstack-tree-receipt: --gated <sha> or --from <gate-ahead.json> is required\n${HELP}\n`); return EXIT.usage; }
const predSha = flag(argv, '--predecessor');
const predPr = flag(argv, '--predecessor-pr');
const predRef = flag(argv, '--predecessor-ref');
if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null };
const policyFrom = flag(argv, '--policy-from') ?? 'base';
let loaded: LoadedPolicy;
if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot);
else {
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const l = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; }
loaded = l;
}
if (loaded.errors.length) { for (const e of loaded.errors) process.stderr.write(resultLine('gstack-tree-receipt', e.code, `${e.message} at ${e.path}`) + '\n'); return EXIT.refused; }
const receipt = buildReceipt({
repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label,
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'),
});
if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; }
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt));
return receipt.gate_reuse === 'eligible' ? EXIT.ok : EXIT.fail;
}
process.exitCode = main(process.argv.slice(2));