train S4: merge #3937 at 269ccb6bcb v1.94.38.0

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit abe6712cb5
9 files changed
+204 -5

No files matched your search

+4
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.94.37.0
1.94.38.0
+1 -1
View File
@@ -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,
+8 -1
View File
@@ -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')));
+1
View File
@@ -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 <branch> 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 <sha12> 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 (<file>)`) |
| 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
+3 -1
View File
@@ -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)).
+1 -1
View File
@@ -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",
@@ -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<string, string> = {}) => 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<string, string>; keep?: (main: string) => string; extra?: Record<string, string>; 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);
});
@@ -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<string, string>, 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);
});