diff --git a/CHANGELOG.md b/CHANGELOG.md index 68538aa8b..8a298b1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Added - **`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, a reused name, or one whose text is already released on the base (a parent PR's fragment carried in by stacked history) 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` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`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, the READY 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` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--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`. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 749dcfa76..be2e10152 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -170,8 +170,12 @@ ci: green at (passed=31 failed=0 pending=0 skipped=2) PREGATE: pass tree= head= checks=6 gate: command="" full_e2e=no gate-ahead=none SHIP_RECEIPT: pr=42 head= … +READY_RECEIPT: /ship-queue/ready//42-.json (notes pushed to origin refs/notes/gstack-trains) ``` +Under `changelog_fragments`, a `READY_RECEIPT:` line follows, written only when +every check above passed; `land` refuses a pin without one. + CI's Version Gate (`scripts/compare-pr-version.ts`) accepts that head as unstamped when `VERSION` and `package.json`'s version match its merge base, the CHANGELOG change is confined to `[Unreleased]`, and any changed release output @@ -1217,7 +1221,10 @@ stamp commit. Steps, each journaled in `.gstack/tmp/ship-queue/land/.json` and resumable by rerunning the same command: -1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a /ship receipt that is missing or +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 + 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 required checks that are not green at the pin. 2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 2a9eb8cd8..63e04aad8 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -21,6 +21,7 @@ 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 { bindShippedReceipt } from './ship-receipt-depth'; +import { loadReady, rebuildFromNotes, repoIdentity } from './ship-queue-train-record'; export const LAND_LOCK_REF = 'refs/notes/gstack-land'; export const MAX_RESTAMPS = 2; @@ -191,6 +192,10 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } } if (!git(wt, ['commit', '-q', '--no-verify', '--allow-empty', '-m', `land #${o.pr}: merge origin/${ctx.base}`]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot commit the merge in the scratch worktree' }; + const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; + const linked = git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) + .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean); + if (linked) return { code: 'QUEUE_LAND_CONFLICT', message: `${linked} is a symlink in the merged tree; the merge step writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: ctx.base, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: o.now, env: process.env }); if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; lines.push(...r.stages.filter(s => s.status !== 'pending').map(s => `stage ${s.name}: ${s.status}${s.detail ? ` (${s.detail})` : ''}`)); @@ -295,14 +300,21 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi if (!git(wt, ['commit', '-q', '--no-verify', '-m', `land: release entry for the merge outside land beside #${j.pr}`]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'cannot commit the repair' }, run); const head = git(wt, ['rev-parse', 'HEAD']).out; const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${branch}:`, 'origin', `${head}:refs/heads/${branch}`]); - if (!push.ok) lines.push(`branch: ${branch} already on origin (an earlier --repair pushed it); opening or finding its PR`); + let pushed = head; + if (!push.ok) { + git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]); + const remote = git(root, ['log', '-1', '--format=%H%n%s', 'FETCH_HEAD']).out.split('\n'); + if (!isSha(remote[0]) || remote[1] !== `land: release entry for the merge outside land beside #${j.pr}`) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `cannot push ${branch}, and origin holds no repair commit there: ${push.err.split('\n')[0]}` }, run); + pushed = remote[0]!; + lines.push(`branch: ${branch} already on origin at ${pushed.slice(0, 12)} (an earlier --repair pushed it); opening or finding its PR`); + } const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Release entry for the merge outside land beside #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); const n = /\/pull\/(\d+)/.exec(`${pr.out}\n${pr.err ?? ''}`)?.[1]; if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); j.repair_pr = n; writeJsonAtomic(file, j); const held = readLock(root); if (held.lock?.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n }); - return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${head}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; + return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${pushed}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; } finally { git(root, ['worktree', 'remove', '--force', wt]); fs.rmSync(wt, { recursive: true, force: true }); @@ -336,6 +348,10 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { 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 (!policy.changelog_fragments) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'the base policy has changelog_fragments off; land merges only under the key, where ready records the READY receipt it checks' }, run); + const keys = repoIdentity(root).keys; + if (!loadReady(keys, o.pr, pin)) rebuildFromNotes(root, ctx.remote ?? 'origin'); + if (!loadReady(keys, o.pr, pin)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY receipt for #${o.pr} at the pin ${pin.slice(0, 12)}; ready checks the pregate and full_e2e evidence land merges on`, fix: `run gstack-ship-queue ready --pr ${o.pr}` }, run); if (!j) { const ci = ciAtHead(ctx, pin); lines.push(`CI: ${ci.line}`); diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index e4588ea13..68ae3cf26 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -39,6 +39,7 @@ import { type AttemptRecord, type QueueFailure, } from './ship-queue-gate'; import { wtreeFingerprint } from './script-invocation'; +import { nowIso, persist, readyPath, repoIdentity } from './ship-queue-train-record'; import { buildReceipt, type TreeReceipt } from './tree-receipt'; export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string }; @@ -277,6 +278,12 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run); if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run); if (!receipt.ok) return fail({ code: receipt.code, message: receipt.message }, lines, run); + if (!policy.policy.train && policy.policy.changelog_fragments) { + const repo = repoIdentity(ctx.repoRoot); + const now = nowIso(); + const p = persist(ctx.repoRoot, [{ schema: 1, kind: 'ready', repo_key: repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }], { remote: ctx.remote ?? 'origin' }); + lines.push(`READY_RECEIPT: ${readyPath(repo.key, o.pr, head)} (${p.detail})`); + } return { ok: true, lines, run }; } diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index a54795fe4..4d5cc9b71 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -15,6 +15,7 @@ import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, runBin, type FixtureRe import { shippedBody } from './helpers/ship-queue-train-fixture'; import { resolveReleaseConflicts } from '../lib/ship-queue-land'; import { mergePolicy } from '../lib/ship-policy'; +import { persist, repoIdentity } from '../lib/ship-queue-train-record'; const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; const repos: FixtureRepo[] = []; @@ -58,7 +59,17 @@ function setup(policy: Record = POLICY): Fx { } /** A PR branch from origin/main, pushed, with a fragment (or none), and a /ship receipt for its head in the shim. */ -function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (work: string) => void; shipped?: boolean } = {}): string { +/** The READY receipt `gstack-ship-queue ready` records (a note on origin refs/notes/gstack-trains), written into the fixture's own state root. */ +function markReady(fx: Fx, pr: string, head: string): void { + const prior = process.env.GSTACK_STATE_ROOT; + process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state'); + try { + const now = new Date().toISOString(); + persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr, head, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }]); + } finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; } +} + +function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (work: string) => void; shipped?: boolean; ready?: boolean } = {}): string { const { repo } = fx; repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr); fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n'); @@ -72,6 +83,7 @@ function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (wo repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr); const head = repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs[pr] = { state: 'OPEN', headRefName: 'pr-' + pr, isCrossRepository: false, title: 'PR ' + pr + ' title', body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); + if (opts.ready !== false) markReady(fx, pr, head); repo.git('checkout', '-q', '--detach', 'origin/main'); return head; } @@ -131,6 +143,7 @@ describe('land: fold, push, merge', () => { fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-3'); const head = fx.repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs['3'] = { state: 'OPEN', headRefName: 'pr-3', isCrossRepository: false, title: 'legacy', body: shippedBody('3', head) }; }); + markReady(fx, '3', head); const r = land(fx, ['--pr', '3']); expect(r.err).toBe(''); expect(r.out).toMatch(/^legacy: the pin stamped 1\.2\.9\.0 itself/m); @@ -236,6 +249,7 @@ describe('land: lock, races and refusals', () => { expect(show(fx, n[2] + ':changelog.d/repair-9.md')).toBe('- Changes merged outside land beside #9: outside merge'); expect(land(fx, ['--pr', n[1]!]).err).toContain('(QUEUE_RECEIPT_MISSING)'); fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, n[2]!); }); + markReady(fx, n[1]!, n[2]!); const pushed = land(fx, ['--pr', n[1]!]); expect(pushed.out).toContain("LOCK: taking over #9's drift lock for its repair"); const merged = land(fx, ['--pr', n[1]!]); @@ -516,6 +530,7 @@ describe('land: one pass, base, lock and history', () => { fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-11'); const head = fx.repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs['11'] = { state: 'OPEN', headRefName: 'pr-11', isCrossRepository: false, title: 'child', body: shippedBody('11', head) }; }); + markReady(fx, '11', head); fx.repo.git('checkout', '-q', '--detach', 'origin/main'); const r = land(fx, ['--pr', '11']); expect(r.status).toBe(EXIT.refused); @@ -592,6 +607,7 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release const n = /REPAIR_PR: #(\d+) gstack-land-repair\/23/.exec(rep.out)!; const head = remote(fx, 'gstack-land-repair/23'); fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, head); }); + markReady(fx, n[1]!, head); fs.rmSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '23.json')); expect(land(fx, ['--pr', n[1]!]).out).toContain("LOCK: taking over #23's drift lock for its repair"); const merged = land(fx, ['--pr', n[1]!]); @@ -610,6 +626,17 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n'); }, 90_000); + // Value: protects=restamp never folds fragments through a PR-controlled symlinked CHANGELOG; fails_when=the fold rewrites a file outside the checkout, even in --dry-run; why_new=the legacy guard runs only when the pin bumped VERSION; seam=none; no_claim=Windows symlink semantics + test('a pin whose CHANGELOG is a symlink refuses before restamp and leaves the target untouched', () => { + const fx = setup(); + const outside = path.join(fx.repo.root, 'outside-changelog.md'); + fs.writeFileSync(outside, '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n'); + openPr(fx, '27', { edit: w => { fs.rmSync(path.join(w, 'CHANGELOG.md')); fs.symlinkSync(outside, path.join(w, 'CHANGELOG.md')); } }); + const r = land(fx, ['--pr', '27', '--dry-run']); + expect(r.err).toMatch(/CHANGELOG\.md is a symlink in the merged tree.*\(QUEUE_LAND_CONFLICT\)/); + expect(fs.readFileSync(outside, 'utf8')).toBe('# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n'); + }, 90_000); + // Value: protects=a legacy pin in a repo whose version source is package.json keeps its dependency and script edits; fails_when=the whole manifest is reset to the merge base; why_new=every case used a VERSION file; seam=none; no_claim=lockfile shapes beyond package.json test('version source package.json: a legacy pin keeps its dependency and script edits', () => { const fx = setup(); @@ -631,3 +658,21 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release expect(pkg.version).toBe('1.2.4'); }, 90_000); }); + +// GStack Minder ruling (2026-10-29, GSTA-154): standalone land checks the evidence ready enforces. +describe('land: the READY receipt', () => { + // Value: protects=land refuses a pin ready never accepted (pregate, full_e2e), before any lock or push; fails_when=land merges a head that skipped ready; why_new=every other case marks its PR ready; seam=none; no_claim=what ready itself checks (its own tests) + test('a pin with a /ship receipt but no READY receipt refuses, naming the ready command; nothing is locked or pushed', () => { + const fx = setup(); + const pin = openPr(fx, '28', { ready: false }); + for (const args of [['--pr', '28'], ['--pr', '28', '--dry-run']]) { + const r = land(fx, args); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/no READY receipt for #28 at the pin [0-9a-f]{12}.*fix: run gstack-ship-queue ready --pr 28 \(QUEUE_NOT_READY\)/); + } + expect(remote(fx, 'pr-28')).toBe(pin); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); + markReady(fx, '28', pin); + expect(land(fx, ['--pr', '28']).out).toContain('PUSHED: '); + }, 90_000); +}); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 15e24c277..0926bc00d 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -108,6 +108,23 @@ describe('the policy gate', () => { expect(flagged.err).toContain('--screen/--no-screen/--broad ignored: the base policy declares no train block'); expect(fs.existsSync(path.join(fx.state, 'ship-queue'))).toBe(false); }, 120_000); + + // Value: protects=under changelog_fragments ready records the READY receipt land checks, only after every ready check passed; fails_when=land has no proof of the pregate; why_new=the case above covers the key off; seam=none; no_claim=the notes push to a real GitHub remote + test('under changelog_fragments ready records a READY receipt; a failed pregate records none', () => { + const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }); + const head = openPr(fx, '1'); + fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; }); + const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim(); + const pregate = path.join(fx.repo.root, 'pregate.json'); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'fail', checks: [{ id: 'unit', status: 'fail' }] })); + expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate]).status).toBe(EXIT.refused); + expect(fs.existsSync(path.join(fx.state, 'ship-queue'))).toBe(false); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] })); + const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate]); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(new RegExp(`^READY_RECEIPT: .*ship-queue/ready/[^/]+/1-${head}\\.json \\(notes pushed to origin refs/notes/gstack-trains\\)$`, 'm')); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(''); + }, 120_000); }); describe('ready under a train policy: the screen choice', () => {