mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
6748d54f7f
commit
c6e781f4a9
8 files changed
+83
-12
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user