From d095dc2c813fcdd3b024b940966139148944db2a Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 07:29:17 +0000 Subject: [PATCH] fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready gstack-pregate clear and gstack-tree-receipt now classify through one classifyFiles, so under changelog_fragments a receipt for the fragment head clears the stamped head when the stamp is exactly the fold. The ship-policy reference names ready's receipt push, land's own check polling, the key-off refusal and the /ship changelog_fragments line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-queue | 5 +-- docs/ship-policy.md | 21 ++++++++----- lib/pregate/remote-receipt.ts | 6 ++-- lib/tree-receipt.ts | 42 +++++++++++++++----------- test/pregate-remote-stamp-only.test.ts | 25 +++++++++++++++ 5 files changed, 70 insertions(+), 29 deletions(-) diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index b05dc7e4a..0584c3bba 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -21,7 +21,8 @@ * * Every subcommand takes --base (default origin/HEAD, else main) and * ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=. - * gstack-restamp stays the primitive. Nothing here pushes, except under a + * gstack-restamp stays the primitive. Nothing here pushes, except land, ready + * under changelog_fragments (its READY receipt note), and under a * ship policy with a `train` block: merge trains push train/ branches * and the refs/notes/gstack-trains records, and dispatch through eval-bg. * Logic: lib/ship-queue.ts, lib/ship-queue-gate.ts, lib/ship-queue-train*.ts @@ -66,7 +67,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen --base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the -refs/notes/gstack-land lock) and merge trains. +refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { diff --git a/docs/ship-policy.md b/docs/ship-policy.md index be2e10152..3792e7e43 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -149,7 +149,8 @@ ship receipt into the coordinator protocol, so nobody proves a stamp diff by hand. Lanes own their PRs and never merge; the coordinator owns the order and every merge. Each step ends in a line the other side acts on, then `GSTACK_RESULT: skill=ship-queue status=complete|refused run=`. Nothing -here pushes. +here pushes, except `ready` under `changelog_fragments` (its READY receipt goes +to `refs/notes/gstack-trains`) and [`land`](#land). **1. Lane: report ready (unstamped).** After the gate and pregate pass on the pushed head: @@ -784,6 +785,7 @@ which carry the workflow consequences the skill prose would otherwise spell out: | `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base ` must print only `ANCESTOR: ok` before push | | `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) | | `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) | +| `ship: changelog_fragments : …` | Step 12 writes no version; Step 13 writes the entry as `/.md` (entry text only, `bump:` front matter only when the change warrants more than the policy bump), never an `[Unreleased]` section, linted with `gstack-prose-lint --gate`; `gstack-ship-queue land` assigns the version; see [changelog fragments](#changelog-fragments) | The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`), written by the pr-body section of `/ship` after the body is composed. @@ -1209,8 +1211,9 @@ valid fragment (a legacy `[Unreleased]`-only entry passes during migration; a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a dependency or a script, pass. A head made by the merge step, a commit whose parents are [pin, base], must carry the base's version plus the effective -bump of the fragments its pin adds. `gstack-ship-queue ready` applies the same -rule. +bump of the fragments its pin adds. Under a train policy `gstack-ship-queue +ready` applies the same rule itself; without one, `ready` requires the head's +checks green, Version Gate included. ## `gstack-ship-queue land`: the per-PR merge step @@ -1222,7 +1225,8 @@ stamp commit. Steps, each journaled in command: 1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a - pin without a READY receipt (`gstack-ship-queue ready --pr ` records one + base policy with `changelog_fragments` off (`QUEUE_NOT_READY`), a pin + without a READY receipt (`gstack-ship-queue ready --pr ` records one on `refs/notes/gstack-trains` once its pregate, full_e2e and receipt checks pass; land checks nothing weaker), a /ship receipt that is missing or bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and @@ -1250,8 +1254,10 @@ command: keeps the reviewed pin apart from the last pushed head, so a rerun leases on what it pushed and never on a head someone else pushed (`QUEUE_TRAIN_MOVED`, never overwritten). -5. **Wait** for the PR's checks on the stamp commit with `gstack-pr-wait` - (`--no-wait` prints the command instead; `--timeout `, default 60). +5. **Wait** for the PR's checks on the stamp commit, polling every 30 seconds + with the same check read the pin passed (`--timeout `, default 60). `--no-wait` + stops after the push and prints `gstack-pr-wait --until green && + gstack-ship-queue land --pr ` to run instead. CI runs for real on the stamp commit in this mode. 6. **Merge.** Re-read the base first: if it moved, restamp from the new base and push again under the last-pushed lease, at most twice @@ -1311,7 +1317,8 @@ Hunks are classified per file type: the version file (old → new only), the manifest (`version` key only), npm and bun lockfiles (root `version` keys only, where `gstack-version-bump` writes; a dependency entry is `changed`), the CHANGELOG (a heading move or re-heading, compared as an order-independent -multiset of entries), `stamp_paths` and `release_outputs` (every +multiset of entries; under `changelog_fragments`, also exactly the deleted +fragments folded under the new heading, and those deletions are stamps), `stamp_paths` and `release_outputs` (every removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with X the gated version, or a newer version of the repo's width that the stamp_paths stage moves (a tentative `*_KEY_VERSION` written as the next diff --git a/lib/pregate/remote-receipt.ts b/lib/pregate/remote-receipt.ts index 701bcbdf4..1d5fa7810 100644 --- a/lib/pregate/remote-receipt.ts +++ b/lib/pregate/remote-receipt.ts @@ -12,7 +12,7 @@ * `substituteStampOnlyTree` is the one accepted substitution. The gated tree * and the head tree are diffed path by path, and every differing file is * classified by the tree receipt's own rule (lib/tree-receipt.ts - * `classifyFile`: the repo's `stamp_files` allowlist and `stamp_patterns` + * `classifyFiles`, the changelog_fragments fold included: the repo's `stamp_files` allowlist and `stamp_patterns` * templates from `.gstack/ship-policy.json` on the base branch, beside the * built-in version-file, manifest-version, lockfile-root-version and * changelog-heading forms). The receipt is accepted for the head only when @@ -33,7 +33,7 @@ import { spawnSync } from 'node:child_process'; import type { PregateReceiptSubstitution } from '../headless-artifacts'; import { parseUnifiedDiff, versionAtRef } from '../restamp'; import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy'; -import { classifyFile } from '../tree-receipt'; +import { classifyFiles } from '../tree-receipt'; import { treeMismatch } from './checks/lanes'; /** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */ @@ -103,7 +103,7 @@ export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTre if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun }; const inventory = git(repoRoot, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', '--ignore-submodules=none', '--end-of-options', gatedTree, headTree]); if (inventory === null) return { problem: `${mismatch}; git diff-tree ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun }; - const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV)); + const files = classifyFiles(repoRoot, parseUnifiedDiff(diff), gatedTree, headTree, loaded.policy, versionRel, oldV, newV); if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun }; const expected = new Set(inventory.split('\0').filter(Boolean)); const seen = new Set(files.map(f => f.path)); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 9285583f8..de94d0004 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -50,6 +50,30 @@ function blob(cwd: string, treeish: string, rel: string): string | null { return r.status === 0 ? r.stdout : null; } +/** + * Every file's verdict for one gated/current pair, with the changelog_fragments fold: a fragment deleted + * between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new + * heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree. + */ +export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] { + const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; + const folded = new Map(); + for (const f of fileDiffs) { + if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue; + const text = blob(repoRoot, gated, f.path); + const parsed = text === null ? null : parseFragment(f.path, text); + if (parsed && !('code' in parsed)) folded.set(f.path, parsed); + } + const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); + const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments); + const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog); + const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; + return fileDiffs.map((f): FileVerdict => { + if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV); + return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` }; + }); +} + /** * Every removed/added pair differs only by stamp rewrites to the new version: * the old version, or a tentative one (newer than it) the stamp_paths stage @@ -147,23 +171,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; const oldV = 'version' in gv ? gv.version : null; const newV = 'version' in cv ? cv.version : null; const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? ''; - const fileDiffs = parseUnifiedDiff(diffText); - const prefix = o.policy.changelog_fragments ? o.policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; - const folded = new Map(); - for (const f of fileDiffs) { - if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue; - const text = blob(o.repoRoot, gatedHead, f.path); - const parsed = text === null ? null : parseFragment(f.path, text); - if (parsed && !('code' in parsed)) folded.set(f.path, parsed); - } - const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); - const changelogVerdict = (f: FileDiff) => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV, fragments); - const clDiff = fileDiffs.find(f => o.policy.changelog && f.path === o.policy.changelog); - const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; - const files = fileDiffs.map((f): FileVerdict => { - if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV); - return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${o.policy.changelog ?? 'the CHANGELOG'}` }; - }); + const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/test/pregate-remote-stamp-only.test.ts b/test/pregate-remote-stamp-only.test.ts index fbeb5edc2..1a50a49b0 100644 --- a/test/pregate-remote-stamp-only.test.ts +++ b/test/pregate-remote-stamp-only.test.ts @@ -290,3 +290,28 @@ describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-o expect(pregateJson(repo).requires_remote[0].receipt).toBeNull(); }, 120_000); }); + +// /ship docs audit (GSTA-154 PR A): clear's substitution and gstack-tree-receipt share one classifier, fragment fold included. +describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_fragments', () => { + // Value: protects=a receipt for the fragment head clears the stamped head when the stamp is exactly the fold; fails_when=clear treats the fold as a change and forces a second remote gate, or accepts a fold that dropped the text; why_new=every other case uses [Unreleased]; seam=none; no_claim=restamp's own fold (its tests) + test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => { + const repo = makeFixtureRepo({ + pregate: { ...BASE_PREGATE, remote_gate: GATE }, + baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' }, + }); + repos.push(repo); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--ff-only', 'main'); + const gated = commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b;\n', 'changelog.d/add.md': '- add\n' }, 'change with a fragment'); + const gatedTree = repo.git('rev-parse', `${gated}^{tree}`); + const stampTo = (entry: string) => { + repo.git('checkout', '-q', '--detach', gated); + fs.rmSync(path.join(repo.work, 'changelog.d', 'add.md')); + return repo.git('rev-parse', `${commit(repo, { VERSION: '1.0.1\n', 'CHANGELOG.md': `# Changelog\n\n## [1.0.1] - 2026-10-29\n\n${entry}\n## [1.0.0] - 2026-10-01\n\n- base\n` }, 'v1.0.1 stamp')}^{tree}`); + }; + const ok = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add\n')); + expect('substitution' in ok && ok.substitution.files).toEqual(expect.arrayContaining([{ path: 'CHANGELOG.md', rule: 'changelog-fragments' }, { path: 'changelog.d/add.md', rule: 'changelog_fragments' }])); + const edited = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add, reworded\n')); + expect('problem' in edited && edited.problem).toContain('changelog.d/add.md (changelog_fragments: deleted without its text folded'); + }, 120_000); +});