From d053d1d54d87271d88b935b314badff00613b3ef Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:02:13 +0000 Subject: [PATCH] feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 ++- bin/gstack-doctor-components.sh | 5 +++++ docs/ship-policy.md | 5 +++++ lib/ship-policy.ts | 2 ++ lib/ship-queue-land.ts | 7 ++++--- ship/SKILL.md | 2 +- ship/SKILL.md.tmpl | 2 +- test/fixtures/golden/agy-ship-SKILL.md | 2 +- test/fixtures/golden/claude-ship-SKILL.md | 2 +- test/fixtures/golden/codex-ship-SKILL.md | 2 +- test/fixtures/golden/factory-ship-SKILL.md | 2 +- test/gstack-ship-policy.test.ts | 3 +++ test/ship-queue-land.test.ts | 10 ++++++++++ test/size-ledger/ship/gsta154-changelog-fragments.json | 4 ++++ 14 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 test/size-ledger/ship/gsta154-changelog-fragments.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b9f8da8..048a4deff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. - **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. @@ -16,7 +17,7 @@ ### Tests - `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses. - `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index dda793216..d02a688e8 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -315,6 +315,11 @@ gstack_result_anchor() { RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;; RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;; + CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;; + QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;; + QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;; + QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;; + QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;; PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;; PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;; APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 6fe54954d..8f255beb0 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1184,6 +1184,11 @@ release heading, or one whose name already landed on the base with other text is `CHANGELOG_FRAGMENT_INVALID`, and nothing is allocated. Only fragments the branch adds against the base count. +**/ship.** `show-effective` prints a `ship: changelog_fragments` line, so /ship +writes no version in Step 12 and writes Step 13's entry as +`/.md`, linted with `gstack-prose-lint --gate`, never an +`[Unreleased]` section. + **The fold.** `gstack-restamp` (and so `land`) concatenates the fragment bodies in path order above any legacy `[Unreleased]` section, re-heads both as one `## [] - ` section at the top, and deletes the fragment diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 5159d153d..62ece4e94 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -571,6 +571,8 @@ export function shipConsequences(policy: ShipPolicy, base: string): string[] { out.push(`shared merge queue: when a coordinator runs the queue, \`gstack-ship-queue ready --pr \`, \`gate-ahead --after --pr \` and \`stamp --pr --version --expect-gated --expect-base \` replace the calls above; stamp only when your slot is called, send the READY block and the QUEUE_STAMP: line; the coordinator runs \`gstack-ship-queue verify\` (docs/ship-policy.md#run-a-shared-merge-queue)${policy.gate_command ? `; gate command: \`${policy.gate_command}\`` : ''}`); out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr --bundle --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate ` is the coordinator\'s call'); } else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)'); + const frag = policy.changelog_fragments?.dir.replace(/\/+$/, ''); + if (frag) out.push(`changelog_fragments ${frag}: Step 12 writes no version and Step 13 writes the entry as ${frag}/.md (the entry text only, no heading; \`bump: minor\` or \`bump: major\` front matter only when the change warrants more than the policy bump), never an [Unreleased] section; lint it with \`gstack-prose-lint ${frag}/.md --gate\`; \`gstack-ship-queue land\` assigns the version at merge (docs/ship-policy.md#changelog-fragments)`); out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written'); out.push(policy.review_scope === 'all' ? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)' diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index c245f19e7..2e1bc3061 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -20,7 +20,7 @@ import { restamp, versionAtRef } from './restamp'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; -import { bindReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; export const LAND_LOCK_REF = 'refs/notes/gstack-land'; export const MAX_RESTAMPS = 2; @@ -297,8 +297,9 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { if (j && (j.phase === 'landed' || j.reviewed_pin !== info.headRefOid && j.last_pushed_head !== info.headRefOid)) j = null; const pin = j?.reviewed_pin ?? info.headRefOid; const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : ''}`]; - const receipt = bindReceipt(info.body ?? '', o.pr, [pin]); - lines.push(receipt.line); + git(root, ['fetch', '-q', 'origin', ctx.base]); + const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]); + lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : [])); if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run); if (!j) { const ci = ciAtHead(ctx, pin); diff --git a/ship/SKILL.md b/ship/SKILL.md index 5846b96d3..af3321709 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 810bbd990..60b353f16 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -204,7 +204,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 59c5baac2..89f7b3e99 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 5846b96d3..af3321709 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index afd639848..8e3efeb73 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -472,7 +472,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index e2c3e30bc..c8172cdcd 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -474,7 +474,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 983491aa5..20c0e3343 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -160,6 +160,9 @@ describe('gstack-ship-policy (bin)', () => { expect(r.out).toMatch(/^ship: bump patch: Step 12 never asks/m); expect(r.out).toMatch(/^ship: release_tool: `node gen\.js` runs only inside gstack-restamp/m); expect(r.out).not.toMatch(/^ship: history/m); + expect(r.out).not.toMatch(/^ship: changelog_fragments/m); + const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.92.0.0' } }); + expect(runBin(frag, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out).toMatch(/^ship: changelog_fragments changelog\.d: Step 12 writes no version and Step 13 writes the entry as changelog\.d\/\.md .*never an \[Unreleased\] section; lint it with `gstack-prose-lint changelog\.d\/\.md --gate`/m); const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } }); const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: ')); expect(lines.map(l => l.split(':')[1]!.trim())).toEqual(['history merge-only', 'queue_mode claim-at-open', 'bump ask', 'review_scope frozen', 'preregistration']); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 2ff4225bf..335a19f58 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -200,6 +200,16 @@ describe('land: lock, races and refusals', () => { expect(remote(fx, 'pr-8')).toBe(late); }, 90_000); + test('the receipt check is the depth-checked one READY uses: a receipt whose writer records depth but which records none refuses', () => { + const fx = setup(); + const head = openPr(fx, '31'); + fx.write(d => { d.prs['31'].body = shippedBody('31', head, { gstack_version: '1.91.307.0' }); }); + const r = land(fx, ['--pr', '31']); + expect(r.err).toContain('(QUEUE_RECEIPT_DEPTH_RAISED)'); + expect(r.out).toMatch(/^REVIEW_DEPTH: receipt=none required=/m); + expect(remote(fx, 'pr-31')).toBe(head); + }, 60_000); + test('drift: a merge outside land between the check and the squash fails loudly and keeps the lock', () => { const fx = setup(); openPr(fx, '9'); diff --git a/test/size-ledger/ship/gsta154-changelog-fragments.json b/test/size-ledger/ship/gsta154-changelog-fragments.json new file mode 100644 index 000000000..69cbc7434 --- /dev/null +++ b/test/size-ledger/ship/gsta154-changelog-fragments.json @@ -0,0 +1,4 @@ +{ + "bytes": 1, + "why": "GSTA-154: the ship-policy paragraph says its ship: lines bind Steps 12–16, so the changelog_fragments line reaches Step 13's entry" +}