mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
v1.92.28.0 ship-receipt: a base merge's metadata changes no longer block carry (GST-343, #3606) (#3706)
* ship-receipt: carry demotes a metadata glob only for the branch's own changes (GST-343, #3606) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-receipt: carry demotes a metadata glob from the branch's own non-stamp changes on either side (GST-343, GST-407 cycle 5 test) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-receipt: a branch side that leaves a metadata path unchanged never demotes its glob Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
cd0c912a0e
commit
6bffa200a5
7 files changed
+37
-15
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.92.28.0] - 2026-11-01
|
||||||
|
|
||||||
|
**A PR reviewed before #3654 keeps its /ship receipt after it merges main and moves its entry into `changelog.d/` (GST-343, #3606).** #3656 taught carry that a fragment is release metadata, but the base merge also brought in `changelog.d/README.md`. Carry counted that README as a product change, dropped the fragment rule for the whole range, and refused with RECEIPT_CARRY_PRODUCT_CHANGED although the product patch-id was unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Only the branch's own changes turn release-metadata paths back into product** (`bin/gstack-ship-receipt`). A metadata path whose change is not a stamp still counts as product when the branch changed it against the base. A change a base merge brought in no longer does, because its patch against the base is the same at both heads. A README edit the PR makes itself is still refused. Replayed on #3606 (49666bc8 to e28bb62): carry now reports "base merge + release metadata".
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `test/gstack-ship-receipt.test.ts`: the fragment carry case now adds `changelog.d/README.md` on main together with the policy, the way #3654 did. It failed before this fix with RECEIPT_CARRY_PRODUCT_CHANGED.
|
||||||
|
|
||||||
## [1.92.27.0] - 2026-11-01
|
## [1.92.27.0] - 2026-11-01
|
||||||
|
|
||||||
**/ship on Capy no longer spends calls reading its review sections.** In the GSTA-193 replay of PR #34 (C1-34), 13 of 42 parent calls only read part of a section (review-army.md, the core checklist, Steps 8.1-8.2, adversarial.md, documentation.md) or ran a step that belonged in the next program. Each phase program now ends by returning the next phase's sections, unchanged and split at headings, in the space its own 32 KB return has left. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged.
|
**/ship on Capy no longer spends calls reading its review sections.** In the GSTA-193 replay of PR #34 (C1-34), 13 of 42 parent calls only read part of a section (review-army.md, the core checklist, Steps 8.1-8.2, adversarial.md, documentation.md) or ran a step that belonged in the next program. Each phase program now ends by returning the next phase's sections, unchanged and split at headings, in the space its own 32 KB return has left. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.92.27.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.92.28.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
+19
-9
@@ -460,8 +460,15 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
|
|||||||
* The product pathspec between two commits: every path but tests and release
|
* The product pathspec between two commits: every path but tests and release
|
||||||
* metadata, where a metadata-listed path whose change from `from` to `to` is
|
* metadata, where a metadata-listed path whose change from `from` to `to` is
|
||||||
* not a stamp stays product (every glob that matches it leaves the exclusion).
|
* not a stamp stays product (every glob that matches it leaves the exclusion).
|
||||||
|
* With `branch` (the paths the branch itself changed against the base, and its
|
||||||
|
* base..head ranges), only the branch's own non-stamp changes demote a glob: a
|
||||||
|
* touched path whose change is not a stamp on either side, or between the two
|
||||||
|
* heads. A path only a base merge brought in has the same patch against the base
|
||||||
|
* at both heads, and demoting its glob would pull a matching path the branch did
|
||||||
|
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
|
||||||
|
* (GST-343, #3606), into the product patch-id at one head only.
|
||||||
*/
|
*/
|
||||||
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
|
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]> }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
|
||||||
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
|
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
|
||||||
if (changedRaw === undefined) return undefined;
|
if (changedRaw === undefined) return undefined;
|
||||||
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
||||||
@@ -470,11 +477,14 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
|
|||||||
if (file !== null) return file.trim() || null;
|
if (file !== null) return file.trim() || null;
|
||||||
try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
|
try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
|
||||||
};
|
};
|
||||||
const oldVersion = versionAt(from);
|
const versions = new Map<string, string | null>();
|
||||||
const newVersion = versionAt(to);
|
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
|
||||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) }));
|
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 } : undefined);
|
||||||
|
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
|
||||||
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
||||||
const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path);
|
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')));
|
||||||
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
|
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
|
||||||
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
|
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
|
||||||
}
|
}
|
||||||
@@ -579,7 +589,10 @@ function cmdCarry(rest: string[], json: boolean): number {
|
|||||||
} else {
|
} else {
|
||||||
const fromBase = git(['merge-base', baseRef, from], repoRoot);
|
const fromBase = git(['merge-base', baseRef, from], repoRoot);
|
||||||
if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
|
if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
|
||||||
const spec = productSpec(repoRoot, rules, from, head);
|
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
|
||||||
|
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
||||||
|
const touched = new Set([...listed[0], ...listed[1]]);
|
||||||
|
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] });
|
||||||
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
|
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
|
||||||
const { changed } = spec;
|
const { changed } = spec;
|
||||||
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
|
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
|
||||||
@@ -610,9 +623,6 @@ function cmdCarry(rest: string[], json: boolean): number {
|
|||||||
// which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks
|
// which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks
|
||||||
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
|
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
|
||||||
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
|
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
|
||||||
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
|
|
||||||
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
|
||||||
const touched = new Set([...listed[0], ...listed[1]]);
|
|
||||||
for (const p of [...touched].sort()) {
|
for (const p of [...touched].sort()) {
|
||||||
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
||||||
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
||||||
|
|||||||
+2
-1
@@ -1341,7 +1341,8 @@ other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
|
|||||||
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each
|
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each
|
||||||
`<changelog_fragments.dir>/<slug>.md` fragment when the base policy sets
|
`<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
|
`changelog_fragments` (not the directory's README.md or a nested path; a README
|
||||||
change also makes the range's fragments product), and `VERSION`
|
change the branch itself makes also makes the range's fragments product, while one
|
||||||
|
a base merge brings in does not), and `VERSION`
|
||||||
or `package.json` only while the change is a version stamp and nothing else: a
|
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
|
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
|
`stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.92.27",
|
"version": "1.92.28",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -524,16 +524,17 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea
|
|||||||
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 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');
|
const released = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||||
put('src/app.ts', 'export const a = 1;\n');
|
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('add', '-A'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
|
||||||
repo.git('checkout', '-qb', 'feat');
|
repo.git('checkout', '-qb', 'feat');
|
||||||
put('src/app.ts', 'export const a = 2;\n');
|
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'));
|
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');
|
repo.git('add', '-A'); repo.git('commit', '-qm', 'feat: a=2');
|
||||||
const reviewed = repo.git('rev-parse', 'HEAD');
|
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
|
// main turns on fragments with their README (or only adds a policy without them), as #3654 did after #3606 was
|
||||||
|
// reviewed (GST-343); the merge moves the entry into changelog.d/feat.md
|
||||||
repo.git('checkout', '-q', 'main');
|
repo.git('checkout', '-q', 'main');
|
||||||
put('.gstack/ship-policy.json', policy(fragments));
|
put('.gstack/ship-policy.json', policy(fragments));
|
||||||
|
put('changelog.d/README.md', '# fragments\n');
|
||||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'policy'); repo.git('push', '-q', 'origin', 'main');
|
repo.git('add', '-A'); repo.git('commit', '-qm', 'policy'); repo.git('push', '-q', 'origin', 'main');
|
||||||
repo.git('checkout', '-q', 'feat');
|
repo.git('checkout', '-q', 'feat');
|
||||||
repo.git('merge', '-q', '--no-commit', 'origin/main');
|
repo.git('merge', '-q', '--no-commit', 'origin/main');
|
||||||
|
|||||||
Reference in new issue
Block a user