Files
gstack/test/ship-queue-train-verify.test.ts
T
Garry Tanandcapy-ai[bot] e25997515c v1.92.54.0 ship-queue: train land takes the shared land lock; verify proves the stamp; restamp stages its writes (#3599, #3531) (#3737)
* feat(ship): pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1)

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

* fix: base-red shell runner launches bash through runBashSync

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

* test: base-red coverage additions (unreproduced head, unresolved head, readPause, wait unconfigured)

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

* docs: base-red pause in unattended endings; BASE_RED_UNCONFIGURED covers an invalid base policy

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

* test: base-red coverage pass 2 (write --base-red, summary line, classify usage before fetch, unresolved-head detail)

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

* feat(ship-queue): merge trains for any repo — train.gates (workflows/command), every prefix gated, generalized train land (direct/update, merge method), base-red trains, train init/doctor, watch --land (GSTA-157 PR 2)

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

* fix: pre-landing review fixes (train gates: dispatch bound, lease, rebase, post-merge checks, output tail, caps)

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

* fix: train land refuses the next slot until the previous slot's post-merge checks are green

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

* docs: ship-policy, troubleshooting and captain for train gates review fixes

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

* chore: changelog fragment for merge trains for any repo

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

* test: keep ship-queue-train-generic out of the Windows subset (gh shim resolves gh.exe there)

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

