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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 00:56:31 +00:00
1 parent 6748d54f7f
commit c6e781f4a9
8 files changed
+83 -12

No files matched your search

+10
View File
@@ -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/<slug>.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 `<dir>/<slug>.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).**
+1 -1
View File
@@ -1 +1 @@
1.91.343.0
1.91.344.0
+1 -1
View File
@@ -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,
+4 -3
View File
@@ -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 ?? [] };
}
/**
+4 -1
View File
@@ -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.<code ext>`, `**/*.spec.<code ext>` 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
`<changelog_fragments.dir>/<slug>.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
+9 -5
View File
@@ -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 `<dir>/<slug>.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';
+1 -1
View File
@@ -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",
+53
View File
@@ -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 <dir>/<slug>.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<string, string> = {}) => {
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;