mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
* 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>
183 lines
11 KiB
TypeScript
183 lines
11 KiB
TypeScript
/**
|
|
* C3: the tree-equality receipt classifies hunk by hunk, keeps stamp-sensitive
|
|
* gates on the rerun side, names the negative fixtures (a dependency pin equal
|
|
* to the old version; a lockfile dependency entry), refuses `eligible` when the
|
|
* predecessor moved, and emits the same receipt as JSON.
|
|
*/
|
|
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 { cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
|
|
|
const repos: FixtureRepo[] = [];
|
|
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo({ prefix: 'tree-receipt-', ...opts }); repos.push(r); return r; };
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
|
|
const LANES = [{ label: 'tests', command: 'true' }, { label: 'digest', command: 'cat generated/digest.md' }];
|
|
|
|
/** A gated head: pr-a stamped at 1.2.4.0 with both lanes recorded and bundled. */
|
|
function gated(repo: FixtureRepo, env: Record<string, string> = {}): { head: string; bundle: string } {
|
|
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
|
|
fs.writeFileSync(path.join(repo.work, 'docs', 'deps.md'), 'left-pad: 1.2.4.0\n');
|
|
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'deps doc');
|
|
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
|
|
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
|
const bundle = path.join(repo.root, 'bundle.json');
|
|
recordAndBundle(repo, repo.work, LANES, bundle, env);
|
|
return { head: repo.git('rev-parse', 'HEAD'), bundle };
|
|
}
|
|
|
|
const receipt = (repo: FixtureRepo, args: string[], env: Record<string, string> = {}) => runBin(repo, 'gstack-tree-receipt', ['--base', 'main', ...args], env);
|
|
|
|
describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () => {
|
|
test('a restamp is same-modulo-stamps: tests reusable, the digest lane (stamp-sensitive) reruns, the diff is limited to stamp files', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
|
repo.git('commit', '-qam', 'stamp 1.2.5.0');
|
|
expect(readFile(repo, 'docs/pr-a.md')).toBe('since: 1.2.5.0\nRequires v1.2.5.0 or later.\n');
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle]);
|
|
expect(r.status).toBe(EXIT.ok);
|
|
expect(r.out).toMatch(/^version:\s+1\.2\.4\.0 -> 1\.2\.5\.0$/m);
|
|
expect(r.out).toMatch(/^ stamp VERSION \(version-file\)$/m);
|
|
expect(r.out).toMatch(/^ stamp package-lock\.json \(lockfile-root-version\)$/m);
|
|
expect(r.out).toMatch(/^ stamp package\.json \(manifest-version\)$/m);
|
|
expect(r.out).toMatch(/^ stamp CHANGELOG\.md \(changelog-heading\)$/m);
|
|
expect(r.out).toMatch(/^ stamp docs\/pr-a\.md \(stamp_paths\)$/m);
|
|
expect(r.out).toMatch(/^ stamp generated\/digest\.md \(release_outputs\)$/m);
|
|
expect(r.out).toMatch(/^ gate tests: reusable \(identity matches; tree same modulo stamps\)$/m);
|
|
expect(r.out).toMatch(/^ gate digest: rerun \(stamp-sensitive/m);
|
|
expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m);
|
|
expect(r.out).toMatch(/^gate-reuse: eligible \(reusable: tests; rerun: digest\)$/m);
|
|
expect(r.out).toContain('-since: 1.2.4.0');
|
|
expect(r.out).toContain('+since: 1.2.5.0');
|
|
expect(r.out).not.toContain('pr-a.txt');
|
|
});
|
|
|
|
test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
|
expect(readFile(repo, 'docs/deps.md')).toBe('left-pad: 1.2.4.0\n');
|
|
fs.writeFileSync(path.join(repo.work, 'docs', 'deps.md'), 'left-pad: 1.2.5.0\n');
|
|
repo.git('commit', '-qam', 'bump the pin by hand');
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle]);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toMatch(/^ changed docs\/deps\.md \(stamp_paths: "left-pad: 1\.2\.4\.0" -> "left-pad: 1\.2\.5\.0"\)$/m);
|
|
expect(r.out).toMatch(/^TREE: changed \(docs\/deps\.md\)$/m);
|
|
expect(r.out).toMatch(/^gate-reuse: not-eligible \(tree changed: docs\/deps\.md\) — fix: gstack-evidence run --label <lane> -- <lane command> \(GATE_REUSE_NOT_ELIGIBLE\)$/m);
|
|
});
|
|
|
|
test('lockfile rule: only the root package version keys are stamps; a dependency entry is a change', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
const lock = JSON.parse(readFile(repo, 'package-lock.json'));
|
|
lock.packages['node_modules/left-pad'].version = '1.2.4';
|
|
fs.writeFileSync(path.join(repo.work, 'package-lock.json'), JSON.stringify(lock, null, 2) + '\n');
|
|
repo.git('commit', '-qam', 'dependency moved');
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle]);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toMatch(/^ changed package-lock\.json \(lockfile: a dependency entry changed \(only the root version keys are stamps\)\)$/m);
|
|
expect(r.out).toMatch(/^TREE: changed \(package-lock\.json\)$/m);
|
|
});
|
|
|
|
test('a changed generated prompt or CHANGELOG entry is a change even when the version moved', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
|
fs.appendFileSync(path.join(repo.work, 'generated', 'digest.md'), 'an extra generated line\n');
|
|
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), readFile(repo, 'CHANGELOG.md').replace('- pr-a change', '- pr-a change, reworded'));
|
|
repo.git('commit', '-qam', 'stamp plus edits');
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle, '--json']);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
const j = JSON.parse(r.out);
|
|
expect(j.files.find((f: any) => f.path === 'generated/digest.md')).toMatchObject({ verdict: 'changed', rule: 'release_outputs' });
|
|
expect(j.files.find((f: any) => f.path === 'CHANGELOG.md')).toMatchObject({ verdict: 'changed', rule: 'changelog' });
|
|
expect(j.residual.sort()).toEqual(['CHANGELOG.md', 'generated/digest.md']);
|
|
expect(j.tree).toBe('changed');
|
|
expect(j.gate_reuse).toBe('not-eligible');
|
|
expect(j.code).toBe('GATE_REUSE_NOT_ELIGIBLE');
|
|
});
|
|
});
|
|
|
|
describe('tree receipt: identity and predecessor', () => {
|
|
test('without a bundle the identity is unknown and reuse is refused even on an equal tree', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
const r = receipt(repo, ['--gated', g.head]);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m);
|
|
expect(r.out).toMatch(/^gate-reuse: not-eligible \(none \(no evidence bundle; execution identity unknown\)\)/m);
|
|
});
|
|
|
|
test('an environment change under an identical tree (runner image) is not-eligible and names what changed', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo, { GSTACK_RUNNER_IMAGE: 'ubuntu-24.04/20261001' });
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle], { GSTACK_RUNNER_IMAGE: 'ubuntu-24.04/20261008' });
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toMatch(/^identity:\s+bundle .* — changed: runner image ubuntu-24\.04\/20261001 -> ubuntu-24\.04\/20261008$/m);
|
|
expect(r.out).toMatch(/^ gate tests: not-reusable \(identity changed: runner image/m);
|
|
expect(r.out).toMatch(/^gate-reuse: not-eligible \(runner image ubuntu-24\.04\/20261001 -> ubuntu-24\.04\/20261008\)/m);
|
|
const same = receipt(repo, ['--gated', g.head, '--bundle', g.bundle], { GSTACK_RUNNER_IMAGE: 'ubuntu-24.04/20261001' });
|
|
expect(same.status).toBe(EXIT.ok);
|
|
});
|
|
|
|
test('a bundle produced on another tree is not the gated identity', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
|
repo.git('commit', '-qam', 'stamp 1.2.5.0');
|
|
const other = path.join(repo.root, 'other.json');
|
|
recordAndBundle(repo, repo.work, LANES, other);
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', other]);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toMatch(/^gate-reuse: not-eligible \(bundle .* was produced on tree [0-9a-f]{12}, not the gated tree [0-9a-f]{12}\)/m);
|
|
});
|
|
|
|
test('PREDECESSOR MOVED: a predecessor ref or PR whose head is no longer the recorded one refuses with RESTAMP_PREDECESSOR_MOVED', () => {
|
|
const repo = fixture();
|
|
const recorded = repo.git('rev-parse', 'origin/main');
|
|
const g = gated(repo);
|
|
repo.git('checkout', '-q', 'main');
|
|
fs.writeFileSync(path.join(repo.work, 'moved.txt'), 'moved\n');
|
|
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'main moved'); repo.git('push', '-q', 'origin', 'main');
|
|
const moved = repo.git('rev-parse', 'main');
|
|
repo.git('checkout', '-q', 'pr-a');
|
|
const byRef = receipt(repo, ['--gated', g.head, '--bundle', g.bundle, '--predecessor', recorded, '--predecessor-ref', 'origin/main']);
|
|
expect(byRef.status).toBe(EXIT.fail);
|
|
expect(byRef.out).toMatch(/^TREE: same-modulo-stamps$/m);
|
|
expect(byRef.out).toContain(`PREDECESSOR MOVED ${recorded} -> ${moved} — fix: gstack-restamp --after <pr> again (RESTAMP_PREDECESSOR_MOVED)`);
|
|
expect(byRef.out).toMatch(/^gate-reuse: not-eligible \(PREDECESSOR MOVED/m);
|
|
const byPr = receipt(repo, ['--gated', g.head, '--bundle', g.bundle, '--predecessor', recorded, '--predecessor-pr', '7', '--json'], ghShim(repo, { 7: { headRefOid: moved } }));
|
|
const j = JSON.parse(byPr.out);
|
|
expect(j.predecessor).toEqual({ pr: '7', recorded_head: recorded, current_head: moved, moved: true });
|
|
expect(j.code).toBe('RESTAMP_PREDECESSOR_MOVED');
|
|
const still = receipt(repo, ['--gated', g.head, '--bundle', g.bundle, '--predecessor', recorded, '--predecessor-pr', '7'], ghShim(repo, { 7: { headRefOid: recorded } }));
|
|
expect(still.status).toBe(EXIT.ok);
|
|
expect(still.out).toMatch(/^predecessor:\s+#7 recorded=[0-9a-f]{12} current=[0-9a-f]{12}$/m);
|
|
});
|
|
|
|
test('--json carries every identifier the human rendering prints', () => {
|
|
const repo = fixture();
|
|
const g = gated(repo);
|
|
const r = receipt(repo, ['--gated', g.head, '--bundle', g.bundle, '--json']);
|
|
expect(r.status).toBe(EXIT.ok);
|
|
const j = JSON.parse(r.out);
|
|
expect(j).toMatchObject({ schema_version: 1, gated_head: g.head, current_head: g.head, tree: 'same-modulo-stamps', gate_reuse: 'eligible', old_version: '1.2.4.0', new_version: '1.2.4.0', residual: [], files: [] });
|
|
expect(j.gated_tree).toBe(repo.git('rev-parse', `${g.head}^{tree}`));
|
|
expect(j.identity.gated.lockfiles['package-lock.json']).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(j.identity.diffs).toEqual([]);
|
|
expect(j.gates.map((x: any) => [x.label, x.verdict])).toEqual([['tests', 'reusable'], ['digest', 'rerun']]);
|
|
expect(j.gates[0].cmd_sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(j.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/);
|
|
});
|
|
|
|
test('a gated head that is not a commit here is EVIDENCE_IDENTITY_UNKNOWN with the fetch hint', () => {
|
|
const repo = fixture();
|
|
const r = receipt(repo, ['--gated', '0123456789abcdef0123456789abcdef01234567']);
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.err).toMatch(/not a committed head here \(fetch it, or copy the gate-ahead worktree's object store\).*\(EVIDENCE_IDENTITY_UNKNOWN\)/);
|
|
});
|
|
});
|