Files
gstack/test/evidence-bundle-portability.test.ts
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

173 lines
11 KiB
TypeScript

/**
* C3: `gstack-evidence bundle` is the portable form of the machine-local
* evidence ledger. A fresh coordinator clone with an empty state root reads
* the bundle without the gate VM; imported assertions stay distinct from
* verified lanes; a bundle without an execution identity refuses reuse.
* C7: `gstack-evidence ancestor` checks preregistration shas against HEAD.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { gitArgvIn } from './helpers/scratch-repo';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo({ prefix: 'bundle-', ...opts }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
function gatedAndPushed(repo: FixtureRepo): { head: string; bundle: string } {
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
repo.git('push', '-q', '-u', 'origin', 'pr-a');
const bundle = path.join(repo.root, 'bundle.json');
recordAndBundle(repo, repo.work, [{ label: 'tests', command: 'true' }], bundle);
return { head: repo.git('rev-parse', 'HEAD'), bundle };
}
describe('evidence bundle portability', () => {
test('a fresh clone with an empty state root reads the bundle and reaches the same eligible verdict', () => {
const repo = fixture();
const g = gatedAndPushed(repo);
const bundle = JSON.parse(fs.readFileSync(g.bundle, 'utf8'));
expect(bundle).toMatchObject({ schema_version: 1, kind: 'gstack-evidence-bundle', repo: { commit: g.head, dirty: false }, required_lanes: ['tests'] });
expect(bundle.lanes[0]).toMatchObject({ label: 'tests', verified: true, exit: 0 });
expect(bundle.lanes[0].cmd_sha256).toMatch(/^[0-9a-f]{64}$/);
expect(bundle.comparison).toMatchObject({ version_rel: 'VERSION', version: '1.2.4.0', changelog: 'CHANGELOG.md', stamp_paths: ['docs/**/*.md'], release_outputs: ['generated/**'] });
expect(bundle.identity.runtime.bun).toBeTruthy();
const fresh = path.join(repo.root, 'coordinator');
const home = path.join(repo.root, 'coordinator-home');
fs.mkdirSync(home, { recursive: true });
const clone = gitArgvIn(repo.root, ['clone', '-q', path.join(repo.root, 'origin.git'), fresh], 60_000, { ...process.env, ...repo.env });
expect(clone.status).toBe(0);
gitArgvIn(fresh, ['checkout', '-q', 'pr-a'], 30_000, { ...process.env, ...repo.env });
const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home };
const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', g.head, '--bundle', g.bundle], env, fresh);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m);
expect(r.out).toMatch(/^gate-reuse: eligible \(reusable: tests; rerun: -\)$/m);
expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false);
expect(fs.existsSync(path.join(home, 'evidence'))).toBe(false);
});
test('imported assertions are listed apart from verified lanes and never make the gate reusable', () => {
const repo = fixture();
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
expect(runBin(repo, 'gstack-evidence', ['run', '--label', 'tests', '--', 'true']).status).toBe(0);
const imported = path.join(repo.root, 'ci.json');
fs.writeFileSync(imported, JSON.stringify([{ label: 'e2e', command: 'bun run test:e2e', exit: 0, source: 'github-actions run 42', run_url: 'https://ci.example/runs/42' }]));
const out = path.join(repo.root, 'bundle.json');
const b = runBin(repo, 'gstack-evidence', ['bundle', '--out', out, '--base', 'main', '--label', 'tests', '--require', 'tests,e2e', '--selection', 'tests=unit shard 1/1', '--import', imported]);
expect(b.status).toBe(0);
expect(b.out).toMatch(/^EVIDENCE_BUNDLE: .*bundle\.json tree=[0-9a-f]{40} lanes=1 verified=1 imported=1$/m);
const bundle = JSON.parse(fs.readFileSync(out, 'utf8'));
expect(bundle.lanes[0]).toMatchObject({ label: 'tests', verified: true, selection: 'unit shard 1/1' });
expect(bundle.imported[0]).toMatchObject({ label: 'e2e', verified: false, run_url: 'https://ci.example/runs/42' });
expect(bundle.imported[0].reason).toBe('imported assertion from github-actions run 42; not independently verified');
const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', 'HEAD', '--bundle', out]);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toMatch(/^ gate tests: reusable/m);
expect(r.out).toMatch(/^ gate e2e: not-reusable \(required lane has no verified record in the bundle\)$/m);
expect(r.out).toMatch(/^ gate e2e: not-reusable \(imported assertion from github-actions run 42; not independently verified\)$/m);
expect(r.out).toMatch(/^gate-reuse: not-eligible \(e2e: required lane has no verified record in the bundle; e2e: imported assertion/m);
});
test('a lane recorded on a dirty tree or another commit is in the bundle but not verified', () => {
const repo = fixture();
makePr(repo, 'pr-a');
expect(runBin(repo, 'gstack-evidence', ['run', '--label', 'old', '--', 'true']).status).toBe(0);
fs.writeFileSync(path.join(repo.work, 'more.txt'), 'more\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'more');
fs.writeFileSync(path.join(repo.work, 'more.txt'), 'dirty\n');
expect(runBin(repo, 'gstack-evidence', ['run', '--label', 'dirty', '--', 'true']).status).toBe(0);
repo.git('checkout', '--', 'more.txt');
const out = path.join(repo.root, 'bundle.json');
const b = runBin(repo, 'gstack-evidence', ['bundle', '--out', out, '--base', 'main', '--label', 'old', '--label', 'dirty', '--label', 'absent']);
expect(b.status).toBe(1);
expect(b.err).toMatch(/no ledger record for label absent.*\(EVIDENCE_IDENTITY_UNKNOWN\)/);
expect(b.out).toMatch(/^EVIDENCE_BUNDLE: .* lanes=2 verified=0 imported=0$/m);
const bundle = JSON.parse(fs.readFileSync(out, 'utf8'));
expect(bundle.lanes.map((l: any) => [l.label, l.verified])).toEqual([['old', false], ['dirty', false]]);
expect(bundle.lanes[0].reason).toMatch(/^ran on [0-9a-f]{12}, HEAD is [0-9a-f]{12}$/);
expect(bundle.lanes[1].reason).toBe('ran on a dirty or unfingerprinted tree (committed heads only)');
const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', 'HEAD', '--bundle', out]);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toMatch(/^gate-reuse: not-eligible \(old: ran on .*; dirty: ran on a dirty/m);
});
test('a bundle without an execution identity, or that is not a bundle, refuses reuse (EVIDENCE_BUNDLE_INVALID)', () => {
const repo = fixture();
const g = gatedAndPushed(repo);
const stripped = path.join(repo.root, 'no-identity.json');
const bundle = JSON.parse(fs.readFileSync(g.bundle, 'utf8'));
delete bundle.identity;
fs.writeFileSync(stripped, JSON.stringify(bundle));
const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', g.head, '--bundle', stripped]);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toMatch(/^gate-reuse: not-eligible \(invalid bundle: .*no-identity\.json is not a gstack-evidence-bundle v1\)/m);
const garbage = path.join(repo.root, 'garbage.json');
fs.writeFileSync(garbage, '{not json');
const g2 = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', g.head, '--bundle', garbage]);
expect(g2.status).toBe(EXIT.fail);
expect(g2.out).toMatch(/^gate-reuse: not-eligible \(invalid bundle: .*garbage\.json: /m);
});
test('bundle needs a committed head and --out', () => {
const repo = fixture();
expect(runBin(repo, 'gstack-evidence', ['bundle']).status).toBe(2);
const nowhere = path.join(repo.root, 'not-a-repo');
fs.mkdirSync(nowhere);
const r = runBin(repo, 'gstack-evidence', ['bundle', '--out', path.join(nowhere, 'b.json')], {}, nowhere);
expect(r.status).not.toBe(0);
});
});
describe('gstack-evidence ancestor (C7)', () => {
test('each preregistration sha is checked against HEAD; a missing or unrelated one fails with PREREGISTRATION_NOT_ANCESTOR', () => {
const repo = fixture();
const base = repo.git('rev-parse', 'HEAD');
makePr(repo, 'pr-a');
const head = repo.git('rev-parse', 'HEAD');
repo.git('checkout', '-q', '-b', 'stray', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'stray.txt'), 'stray\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stray');
const stray = repo.git('rev-parse', 'HEAD');
repo.git('checkout', '-q', 'pr-a');
const ok = runBin(repo, 'gstack-evidence', ['ancestor', base, head.slice(0, 12)]);
expect(ok.status).toBe(0);
expect(ok.out).toMatch(new RegExp(`^ANCESTOR: ok ${base} \\(ancestor of HEAD\\)$`, 'm'));
expect(ok.out).toMatch(new RegExp(`^ANCESTOR: ok ${head.slice(0, 12)} \\(ancestor of HEAD\\)$`, 'm'));
const bad = runBin(repo, 'gstack-evidence', ['ancestor', stray, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'nothex']);
expect(bad.status).toBe(1);
expect(bad.out).toMatch(new RegExp(`^ANCESTOR: missing ${stray} \\(not an ancestor of HEAD\\) — fix: .* \\(PREREGISTRATION_NOT_ANCESTOR\\)$`, 'm'));
expect(bad.out).toMatch(/^ANCESTOR: missing deadbeef.* \(unknown commit \(fetch it first\)\)/m);
expect(bad.out).toMatch(/^ANCESTOR: missing nothex \(not a hex commit id\)/m);
});
test('--from-policy reads preregistration_shas from the base branch copy; none means no check', () => {
const none = fixture();
makePr(none, 'pr-a');
const n = runBin(none, 'gstack-evidence', ['ancestor', '--from-policy', '--base', 'main']);
expect(n.status).toBe(0);
expect(n.out).toBe('ANCESTOR: none (no preregistration_shas)\n');
const seeded = fixture();
const base = seeded.git('rev-parse', 'HEAD');
fs.writeFileSync(path.join(seeded.work, '.gstack', 'ship-policy.json'), JSON.stringify({ ...BASE_POLICY, preregistration_shas: [base] }, null, 2) + '\n');
seeded.git('add', '-A'); seeded.git('commit', '-q', '-m', 'preregister'); seeded.git('push', '-q', 'origin', 'main');
makePr(seeded, 'pr-a');
const r = runBin(seeded, 'gstack-evidence', ['ancestor', '--from-policy', '--base', 'main']);
expect(r.status).toBe(0);
expect(r.out).toMatch(new RegExp(`^ANCESTOR: ok ${base} \\(ancestor of HEAD\\)$`, 'm'));
seeded.git('checkout', '-q', '--orphan', 'rewritten');
seeded.git('commit', '-q', '-m', 'orphan history');
const o = runBin(seeded, 'gstack-evidence', ['ancestor', '--from-policy', '--base', 'main']);
expect(o.status).toBe(1);
expect(o.out).toMatch(/PREREGISTRATION_NOT_ANCESTOR/);
});
});