* fix(ship-queue): train land takes the shared land lock (train-scoped, freeze-owned), verify --train proves the stamp and needs GitHub MERGED, restamp stages its writes (#3599, #3531)

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

* fix: pre-landing review fixes (train keeps the land lock through a mid-train refusal; freeze takeover reads the notes ref; a refused lock push is not reported as a taken lock)

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

* fix: a freeze lock is taken over only on a freeze record cleared after it (pre-landing review pass 2)

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

* fix: a stale restamp staging marker is dropped, not a failure; a failed dry run refuses before the real run; settleLock names the real lock-write failure (Step 11 review)

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

* fix(ship-queue): land takes over a cleared freeze lock; deleted lock ref reads absent; unread verify head refuses QUEUE_PROOF_MISSING

Step 11 adversarial findings on PR #3737: a freeze lock whose release push failed
blocked every standalone land after its freeze cleared (freezeLockCleared shared by
land and train land takeover); readLock kept a cached ref deleted on origin; verify
--train --no-fetch judged the unstamped pin; the slot call now restores the worktree
for an unrequested fifth file.

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

* chore(changelog): name the fragment after the branch slug

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

* docs(captain): the slot restamp stages the stamp files it wrote

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

* docs(ship-queue): verify --train refuses QUEUE_PROOF_MISSING on an unread head

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-11-02 03:23:35 +00:00

401 lines
26 KiB
TypeScript

// Split from test/ship-queue-train.test.ts (GSTA-151): verify --train acceptance, prefix equality and the CHANGELOG rule before and after merge.
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { mergeChangelogs, parseChangelog } from '../lib/changelog-check';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import {
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson,
type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const fixtures: TrainFixture[] = [];
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
const next = (out: string) => line(out, 'NEXT:');
describe('verify, freeze, repair and clear', () => {
test('verify accepts each merge only at prefix equality modulo stamps; an interrupted publication freezes the queue until one repair train lands', () => {
const fx = make();
const { h1, h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord);
const s1 = laneStamp(fx, '1', h1);
const pre1 = v('1');
expect(pre1.status).toBe(EXIT.ok);
expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`);
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const early = v('2');
expect(early.status).toBe(EXIT.fail);
expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
const post1 = v('1');
expect(post1.status).toBe(EXIT.ok);
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m);
expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' });
const mismatch = v('2');
expect(mismatch.status).toBe(EXIT.fail);
expect(mismatch.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
expect(line(mismatch.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(new RegExp(`^QUEUE_TRAIN_FROZEN: train \\S+ merged #1; main ${broken.slice(0, 12)} tree [0-9a-f]{12}; backstop dispatched run \\d+ \\(nonce gate\\.backstop-`));
const backstop = dispatches(fx, 'evals.yml').at(-1)!;
expect([backstop.ref, (backstop.inputs as any).expected_sha, (backstop.inputs as any).evals_all]).toEqual(['main', broken, 'true']);
const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ kind: 'freeze', merged: ['1'], unmerged: [{ pr: '2', head: h2 }], cleared_at: null });
const h3 = openPr(fx, '3');
readyReceipt(fx, '3', h3);
const ordinary = queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--urgent']);
expect(ordinary.status).toBe(EXIT.refused);
expect(ordinary.err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(next(ordinary.out)).toBe('NEXT: gstack-ship-queue train build --repair --prs <repair-pr>@<sha>');
const fresh = path.join(fx.repo.root, 'fresh-state');
expect(queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: fresh }, coord).out).toMatch(/^QUEUE_TRAIN_FROZEN: train /m);
const repair = queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]);
expect(repair.status).toBe(EXIT.ok);
const rn = nonceOf(repair.out);
expect(trainJson(fx, rn)).toMatchObject({ repair: true, base: { sha: broken } });
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1);
// The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand.
fx.repo.git('checkout', '-q', '-B', 'lane3', h3);
fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n');
fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n');
fx.repo.git('commit', '-qam', 'slot stamp #3');
const s3 = fx.repo.git('rev-parse', 'HEAD');
fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`);
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`);
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
markMerged(fx, '3');
const cleared = v('3', rn);
expect(cleared.status).toBe(EXIT.ok);
expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m);
expect(next(cleared.out)).toBe(`NEXT: gstack-ship-queue train build --prs 2@${h2}`);
expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8')).cleared_at).toBe('2026-10-18T12:00:00.000Z');
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
}, 300_000);
test("rule 22: verify --train binds the receipt to the pinned head when the current head is its stamp commit; a receipt for another head refuses the slot and merges nothing", () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const coord = coordinatorClone(fx.repo);
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
const { repo } = fx;
const stamped = laneStamp(fx, '1', h1);
const ok = v();
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`);
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
const stale = v();
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for bbbbbbbbbbbb, not the head ${h1.slice(0, 12)} or ${stamped.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
expect(stale.out).not.toContain('MERGE_CONDITION:');
expect(next(stale.out)).toBe("NEXT: #1's lane runs /ship and READYs again; then gstack-ship-queue train build --prs <pins>");
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', () => {
const fx = make();
const { h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const { repo } = fx;
const laneHead = (extra?: { file: string; text: string }) => {
repo.git('reset', '-q', '--hard');
repo.git('checkout', '-q', '-B', 'lane2', h2);
repo.git('rm', '-rq', '.');
repo.git('checkout', t.prefixes[2].commit, '--', '.');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n');
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
repo.git('add', '-A');
const tree = repo.git('write-tree');
const head = repo.git('commit-tree', tree, '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0');
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
return head;
};
const merged = laneHead();
const ok = v('2');
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toMatch(new RegExp(`^head: ${merged} \\(T2 with main merged in, same modulo stamps`, 'm'));
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${merged}`);
const stray = laneHead({ file: 'stray.txt', text: 'not in the train\n' });
const moved = v('2');
expect(moved.status).toBe(EXIT.fail);
expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`));
}, 300_000);
test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T<i>'s mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => {
const fx = make();
const h1 = openPr(fx, '1', { changelog: false });
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') });
readyReceipt(fx, '1', h1);
readyReceipt(fx, '2', h2);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]');
const coord = coordinatorClone(fx.repo);
const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
const { repo } = fx;
const laneHead = (entry: string, parents: string[]) => {
repo.git('reset', '-q', '--hard');
repo.git('checkout', '-q', '-B', 'lane2', h2);
repo.git('rm', '-rq', '.');
repo.git('checkout', t.prefixes[2].commit, '--', '.');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n');
repo.git('add', '-A');
const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0');
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
return head;
};
const byHand = laneHead('- #2 change', [h2, main1]);
const ok = v();
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`);
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`);
const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]);
const refused = v();
expect(refused.status).toBe(EXIT.fail);
expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `);
expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)');
const orphan = laneHead('- #2 change', [h2]);
const noMain = v();
expect(noMain.status).toBe(EXIT.fail);
expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `);
}, 300_000);
// fails_when=the post-merge verify of a merged-in head the pre-merge verify accepted freezes the queue because T<i>'s single [Unreleased] became two released sections, or the pre-merge verify refuses the correction bullet mergeChangelogs itself wrote; why_new=the GSTA-67 test above never publishes the accepted head and never stacks a child on a released parent; seam=none (real git, the fixture's gh); no_claim=what the squash commit's message says
test("train 24 (GSTA-96): a stacked child's head the pre-merge verify accepted confirms after its squash merge under the same CHANGELOG rule, corrections to the released parent included", () => {
const fx = make();
const { repo } = fx;
const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') });
const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') });
readyReceipt(fx, '1', h1);
readyReceipt(fx, '2', h2);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
expect(parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0']);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
// Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed).
const s1 = laneStamp(fx, '1', h1);
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
// Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent.
const merged = mergeChangelogs(repo.git('show', `${h2}:CHANGELOG.md`) + '\n', repo.git('show', `${main1}:CHANGELOG.md`) + '\n', repo.git('show', `${h1}:CHANGELOG.md`) + '\n')!;
expect(merged).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".');
repo.git('reset', '-q', '--hard');
repo.git('checkout', '-q', '-B', 'lane2', h2);
repo.git('rm', '-rq', '.');
repo.git('checkout', t.prefixes[2].commit, '--', '.');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), merged.replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18'));
repo.git('add', '-A');
const laneHead = repo.git('commit-tree', repo.git('write-tree'), '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0');
repo.git('push', '-q', '-f', 'origin', `${laneHead}:refs/pull/2/head`);
repo.git('reset', '-q', '--hard');
const pre = v('2');
expect([pre.status, pre.err]).toEqual([EXIT.ok, '']);
expect(pre.out).toContain(`head: ${laneHead} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`);
expect(line(pre.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${laneHead}`);
// The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue.
const main2 = publishMain(fx, laneHead, '1.2.5.0');
expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`));
markMerged(fx, '2');
const post = v('2');
expect([post.status, post.err]).toEqual([EXIT.ok, '']);
expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`);
expect(post.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
}, 300_000);
});
describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => {
/** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */
function unstampedMain(fx: TrainFixture, entry: string): string {
const { repo } = fx;
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`));
repo.git('commit', '-qam', 'merged without its stamp');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
repo.git('fetch', '-q', 'origin');
return repo.git('rev-parse', 'HEAD');
}
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => {
const fx = make();
const { h1, h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const bare = v('1');
expect(bare.status).toBe(EXIT.refused);
expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(bare.out).not.toContain('MERGE_CONDITION:');
expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/);
const s1 = laneStamp(fx, '1', h1);
const ok = v('1');
expect(ok.status, ok.err).toBe(EXIT.ok);
expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const unread = queue(fx, ['verify', '--pr', '1', '--train', nonce, '--no-fetch'], {}, coord);
expect(unread.status).toBe(EXIT.refused);
expect(unread.err).toMatch(/#1's current head was not read \(--no-fetch\).*\(QUEUE_PROOF_MISSING\)/);
expect(unread.out).not.toContain('MERGE_CONDITION:');
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true });
const dropped = v('2');
expect(dropped.status, dropped.err).toBe(EXIT.refused);
expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(dropped.out).not.toContain('MERGE_CONDITION:');
expect(fs.existsSync(freezeFile(fx))).toBe(false);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
const stamped = v('2');
expect(stamped.status, stamped.err).toBe(EXIT.ok);
expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0');
expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
}, 300_000);
test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const { repo } = fx;
repo.git('checkout', '-q', '-B', 'lane1', h1);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'VERSION only');
repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/);
}, 300_000);
test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => {
const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' });
const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') });
readyReceipt(fx, '1', h1);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
}, 300_000);
test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => {
const fx = make();
unstampedMain(fx, '- #3527 change');
const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') });
readyReceipt(fx, '5', h5);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m);
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`);
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`);
expect(trainJson(fx, nonce).merged).toEqual([]);
}, 300_000);
test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
laneStamp(fx, '1', h1);
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[2].commit, '1.2.5.0');
const open = v('2');
expect(open.status).toBe(EXIT.refused);
expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED');
const saved = fx.read().prs['2'];
fx.write(d => { delete d.prs['2']; });
const unread = v('2');
expect(unread.err).toContain('(QUEUE_PROOF_MISSING)');
expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(fs.existsSync(freezeFile(fx))).toBe(false);
fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; });
const done = v('2');
expect(done.status, done.err).toBe(EXIT.ok);
expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
}, 300_000);
});