// 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 = 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 @'); 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 "); 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'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'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); });