diff --git a/CHANGELOG.md b/CHANGELOG.md index 533a39541..01031fbd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.94.38.0] - 2026-11-03 + +- **A /ship receipt now survives a changelog-fragment conversion (GST-1210).** When a PR moved to a `changelog.d` fragment after review, `VERSION`, `package.json` and `CHANGELOG.md` went back to main's content, and if main had also changed `package.json` beyond its version, `gstack-ship-receipt carry` read that as the branch's change and refused with `RECEIPT_CARRY_PRODUCT_CHANGED` (HARN-1, harness-evals#206). A stamp file the branch changes by no more than a stamp against each head's base now carries as release metadata. A non-stamp `package.json` change, a logic edit, or a stamp file whose entry can't be read still refuses. + ## [1.94.37.0] - 2026-11-03 - **Pull requests reuse a green Free Tests run they can't control** (`scripts/ci-tree-reuse.ts find --pull-request`, docs/pregate.md#ci-reuse-pr). A pull request run skips the suite when a push or dispatch run on main, or a dispatch run on its own branch, already passed on the merge commit's exact tree, and the pull request leaves the workflow, the reuse script and `lib/run-evidence.ts` unchanged. A pull request run is never a source, so a pull request never certifies itself. diff --git a/VERSION b/VERSION index bf1e63110..fb73d8cce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.37.0 +1.94.38.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 136686c7d..66661c8cf 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.37.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.38.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index b9695b568..e72d89ecd 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -555,7 +555,14 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; }; const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p); const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, placeholders: rules.placeholders, side: a === from && b === to ? branch?.own(p) : undefined } : undefined); - const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) })); + // A stamp file the branch changed on neither side beyond a stamp against that side's base (back at the base's content + // after a changelog-fragment conversion, or only its version moved) holds none of the branch's product: whatever else + // differs between the two heads came from the base (GST-1210). + // The same `ls-tree` entry (mode and object id, empty when absent) is an unchanged side; a failed read never is. + const entryAt = (ref: string, p: string) => git(['ls-tree', ref, '--', p], repoRoot); + const sameEntry = (a: string, b: string, p: string) => { const x = entryAt(a, p); return x !== undefined && x === entryAt(b, p); }; + const ownStampOnly = (p: string) => !!branch?.touched.has(p) && stamped(p) && branch.sides.every(([a, b]) => sameEntry(a, b, p) || classify(p, a, b) === 'metadata'); + const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: ownStampOnly(p) ? 'metadata' as const : classify(p, from, to) })); const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) : [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product'))); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index f611346eb..149cd8dba 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1510,6 +1510,7 @@ receipt when the base moves: | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | | a stacked PR whose receipt was written against the base, after its parent landed and the base was merged in, HEAD's product diff exactly the reviewed range from the parent's head | carries (reason `stack parent landed`); base context next to the child's hunks is `RECEIPT_CARRY_SCOPED_REVIEW` | | a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows ()`) | +| a stamp file the branch changes, against each head's base, by no more than a stamp (back at the base's content after a changelog-fragment conversion, or only its version field moved), whatever else the base changed in it | carries (reason `release metadata`, GST-1210) | **Stacked PRs (#3570).** A child PR's receipt is written against its parent branch, so its `base` names that branch and its `product_patch_id` is the diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 117f5b646..844bf6965 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6021,7 +6021,9 @@ output the pregate's regen check proved, a rebase onto a covered tree with the recorded product diff, a stacked PR's retarget once its parent fully landed on the base, a stamp on the branch's own rows in a stamp file it also changed as product (every changed line at a branch-added position, the mode -unchanged, the base not touching the file), or base context next to unchanged +unchanged, the base not touching the file), a stamp file the branch changes by +no more than a stamp against each head's base (a changelog-fragment conversion +that puts `package.json` back at the base's content), or base context next to unchanged hunks after a clean scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). diff --git a/package.json b/package.json index 9601bcb5a..e37bb43a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.37", + "version": "1.94.38", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/ship-receipt-carry-fragment-conversion.test.ts b/test/ship-receipt-carry-fragment-conversion.test.ts new file mode 100644 index 000000000..453c48cd9 --- /dev/null +++ b/test/ship-receipt-carry-fragment-conversion.test.ts @@ -0,0 +1,113 @@ +/** + * gstack-ship-receipt carry across a changelog-fragment conversion (GST-1210, HARN-1 harness-evals#206): a PR reviewed + * with its own version stamp (VERSION, package.json, CHANGELOG) moves to a changelog.d fragment after the base adopts + * fragments, which puts every stamp file back at the base's content. A stamp file the branch no longer changes against + * the base, or changes only in its stamp fields, holds no unreviewed change, so the receipt carries; a non-stamp change + * in package.json or a logic edit next to the reviewed one still refuses. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt } from '../lib/ship-receipt'; +import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); +const pkg = (version: string, scripts: Record = {}) => JSON.stringify({ name: 'fixture', version, scripts: { test: 'node --test test/*.test.js', generate: 'node gen.js', ...scripts } }, null, 2) + '\n'; +const POLICY = (fragments: boolean) => JSON.stringify({ min_gstack_version: '1.91.340.0', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'], ...(fragments ? { changelog_fragments: { dir: 'changelog.d' } } : {}) }, null, 2) + '\n'; +const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n"; + +/** + * main at 1.0.0 <- feature: four retries plus its own 1.0.1 stamp, /ship receipt written there. main then ships 1.0.3, + * adopts changelog fragments and (`mainScripts`) changes package.json beyond its version. The branch merges main and + * converts: `converted` is the package.json it keeps (main's by default), plus a fragment and any `extra` files. + */ +function converted(repo: FixtureRepo, opts: { mainScripts?: Record; keep?: (main: string) => string; extra?: Record; executable?: boolean } = {}): { reviewed: string; body: string } { + repo.git('checkout', '-q', 'main'); + commit(repo, { VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## 1.0.0\n- base\n', '.gstack/ship-policy.json': POLICY(false) }, 'main: stamp files'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('reset', '-q', '--hard', 'origin/main'); + const reviewed = commit(repo, { 'lib/retry.js': RETRY4, VERSION: '1.0.1\n', 'package.json': pkg('1.0.1'), 'CHANGELOG.md': '# Changelog\n\n## 1.0.1\n- four retries\n\n## 1.0.0\n- base\n' }, 'feat: four retries (v1.0.1)'); + const body = path.join(repo.root, 'body.md'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '206', '--base', 'main', '--version', '1.0.1', '--session-kind', 'unattended', '--body', body]); + expect(w.status).toBe(EXIT.ok); + repo.git('checkout', '-q', 'main'); + const mainPkg = pkg('1.0.3', opts.mainScripts); + commit(repo, { VERSION: '1.0.3\n', 'package.json': mainPkg, 'CHANGELOG.md': '# Changelog\n\n## 1.0.3\n- other PRs\n\n## 1.0.0\n- base\n', '.gstack/ship-policy.json': POLICY(true), 'changelog.d/README.md': '# fragments\n' }, 'main: v1.0.3, changelog fragments'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + try { repo.git('merge', '-q', '--no-edit', 'origin/main'); } catch { /* the stamp files conflict; the conversion resolves them below */ } + repo.git('checkout', 'origin/main', '--', 'VERSION', 'CHANGELOG.md', 'package.json'); + write(repo.work, 'package.json', (opts.keep ?? (m => m))(mainPkg)); + write(repo.work, 'changelog.d/four-retries.md', '- four retries\n'); + for (const [rel, text] of Object.entries(opts.extra ?? {})) write(repo.work, rel, text); + if (opts.executable) fs.chmodSync(path.join(repo.work, 'package.json'), 0o755); + repo.git('add', '-A'); + if (opts.executable) repo.git('update-index', '--chmod=+x', 'package.json'); + repo.git('commit', '-q', '--no-edit', '-m', 'convert to a changelog fragment'); + return { reviewed, body }; +} + +describe('carry across a changelog-fragment conversion (GST-1210)', () => { + // Value: protects=a PR converted to a changelog fragment after review keeps its receipt when the base also changed + // package.json beyond its version (HARN-1 harness-evals#206 paid a fresh /ship); fails_when=carry reads the base's own + // package.json change as the branch's product change; seam=none; no_claim=gh pr edit (--body stands in) + test('a stamp file back at the base\'s content carries as release metadata', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { reviewed, body } = converted(repo, { mainScripts: { lint: 'node lint.js' } }); + const head = repo.git('rev-parse', 'HEAD'); + const out = carry(repo, body); + expect(out.err).not.toContain('RECEIPT_CARRY'); + expect(out.status).toBe(EXIT.ok); + expect(out.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)}`); + expect(receiptIn(body).carry!.reason).toContain('release metadata'); + }, 90_000); + + // Value: protects=the converted PR whose package.json is the base's except its version field carries as stamp-only; + // fails_when=the version difference against the base reads as a product change; seam=none; no_claim=gh pr edit + test('a package.json equal to the base\'s except the version field carries', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = converted(repo, { mainScripts: { lint: 'node lint.js' }, keep: m => m.replace('"version": "1.0.3"', '"version": "1.0.4"') }); + const out = carry(repo, body); + expect(out.err).not.toContain('RECEIPT_CARRY'); + expect(out.status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toContain('release metadata'); + }, 90_000); + + // Value: protects=fail closed on a package.json field the branch changed beyond the stamp (a new script runs code); + // fails_when=a stamp file that is not the base's content is read as stamp-only; seam=none; no_claim=gh pr edit + test('a non-stamp field the branch adds to package.json still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = converted(repo, { mainScripts: { lint: 'node lint.js' }, keep: m => m.replace('"lint": "node lint.js"', '"lint": "node lint.js",\n "postinstall": "node evil.js"') }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain('package.json'); + }, 90_000); + + // Value: protects=fail closed on a stamp file whose content is the base's but whose mode the branch flipped (a mode is + // not a stamp); fails_when=an unchanged side is read from the object id alone; seam=none; no_claim=gh pr edit + test('a package.json back at the base\'s content with a flipped mode still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = converted(repo, { mainScripts: { lint: 'node lint.js' }, executable: true }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain('package.json'); + }, 90_000); + + // Value: protects=fail closed on a logic edit beside the reviewed one riding the conversion; fails_when=the stamp-file + // rule widens to the whole diff; seam=none; no_claim=gh pr edit + test('a logic edit next to the reviewed constant still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = converted(repo, { mainScripts: { lint: 'node lint.js' }, extra: { 'lib/retry.js': RETRY4.replace('TIMEOUT_MS = 5000', 'TIMEOUT_MS = 50') } }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain('lib/retry.js'); + }, 90_000); +}); diff --git a/test/ship-receipt-carry-ported-pin.test.ts b/test/ship-receipt-carry-ported-pin.test.ts new file mode 100644 index 000000000..7e80a18ab --- /dev/null +++ b/test/ship-receipt-carry-ported-pin.test.ts @@ -0,0 +1,72 @@ +/** + * gstack-ship-receipt carry never guesses which base entry a branch-added pin file holds (GST-1210, GSTA-202 #3903 at + * f0613db8): the branch splits the base's JSON map of pins into one file per entry, and a pin file that changes after review + * is a product change whatever the base did to a same-named entry. Inferring the mapping from names and values let an + * unrelated base edit carry an unreviewed change (GSTA-36 ruling A on #3937), so these controls keep every such pin refused. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); +const RETRY = '{"blob":"aaa","since":6,"nonBehavioral":"first pin"}'; +const UTIL = '{"blob":"bbb","since":6}'; +const RETRY_MAIN = '{"blob":"ccc","since":6,"nonBehavioral":"main repinned retry"}'; +const manifest = (retry: string, short?: string) => `{\n "version": 6,\n "files": {\n "lib/retry.js": ${retry},\n "lib/util.js": ${UTIL}${short ? `,\n "retry.js": ${short}` : ''}\n }\n}\n`; +const PIN = 'scripts/harness-pins/lib/retry.js.json'; + +/** main holds the pin map; feature splits it into one file per entry beside a product change, /ship receipt there; main repins retry; feature merges main and writes `ports`. */ +function ported(repo: FixtureRepo, ports: Record, maps: [string, string] = [manifest(RETRY), manifest(RETRY_MAIN)]): { reviewed: string; body: string } { + repo.git('checkout', '-q', 'main'); + commit(repo, { 'scripts/harness-version.json': maps[0] }, 'main: harness pins'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('reset', '-q', '--hard', 'origin/main'); + const reviewed = commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n", [PIN]: `${RETRY}\n`, 'scripts/harness-pins/lib/util.js.json': `${UTIL}\n` }, 'feat: one file per harness pin'); + const body = path.join(repo.root, 'body.md'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3903', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'scripts/harness-version.json': maps[1], 'docs/README.md': '# docs after the repin\n' }, 'main: repin retry'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + commit(repo, ports, 'port the base repin into its pin file'); + return { reviewed, body }; +} + +describe('carry across a ported harness-pin entry (GST-1210)', () => { + // Value: protects=fail closed on a pin the branch changed beyond the base's entry (an unreviewed repin); + // fails_when=any pin file change carries; seam=none; no_claim=gh pr edit + test('a pin entry that differs from the base\'s still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = ported(repo, { [PIN]: `${RETRY_MAIN.replace('ccc', 'ddd')}\n` }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain(PIN); + }, 90_000); + + // Value: protects=fail closed when the base's entry lands in another entry's pin file; fails_when=the port matches the + // value without its key; seam=none; no_claim=gh pr edit + test('the base\'s entry written into another entry\'s pin file still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = ported(repo, { [PIN]: `${RETRY_MAIN}\n`, 'scripts/harness-pins/lib/util.js.json': `${RETRY_MAIN}\n` }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain('scripts/harness-pins/lib/util.js.json'); + }, 90_000); + + // Value: protects=fail closed when a shorter key's repin lands in a longer key's pin file (`retry.js` for + // `…/lib/retry.js.json`); fails_when=the key is matched as a bare path suffix; seam=none; no_claim=gh pr edit + test('a repin of a key that is only a suffix of the pin path still refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = ported(repo, { [PIN]: `${RETRY_MAIN}\n` }, [manifest(RETRY, RETRY), manifest(RETRY, RETRY_MAIN)]); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.err).toContain(PIN); + }, 90_000); +});