diff --git a/CHANGELOG.md b/CHANGELOG.md index 749fc6f56..534725023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **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; the record is built from the one GitHub read that decided to freeze, so a slot that read MERGED stays merged when GitHub stops answering a moment later, and a slot unreadable beside a merged one is kept out of `unmerged` and named in the reason ("unreadable on GitHub, kept out of unmerged until read"); 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; when GitHub's answer for a slot cannot be read, the verify refuses (`QUEUE_PROOF_MISSING`, "whether # merged on GitHub could not be read") and asks to be rerun, never "nothing merged". 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-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; the record is built from the one GitHub read that decided to freeze, so a slot that read MERGED stays merged when GitHub stops answering a moment later, and a slot unreadable beside a merged one is left out of `unmerged` and named in the reason ("unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers"), so the `NEXT:` after the repair omits it; 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; when GitHub's answer for a slot cannot be read, the verify refuses (`QUEUE_PROOF_MISSING`, "whether # merged on GitHub could not be read") and asks to be rerun, never "nothing merged". 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. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 2f24ee4cf..ecc0d28a1 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -624,7 +624,9 @@ freezes the same way, since main already holds the unverified squash. The record comes from the one GitHub read that decided to freeze, never a second one: a slot that read MERGED stays merged even when GitHub stops answering before the record is written, and a slot unreadable beside a merged one is -kept out of `unmerged` and named in the reason until it can be read. When +left out of `unmerged` and named in the reason; nothing reads it again, so +the `NEXT:` after the repair omits it and the operator seats it in a train by +hand once `gh pr view` answers. When whether a slot merged on GitHub cannot be read, verify refuses (`QUEUE_PROOF_MISSING`, "whether # merged on GitHub could not be read") and asks to be rerun once `gh pr view` answers; an unreadable state is never diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index eded67d2d..2043a0a38 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4352,9 +4352,11 @@ the repair's rebuild never seats it again), main's tree and the backstop run dispatched on main's SHA. The record is built from the one GitHub read that decided to freeze: a slot that read MERGED stays merged even when `gh pr view` stops answering a moment later, and a slot whose state could not be read when -another slot had merged is kept out of `unmerged` and named in the reason -(`# unreadable on GitHub, kept out of unmerged until read`), so the -rebuild after the repair never seats a PR that may already be in. The +another slot had merged is left out of `unmerged` and named in the reason +(`# unreadable on GitHub, left out of unmerged; seat it in a train by hand +once gh pr view answers`), so the rebuild after the repair never seats a PR +that may already be in; nothing reads that slot again, so the `NEXT:` after +the repair omits it and the operator seats it by hand. The freeze lives in local state and on `refs/notes/gstack-trains`. Ordinary trains and other trains' merges are refused until a repair lands. diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 5cf30b561..cb206c1f7 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -114,8 +114,9 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa * anything remote, then the backstop gate is dispatched on main's sha. * `states` is the one GitHub read of every unverified slot, made by the * caller: a slot that read MERGED is merged, verified tree or not, and a slot - * that could not be read is left out of `unmerged` and named in the reason, - * so the rebuild after the repair never seats a PR that may already be in. + * that could not be read is left out of `unmerged` and named in the reason + * (nothing reads it again; the operator seats it by hand once gh answers), so + * the rebuild after the repair never seats a PR that may already be in. */ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string, states: ReadonlyMap): Promise { const now = nowIso(); @@ -127,7 +128,7 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.merged.map(m => m.pr), ...landed], unmerged: unverified.filter(s => !landed.includes(s.pr) && !unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, - reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, kept out of unmerged until read` : ''].filter(Boolean).join('; '), + reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), backstop: null, repair: null, created_at: now, cleared_at: null, cleared_by: null, updated_at: now, }; diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 7aa71fd8b..7a2a8538f 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -1069,6 +1069,22 @@ describe('verify, freeze, repair and clear', () => { 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 slot unreadable on GitHub beside a merged one is left out of unmerged and named in the reason, so the rebuild after the repair never seats a PR that may already be in; fails_when=freezeQueue files an unreadable slot under unmerged, or drops it without naming it; why_new=the test above keeps slot 2 readable; seam=the fixture's gh pr view vanishing for slot 2; no_claim=the backstop's outcome + test("a slot unreadable on GitHub beside a merged one is left out of unmerged and named in the freeze reason", () => { + 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 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'; + 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'; d.vanish = { '2': 0 }; }); + const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + expect(frozen.status).toBe(EXIT.fail); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; #2 unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers; unmerged \(none\); repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [] }); + }, 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();