From c6e781f4a97df57215c02383e5a05332af5b8ffb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 17:56:31 -0700 Subject: [PATCH] ship-receipt: carry counts changelog_fragments fragments as release metadata (GST-343) (#3656) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ship-receipt | 7 +++-- docs/ship-policy.md | 5 ++- lib/ship-receipt.ts | 14 ++++++--- package.json | 2 +- test/gstack-ship-receipt.test.ts | 53 ++++++++++++++++++++++++++++++++ 8 files changed, 83 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b47a9f0..06d6e809d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.344.0] - 2026-10-31 + +**A PR that moves its CHANGELOG entry into `changelog.d/` keeps its /ship receipt (GST-343).** Since #3654 turned on changelog fragments for gstack, a base merge that moved a PR's `[Unreleased]` entry into `changelog.d/.md` made `gstack-ship-receipt carry` refuse with RECEIPT_CARRY_PRODUCT_CHANGED. The PR then needed a new review pass for a changelog edit. + +### Fixed +- **Carry counts a changelog fragment as release metadata** (`lib/ship-receipt.ts`, `bin/gstack-ship-receipt`). When the base policy sets `changelog_fragments`, each `/.md` directly under the directory is metadata, as `CHANGELOG.md` is. The directory's README.md, a nested path, a non-Markdown file and a fragment under a policy without `changelog_fragments` stay product. docs/ship-policy.md lists the fragment under release metadata. + +### Tests +- `test/gstack-ship-receipt.test.ts`: a merge-only head that moves the entry into `changelog.d/feat.md` carries as "base merge + release metadata". A README edit in the same merge is refused and named, and so is the same move when the base policy has no `changelog_fragments`. + ## [1.91.343.0] - 2026-10-31 **Every ask to the owner now uses one lettered format (R41): the Captain's name, then one line per decision with the problem, the options and the recommendation, so the owner can answer "do it" or a letter like "B2" from a phone. When the owner has to do something, the ask links the exact page and lists the steps (R43).** diff --git a/VERSION b/VERSION index aaa173526..058587e4b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.343.0 +1.91.344.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3e673ff7b..26af38ff3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.343.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.344.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 1b24fd1af..59c64628e 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -428,12 +428,13 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); } -interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] } function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { const load = loadPolicyFromBase(repoRoot, base); const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; - const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs }; - return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...outputs], templates: policy?.stamp_patterns ?? [] }; + const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, ''); + const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] }; } /** diff --git a/docs/ship-policy.md b/docs/ship-policy.md index c08f4e986..df2719d2e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1080,7 +1080,10 @@ itself touched at either head (a base merge brings in every path the base changed, none of which can differ between the two base diffs). Product means every changed path that is not test code (`test/**`, `spec/**`, `e2e/**`, `**/*.test.`, `**/*.spec.` and the other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is -product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, and `VERSION` +product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each +`/.md` fragment when the base policy sets +`changelog_fragments` (not the directory's README.md or a nested path; a README +change also makes the range's fragments product), and `VERSION` or `package.json` only while the change is a version stamp and nothing else: a dependency, a script or a new key makes them product). A path the policy's `stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index d02bded8f..6cc75afa4 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -8,6 +8,7 @@ */ import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts'; import { approvalSummary } from './approval-citations'; +import { FRAGMENT_README } from './changelog-fragments'; import { renderIssueLines } from './issue-links'; import { matchesAny } from './ship-policy'; import { stampFileKind, versionStampOnly } from './pregate/stamp-only'; @@ -136,8 +137,9 @@ export function receiptSummaryLine(r: ShipReceipt): string { * Paths whose change never invalidates a /ship verdict on its own: tests (the * reviewed product code is what the verdict covers; CI at the new head reruns * them, unreviewed) and release metadata (the version stamp, the changelog, the - * agents digest). A repo extends the lists through its ship policy - * (`stamp_files`, `stamp_paths`, `release_outputs`, `changelog`); lockfiles are + * agents digest, and under `changelog_fragments` each `/.md` fragment). A repo + * extends the lists through its ship policy (`stamp_files`, `stamp_paths`, `release_outputs`, + * `changelog`, `changelog_fragments.dir`); lockfiles are * product unless the policy names them, because a dependency change is a * product change. The test globs name code extensions only: an API contract * such as `openapi.spec.yaml` is product. @@ -203,8 +205,9 @@ export function stampOnlyText(change: CarryChange): boolean { } /** - * One changed path's carry class. The changelog and the agents digest are - * metadata. A manifest (`package.json`, `VERSION`) is metadata only while its + * One changed path's carry class. The changelog, the agents digest and, when the + * base policy names `changelog_fragments.dir` (`extra.fragments`), each fragment + * directly under it except its README are metadata. A manifest (`package.json`, `VERSION`) is metadata only while its * from..head change is a version stamp and nothing else (`versionStampOnly`): a * dependency, a script or a new key is product. A path the policy names under * `stamp_files`, `stamp_paths` or `release_outputs` is metadata only while its @@ -213,9 +216,10 @@ export function stampOnlyText(change: CarryChange): boolean { * the tree receipt. `change` carries the texts; a caller without them gets the * list-only answer. */ -export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[]; outputs?: readonly string[] } = {}, change?: CarryChange): CarryClass { +export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[]; outputs?: readonly string[]; fragments?: string } = {}, change?: CarryChange): CarryClass { const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change)); if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product'; + if (extra.fragments && rel !== `${extra.fragments}/${FRAGMENT_README}` && matchesAny([`${extra.fragments}/*.md`], rel)) return 'metadata'; if (matchesAny(extra.outputs ?? [], rel)) return 'generated'; if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product'; if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test'; diff --git a/package.json b/package.json index 6c5c3c6c3..cf4d30bfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.343", + "version": "1.91.344", "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/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index 15ac078b2..e18582ebe 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -506,6 +506,59 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head); }); + // GST-343 (found on #3648): after #3654 a base merge moves a PR's entry from CHANGELOG.md's [Unreleased] into changelog.d/. + // Value: protects=a PR that moves its entry into a changelog_fragments fragment keeps its receipt, as a CHANGELOG edit does; + // fails_when=carry counts /.md as product (RECEIPT_CARRY_PRODUCT_CHANGED), or counts the fragment directory's + // README, a nested path or a fragment under a policy without changelog_fragments as metadata; why_new=carry had no + // fragments rule; seam=none; no_claim=gh pr edit (--body stands in) + test('a merge-only head that moves the entry into a changelog_fragments fragment carries; the README, a nested path and a fragment with no fragments policy are product', () => { + const frag = { metadata: [], fragments: 'changelog.d' }; + expect(classifyCarryPath('changelog.d/feat.md', frag)).toBe('metadata'); + expect(classifyCarryPath('changelog.d/README.md', frag)).toBe('product'); + expect(classifyCarryPath('changelog.d/sub/feat.md', frag)).toBe('product'); + expect(classifyCarryPath('changelog.d/feat.sh', frag)).toBe('product'); + expect(classifyCarryPath('changelog.d/feat.md')).toBe('product'); + const policy = (fragments: boolean) => JSON.stringify({ bump: 'patch', changelog: 'CHANGELOG.md', ...(fragments ? { changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' } : {}) }) + '\n'; + const run = (fragments: boolean, extra: Record = {}) => { + const repo = makeFixtureRepo({ policy: null, prefix: 'ship-receipt-frag-' }); repos.push(repo); + const put = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), text); }; + const released = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8'); + put('src/app.ts', 'export const a = 1;\n'); + put('changelog.d/README.md', '# fragments\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-qb', 'feat'); + put('src/app.ts', 'export const a = 2;\n'); + put('CHANGELOG.md', released.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- a=2\n')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'feat: a=2'); + const reviewed = repo.git('rev-parse', 'HEAD'); + // main turns on fragments (or only adds a policy without them); the merge moves the entry into changelog.d/feat.md + repo.git('checkout', '-q', 'main'); + put('.gstack/ship-policy.json', policy(fragments)); + repo.git('add', '-A'); repo.git('commit', '-qm', 'policy'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feat'); + repo.git('merge', '-q', '--no-commit', 'origin/main'); + put('CHANGELOG.md', released); + put('changelog.d/feat.md', '- a=2\n'); + for (const [rel, text] of Object.entries(extra)) put(rel, text); + repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; entry into a fragment'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any)); + const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; + return { reviewed, head: repo.git('rev-parse', 'HEAD'), out: runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env) }; + }; + const moved = run(true); + expect(moved.out.status).toBe(EXIT.ok); + expect(moved.out.out).toContain(`CARRY: would carry from ${moved.reviewed.slice(0, 12)} to ${moved.head.slice(0, 12)} (base merge + release metadata)`); + const readme = run(true, { 'changelog.d/README.md': '# fragments\nRun rm -rf on merge.\n' }); + expect(readme.out.status).toBe(EXIT.refused); + expect(readme.out.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;'); + expect(readme.out.err).toContain('changelog.d/README.md'); + const off = run(false); + expect(off.out.status).toBe(EXIT.refused); + expect(off.out.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;'); + expect(off.out.err).toContain('changelog.d/feat.md'); + }, 30_000); + // /ship Step 9 pass 1 (security specialist, reproduced in the QA smoke): the carry's proof was patch-id --stable, which strips whitespace. // Value: protects=a product edit that only moves whitespace in a reviewed line (semantics in shell, YAML, Python) refuses to carry, // and a dirty working tree refuses before anything is compared; fails_when=patchId drops --verbatim or the status check goes;