diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1ce7ac7..5335b2208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,15 @@ **A merge the pre-merge verify accepted no longer freezes the queue, and a slot stamp touches only the stamp files.** Train 24 (2026-10-27) merged #3434 and #3453, then froze on #3453's post-merge check and paid a $31 backstop: the pre-merge verify judged the lane's CHANGELOG as main's plus the pin's own entry and said MERGE_CONDITION, the post-merge check compared main to T2 section by section, and T2's one `[Unreleased]` (both stacked entries) had become two released sections. In both slots `gstack-restamp` also listed `lib/ship-policy.ts` as a fifth stamp file, because the lane's uncommitted base merge made #3511's `DOCS_CAPS_KEY_VERSION` look like the branch's own tentative key. And `mergeChangelogs` refused the stacked child outright, so the lane's hand resolve dropped #3453's two wording fixes to the released 1.91.276.0 entry. ### Fixed -- **`gstack-ship-queue verify --train`** (`lib/ship-queue-train-verify.ts`): one CHANGELOG rule before and after the merge. Post-merge, main confirms when its tree is T modulo stamps, or differs from T in the CHANGELOG alone while being its own first parent's CHANGELOG (the main the pre-merge verify saw) plus the PR's own entry, the rule `mergedIn` applied pre-merge; the `main:` line says "except CHANGELOG.md, main 's CHANGELOG.md plus #'s own entry". The own lines are the pin's authored CHANGELOG lines or the lines `mergeChangelogs` writes for that pin against main, so a tool-written correction bullet passes. A freeze lists a slot GitHub reports MERGED as merged (its reason says "merged on GitHub without a verified tree"), never under `unmerged`, so the rebuild after the repair does not seat it again. -- **`gstack-restamp` stamp_paths** (`lib/restamp.ts`): a line the base added since the pin's fork point is the base's, whether the base merge is committed or still staged; `branchAddedLines` leaves it out and the stage detail says "N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)". #3514's receipt bound (`stampOnlyLine`) refuses such a move after the fact only while the moved version is older than the base's at the build; a key at the base version passes it, so the restamp is the guard. -- **`mergeChangelogs`** (`lib/changelog-check.ts`): a stacked child whose parent squash-merged and was released since the merge base (`carryReleasedParent`) is a merge, not a hand resolve. The parent's lines leave the child's `[Unreleased]`; each line the child changed inside them becomes `- Correction to the entry below: "" should read "".` (a dropped line "no longer applies", a line added between the parent's "Addition to the entry below") under the child's own `### Gate integrity`, appended to one it already has. The released entry stays main's byte for byte. Without the merge base the shape is GSTA-67's dropped heading and stays null. `mergeChangelogsWithNotes` returns the carried corrections; the slot one-liner in docs/ship-policy.md prints them. `mergeStep` (train build) passes the merge base, so a child of an already-released parent seats. +- **`gstack-ship-queue verify --train`** (`lib/ship-queue-train-verify.ts`): one CHANGELOG rule before and after the merge. Post-merge, main confirms when its tree is T modulo stamps, or differs from T in the CHANGELOG alone while being its own first parent's CHANGELOG (the main the pre-merge verify saw) plus the PR's own entry, the rule `mergedIn` applied pre-merge; the `main:` line says "except CHANGELOG.md, main 's CHANGELOG.md plus #'s own entry". The own lines are the pin's authored CHANGELOG lines or the lines `mergeChangelogs` writes for that pin against main, so a tool-written correction bullet passes. A freeze lists a slot GitHub reports MERGED as merged (its reason says "merged on GitHub without a verified tree"), never under `unmerged`, so the rebuild after the repair does not seat it again; and a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue instead of answering "nothing of the train merged" while main already holds the unverified squash. A main whose CHANGELOG equals its parent's is not "the parent's plus the PR's own entry" (`ownEntryAboveMain`: "no entry of the PR's own above it"), so a squash that lost the entry the gate carried is refused. +- **`gstack-restamp` stamp_paths** (`lib/restamp.ts`): a line the base added since the pin's fork point is the base's, whether the base merge is committed or still staged; `branchAddedLines` leaves it out and the stage detail says "N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)". #3514's receipt bound (`stampOnlyLine`) refuses such a move after the fact only while the moved version is older than the base's at the build; a key at the base version passes it, so the restamp is the guard. A base diff git cannot run is a refusal ("cannot diff .. for the stamp_paths stage"), never an empty "nothing added" set that would move the base's lines again. +- **`mergeChangelogs`** (`lib/changelog-check.ts`): a stacked child whose parent squash-merged and was released since the merge base (`carryReleasedParent`) is a merge, not a hand resolve. The parent's lines leave the child's `[Unreleased]`; each line the child changed inside them becomes `- Correction to the entry below: "" should read "".` (a dropped line "no longer applies", a line added between the parent's "Addition to the entry below") under the child's own `### Gate integrity`, appended to one it already has. The released entry stays main's byte for byte. Without the merge base the shape is GSTA-67's dropped heading and stays null. `mergeChangelogsWithNotes` returns the carried corrections; the slot one-liner in docs/ship-policy.md prints them. `mergeStep` (train build) passes the merge base, so a child of an already-released parent seats; from slot 2 on, where the prefix already holds an earlier slot's `[Unreleased]` and the sections are unioned, the union takes the carried child, so the gated tree never repeats the released parent's text under the child's heading. ### Tests -- `test/ship-queue-train.test.ts`: a stacked child's merged-in head the pre-merge verify accepted confirms after its squash merge (froze before), with the correction bullet accepted pre-merge; a post-merge CHANGELOG holding a line the pin never wrote still freezes, and the freeze lists the slot GitHub reports MERGED under `merged`. `test/restamp-base-merge.test.ts` (new): train 24's shape (pin forked before the base's key, base merge staged), for a key naming an older release and for one at the base version; the dry run lists the four stamp files plus the branch's own key file only, committed or staged, and the receipt bound is asserted for both keys. `test/changelog-merge-damage.test.ts`: the real a78ce08a/3e4240a7/d87eec95 CHANGELOGs (`test/fixtures/merge-train-3453/`) merge to main's sections unchanged plus #3453's entry with the two corrections; without the base they stay null; shape controls for an unedited, a dropped, an added and an empty child. +- `test/ship-queue-train.test.ts`: a stacked child's merged-in head the pre-merge verify accepted confirms after its squash merge (froze before), with the correction bullet accepted pre-merge; a post-merge CHANGELOG holding a line the pin never wrote still freezes, and the freeze lists the slot GitHub reports MERGED under `merged`; a slot-1 squash GitHub reports MERGED that fails the post-merge check freezes; a stacked child behind another slot is gated on the carried CHANGELOG (the released parent's text appears once, under its own heading, with the child's corrections in the child's entry). `test/restamp-base-merge.test.ts` (new): train 24's shape (pin forked before the base's key, base merge staged), for a key naming an older release and for one at the base version; the dry run lists the four stamp files plus the branch's own key file only, committed or staged, and the receipt bound is asserted for both keys. `test/changelog-merge-damage.test.ts`: the real a78ce08a/3e4240a7/d87eec95 CHANGELOGs (`test/fixtures/merge-train-3453/`) merge to main's sections unchanged plus #3453's entry with the two corrections; without the base they stay null; shape controls for an unedited, a dropped, an added and an empty child. ### Docs -- docs/ship-policy.md § Run a merge train: the one CHANGELOG rule, the stamp-files rule (never a fifth), the stacked-child-after-squash resolution, and that an unstamped head changes a release output by regenerating it at its own VERSION (the Version Gate has accepted that since 1.91.237.0; #3434 reverted its digest change against the older gate). docs/captain.md § Merge trains and captain/templates/slot-call.md carry the stamp-files and stacked-child rules. +- docs/ship-policy.md § Run a merge train: the one CHANGELOG rule, the stamp-files rule (never a fifth), the stacked-child-after-squash resolution, and that an unstamped head changes a release output by regenerating it at its own VERSION (the Version Gate has accepted that since 1.91.237.0; #3434 reverted its digest change against the older gate). docs/captain.md § Merge trains and captain/templates/slot-call.md carry the stamp-files rule (the files the slot call names: `STAMP_FILES`, plus the file carrying a `*_KEY_VERSION` the PR introduced when the call names it; a file the restamp lists that the call did not name is left out and reported) and the stacked-child rule. ### Gate integrity - Correction to the 1.91.276.0 entry below: "copies of shared sections twice until a follow-up dedupes them." should read "versions of shared sections beside the shared file (office-hours' copies are deduped in the entry above).". diff --git a/captain/templates/slot-call.md b/captain/templates/slot-call.md index ce52fa672..16d6cd855 100644 --- a/captain/templates/slot-call.md +++ b/captain/templates/slot-call.md @@ -10,8 +10,10 @@ Run: gstack-restamp --next --base --pr --expect-base --expect-o Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict -markers before you stamp). Commit only the BASE merge and the stamp files: , -never a fifth: a file the restamp lists beyond them is not a stamp, leave it out and say so. +markers before you stamp). Commit only the BASE merge and the stamp files this call names: + (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own +line only, when this call names it), never a fifth: a file the restamp lists that this call +does not name is not a stamp, leave it out and say so. A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's squash touched your lines: take your pin's bytes there (verify checks the tree against T); mergeChangelogs carries your edits to the parent's released entry as Gate integrity diff --git a/docs/captain.md b/docs/captain.md index 9d7be6f45..aa6e616e0 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -207,10 +207,12 @@ files. In practice slot 1 needs the same merge whenever the base moved after the taken. A stamp commit with one extra byte freezes the queue for every lane behind it. -The slot stamp is the base merge, `mergeChangelogs`, and the restamp's stamp files -(`STAMP_FILES` in CREW.md; gstack's are `CHANGELOG.md`, `VERSION`, `package.json` and -`agents-digest/gstack-AGENTS.md`), never a fifth: a file the restamp lists beyond them is -not a stamp, so the lane leaves it out and says so in its READY. The restamp itself never +The slot stamp is the base merge, `mergeChangelogs`, and the stamp files the slot call +names (`STAMP_FILES` in CREW.md; gstack's are `CHANGELOG.md`, `VERSION`, `package.json` and +`agents-digest/gstack-AGENTS.md`, plus the file carrying a `*_KEY_VERSION` the PR +introduced, which the restamp moves on the PR's own line, when the call names it), never a +fifth: a file the restamp lists that the call did not name is not a stamp, so the lane +leaves it out and says so in its READY. The restamp itself never moves a line the base added since the pin's fork point, base merge committed or staged. A pin stacked on a PR that squash-merged earlier conflicts beyond the CHANGELOG where the parent's squash touched the pin's lines: the lane takes its pin's bytes for those files diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts index 7a850b880..98cdc72ae 100644 --- a/lib/changelog-check.ts +++ b/lib/changelog-check.ts @@ -140,8 +140,9 @@ export function normalizeChangelog(text: string): string { /** * A merged-in head's CHANGELOG, however its conflict was resolved: why it is * not main's CHANGELOG with the PR's own entry above it, or null. Main's - * header and every main section must follow unchanged and in order, and the - * sections above them may hold only lines the PR itself added (`authored`, + * header and every main section must follow unchanged and in order, at least + * one section must sit above them (a CHANGELOG equal to main's has lost the + * entry the gate carried), and those sections may hold only lines the PR itself added (`authored`, * the + lines of its pin's CHANGELOG diff), so a hand resolve can drop * damage but never add text the train gate did not carry. */ @@ -154,6 +155,7 @@ export function ownEntryAboveMain(head: string, main: string, authored: Readonly if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`; const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines)); if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`; + if (own === 0) return "it is main's CHANGELOG unchanged, with no entry of the PR's own above it"; const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased')); const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased')); if (clash) return `${clash.heading.trim()} is headed twice`; diff --git a/lib/restamp.ts b/lib/restamp.ts index de53e1c7d..e0d473061 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -283,6 +283,8 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to const out = new Map(); if (files.length === 0) return out; const diff = git(cwd, ['diff', '--unified=0', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', since, ...(to ? [to] : []), '--', ...files]); + // A failed diff must not read as "the branch added nothing" (or "the base added nothing"), which would move the base's lines again. + if (!diff.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `cannot diff ${since.slice(0, 12)}..${to ? to.slice(0, 12) : 'the worktree'} for the stamp_paths stage (${(diff.err || diff.out).split('\n')[0]}); nothing written`); for (const f of parseUnifiedDiff(diff.out)) { const own = out.get(f.path) ?? { text: new Set(), at: new Set(), notOwn: 0 }; const base = notOwn?.get(f.path)?.text; diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 7b61e4472..ec8ff4135 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -139,10 +139,16 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh return f; } -/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */ +/** + * Interrupted publication: freeze when a slot merged, verified or merged on + * GitHub without a verified tree (slot 1's own squash failing the post-merge + * check leaves main unverified with nothing in `merged` yet); otherwise a + * plain refusal (nothing was published). + */ async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise { const run = trainFile(t.nonce); - if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); + const landed = t.merged.length > 0 || t.slots.some(s => prState(env.ctx, s.pr) === 'MERGED'); + if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha); return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs @`, run); } diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index ab2951799..8786d22f1 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -16,7 +16,7 @@ */ import { randomBytes } from 'node:crypto'; import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg'; -import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; +import { carryReleasedParent, changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; import type { ResultStatus } from './headless-artifacts'; import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; @@ -146,7 +146,9 @@ function unionChangelog(ours: string, theirs: string): string { * Version files take the prefix's side; the CHANGELOG merges by section (the * gate-ahead rule with the merge base, which also carries a stacked child * past its released parent, else a union of sections, since each heading is - * restamped at merge), unless a side damaged released history + * restamped at merge; the union takes the carried side too, so a prefix that + * already holds an earlier slot's [Unreleased] never duplicates the released + * parent's text under the child's heading), unless a side damaged released history * (changelogDamage), which is a conflict the lane resolves. Any other * conflict is reported with every conflicting path. */ @@ -179,7 +181,8 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy: const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined); const damage = merged ? null : changelogDamage(text(ours), text(theirs), base ? text(base) : undefined); if (damage) { conflicts.push(`${file} (${damage})`); continue; } - const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(text(ours), text(theirs))); + const own = (base && carryReleasedParent(text(ours), text(theirs), text(base))?.text) || text(ours); + const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(own, text(theirs))); if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); } else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs }); else conflicts.push(file); diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index a80de69f1..92fcd904f 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -235,6 +235,7 @@ export const FREE_ONLY_PR_FILES = [ 'scripts/paid-test-durations.json', 'scripts/ubicloud/**', 'capy/skill/**', // The vendored Capy skill (Drive bootstrapper): run by Capy agents, read by no paid case; test/capy-skill-wrapper.test.ts covers it. + 'captain/templates/**', // Crew templates (CREW.md, slot-call.md, READY.md, ...): read by Captains and by free test/captain-skill.test.ts; no paid case reads them. 'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json. '.gitignore', // Changes which untracked files git reports, never tracked content a case reads. '.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/ of the repo they run in; paid cases run in fixture repos. diff --git a/test/changelog-merge-damage.test.ts b/test/changelog-merge-damage.test.ts index ebb4bf5e7..7a8451c64 100644 --- a/test/changelog-merge-damage.test.ts +++ b/test/changelog-merge-damage.test.ts @@ -78,6 +78,8 @@ describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolve expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry"); expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main'); expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/); + // Main's CHANGELOG unchanged is not main's plus the PR's entry: the entry the gate carried is missing (state-machines review of GSTA-129). + expect(ownEntryAboveMain(main, main, authored)).toBe("it is main's CHANGELOG unchanged, with no entry of the PR's own above it"); }); }); @@ -136,4 +138,15 @@ describe("a stacked child whose parent was released since the fork (GSTA-96, tra expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull(); expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n- a\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull(); }); + + // Value: protects=carryReleasedParent rewrites only a child that still holds the released parent's entry and keeps an existing Gate integrity section's neighbours in place; fails_when=an unrelated [Unreleased] or an already-stamped head is rewritten or the correction lands outside Gate integrity; why_new=the shapes test above never has a child without the parent's entry, a head without [Unreleased], or a Gate integrity section followed by another heading; seam=none; no_claim=the wording of corrections beyond one edited line + test('shapes: a child that does not hold the released parent, or a head already stamped, is null; a correction joins the child\'s existing Gate integrity section without crossing the next heading', () => { + const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n'; + const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`; + const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n'; + expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n- totally other\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + expect(carryReleasedParent('# C\n\n## [1.0.2] - d\n\n- mine\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + const child = '# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n\n### Tests\n- child test\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two edited\n- three\n\n## [1.0.0] - d\n\n- base\n'; + expect(carryReleasedParent(child, theirs, base)!.text).toBe('# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n- Correction to the 1.0.1 entry below: "two" should read "two edited".\n\n### Tests\n- child test\n\n## [1.0.0] - d\n\n- base\n'); + }); }); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 566c3dd6e..b08b2f440 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -147,6 +147,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'], 'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'], 'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'], + 'test/fixtures/merge-train-3453/**': ['test/changelog-merge-damage.test.ts'], 'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'], diff --git a/test/restamp-base-merge.test.ts b/test/restamp-base-merge.test.ts index e7a9a9c65..d8d6944d7 100644 --- a/test/restamp-base-merge.test.ts +++ b/test/restamp-base-merge.test.ts @@ -17,7 +17,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { mergeChangelogs } from '../lib/changelog-check'; -import { stampOnlyLine } from '../lib/restamp'; +import { branchAddedLines, stampOnlyLine } from '../lib/restamp'; import { gitArgvIn } from './helpers/scratch-repo'; import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; @@ -106,4 +106,11 @@ describe('stamp_paths: a key the base introduced after the fork stays, base merg const moved = stampOnlyLine(key('DOCS_CAPS', keyVersion).trim(), key('DOCS_CAPS', '1.2.6.0').trim(), '1.2.5.0', '1.2.6.0', TEMPLATES); expect(moved).toBe(receiptAccepts); }, 120_000); + + // Value: protects=the base-added read fails closed; fails_when=a diff git cannot run reads as "nothing added" and the base's key line moves again (red-team review of GSTA-129); why_new=the test above only runs the stage with a readable base; seam=none; no_claim=which git errors occur in practice + test('a base diff git cannot run is a refusal, never an empty "nothing added" set', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-train24-', policy: POLICY }); repos.push(repo); + expect(branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'HEAD').size).toBe(0); + expect(() => branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'f'.repeat(40))).toThrow(/^cannot diff HEAD\.\.ffffffffffff for the stamp_paths stage \(.+\); nothing written$/); + }); }); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index cfcea7460..8c139e96b 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -404,6 +404,33 @@ describe('train build', () => { expect(dispatches(fx)).toEqual([]); }, 120_000); + // Value: protects=a stacked child in a slot after the first is gated on the carried CHANGELOG, its released parent's text never duplicated under a second [Unreleased]; fails_when=mergeStep unions the uncarried child when the prefix already holds an earlier slot's [Unreleased] (the two-[Unreleased] guard returns null, so the carry is lost); why_new=the train 24 test below releases the parent after the train is built and the slot-1 chain never reaches the union; seam=none; no_claim=how the post-merge verify reads a two-[Unreleased] prefix + test("a stacked child behind another slot: the gated tree carries the child past its released parent instead of repeating the parent's text under its own [Unreleased]", () => { + 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') }); + // #1 is released as 1.2.4.0 before the train; #3 forks from that main and takes the first slot, #2 the second. + const main1 = publishMain(fx, h1, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${h1}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); + const h3 = openPr(fx, '3'); + readyReceipt(fx, '3', h3); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const r = queue(fx, ['train', 'build', '--prs', `3@${h3},2@${h2}`, '--order', '3,2']); + expect([r.status, r.err]).toEqual([EXIT.ok, '']); + expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#3(unscreened) 2:#2(unscreened) (explicit --order)'); + const t = trainJson(fx, nonceOf(r.out)); + const gated = parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`) + '\n'); + expect(gated.sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', 'Unreleased', '1.2.4.0', '1.2.3.0', '1.2.2.0']); + const own = gated.sections[0]!.lines.join('\n'); + expect(own).toContain('**Child entry of #2.**'); + expect(own).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".'); + expect(own).not.toContain('Parent entry of #1'); + expect(gated.sections[1]!.lines.join('\n')).toContain('- #3 change'); + const released = parseChangelog(repo.git('show', `${main1}:CHANGELOG.md`) + '\n').sections[0]!; + expect(gated.sections[2]!.lines.join('\n').replace(/\n+$/, '')).toBe(released.lines.join('\n').replace(/\n+$/, '')); + }, 120_000); + test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => { const fx = make(); githubOrigin(fx, 'https://github.com/fixture/repo.git'); @@ -994,4 +1021,46 @@ describe('verify, freeze, repair and clear', () => { const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ merged: ['1', '2'], unmerged: [] }); }, 300_000); + + // Value: protects=a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue; fails_when=stopped() reads an empty merged list as "nothing merged" while main already holds the unverified squash (red-team review of GSTA-129); why_new=the test above freezes on slot 2 with slot 1 verified; seam=the fixture's gh pr view state; no_claim=the backstop's outcome + test("slot 1 merged on GitHub but not confirmed: the queue freezes with #1 merged and #2 unmerged instead of saying nothing merged", () => { + 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 smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + const broken = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; }); + const frozen = v('1'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); + expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); + }, 300_000); + + // Value: protects=a post-merge CHANGELOG difference is excused only when main's parent is T modulo stamps, so a foreign commit between the verified head and the squash freezes; fails_when=published() skips or inverts the parent check; why_new=the two tests above reach the CHANGELOG rule only with a parent that is T1 modulo stamps; seam=none; no_claim=a parent differing from T1 in the CHANGELOG alone + test("a post-merge CHANGELOG difference is not excused when main's parent is not the verified prefix modulo stamps: the queue freezes naming the parent", () => { + const fx = make(); + const { 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 released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n'; + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released }); + expect(v('1').status).toBe(EXIT.ok); + const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' }); + const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; + const main2 = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text }); + expect(fx.repo.git('rev-parse', `${main2}^`)).toBe(foreign); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${main2.slice(0, 12)} is neither T1 nor T2 modulo stamps`); + expect(frozen.err).toContain(`main's parent ${foreign.slice(0, 12)} is not T1 modulo stamps`); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + }, 300_000); });