mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
fix(version-gate): regenerate at the merge base VERSION, read tool output from a file, never follow a VERSION symlink
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2b5445f623
commit
8a656ced1a
5 files changed
+59
-15
No files matched your search
+2
-2
@@ -5,10 +5,10 @@
|
||||
**An unstamped PR can change a release output again (GSTA-96 on #3434).** Regenerating `agents-digest/gstack-AGENTS.md` after a generator change made the Version Gate classify the head as stamped (the digest is in `release_outputs`) and demand base + bump, while changing only `scripts/gen-agents-digest.ts` failed `test/agents-digest.test.ts`'s freshness check, so a digest rewording could not land before its slot.
|
||||
|
||||
### Fixed
|
||||
- **Version Gate under a train policy** (`lib/version-gate-train.ts`, `scripts/compare-pr-version.ts`): a changed `release_outputs` file no longer counts as a stamp when it is byte for byte what the policy's `release_tool` generates at the base's `VERSION`; the gate runs the head's tool in a throwaway worktree with the base's `VERSION` (the changed outputs removed first, a bare environment with `GSTACK_RESTAMP_VERSION` and `GSTACK_RESTAMP_STAGING=1` and none of the gate's tokens, no `node_modules` link, a 120 s limit) and compares byte for byte. `VERSION` and `package.json` version changes still classify the head as stamped, as does an output that differs (hand-edited, generated at a bumped version, or never written by the tool) or a policy with no `release_tool`; a tool that exits non-zero, is killed or cannot be staged is an error with the reason, never a pass; the staging worktree is removed and pruned on every path. `version-gate.yml` checks the head out with `persist-credentials: false`, since the head's tool now runs inside the gate. The freshness test stays strict and a stamp commit still changes only the stamp files, since the slot's restamp regenerates the output. `docs/ship-policy.md` states the rule under "Lane: report ready".
|
||||
- **Version Gate under a train policy** (`lib/version-gate-train.ts`, `scripts/compare-pr-version.ts`): a changed `release_outputs` file no longer counts as a stamp when it is byte for byte what the policy's `release_tool` generates at the head's merge base's `VERSION` (the head's own, since `VERSION` is unchanged on this path; the base tip may have stamped since, and CREW rule 14 says not to merge it); the gate runs the head's tool in a throwaway worktree with that `VERSION` (the changed outputs removed first, a bare environment with `GSTACK_RESTAMP_VERSION` and `GSTACK_RESTAMP_STAGING=1` and none of the gate's tokens, no `node_modules` link, a 120 s limit, output read from a file so a lingering child cannot hold the gate open) and compares byte for byte. `VERSION` and `package.json` version changes still classify the head as stamped, as does an output that differs (hand-edited, generated at a bumped version, or never written by the tool) or a policy with no `release_tool`; a tool that exits non-zero, is killed or cannot be staged is an error with the reason, never a pass; the staging worktree is removed and pruned on every path. `version-gate.yml` checks the head out with `persist-credentials: false`, since the head's tool now runs inside the gate. The freshness test stays strict and a stamp commit still changes only the stamp files, since the slot's restamp regenerates the output. `docs/ship-policy.md` states the rule under "Lane: report ready".
|
||||
|
||||
### Tests
|
||||
- `test/compare-pr-version.test.ts`: a generator change with its regenerated output at the base VERSION passes as unstamped (red without the fix); a hand-edited output, an output generated at the bumped version, an output the tool never writes, a VERSION bump without the output, a failing release tool and a policy without `release_tool` all still refuse; the tool sees no `GH_TOKEN`; no worktree is left behind.
|
||||
- `test/compare-pr-version.test.ts`: a generator change with its regenerated output at the merge base VERSION passes as unstamped (red without the fix), and stays unstamped after main stamps again; a tool whose background child outlives it does not hold the gate open; a hand-edited output, an output generated at the bumped version, an output the tool never writes, a VERSION bump without the output, a failing release tool and a policy without `release_tool` all still refuse; the tool sees no `GH_TOKEN`; no worktree is left behind.
|
||||
|
||||
## [1.91.226.0] - 2026-10-26
|
||||
|
||||
|
||||
+2
-1
@@ -171,7 +171,8 @@ SHIP_RECEIPT: pr=42 head=<head12> …
|
||||
CI's Version Gate (`scripts/compare-pr-version.ts`) accepts that head as
|
||||
unstamped when `VERSION` and `package.json`'s version match its merge base, the
|
||||
CHANGELOG change is confined to `[Unreleased]`, and any changed release output
|
||||
is byte for byte what `release_tool` generates at the base's `VERSION`, so a
|
||||
is byte for byte what `release_tool` generates at the head's merge base
|
||||
`VERSION` (the head's own, so a later stamp on main changes nothing), so a
|
||||
generator change ships with its regenerated output and the slot's restamp
|
||||
regenerates it again. A release output that differs from that (a hand edit, one generated at a
|
||||
bumped version, or one the tool never writes), a `VERSION` bump or a
|
||||
|
||||
+21
-11
@@ -36,9 +36,12 @@ export function bumpVersion(version: string, level: string): string | null {
|
||||
/**
|
||||
* An unstamped head may regenerate a release output (a generator change, for
|
||||
* example) as long as what it committed is exactly what the policy's
|
||||
* release_tool produces at the base's VERSION: the slot's restamp regenerates
|
||||
* it anyway. Runs the head's tool in a throwaway worktree with VERSION set to
|
||||
* the base's and compares each changed output byte for byte. A stamp (new
|
||||
* release_tool produces at its merge base's VERSION (the head's own, since
|
||||
* VERSION is unchanged on this path; the base tip may have stamped since):
|
||||
* the slot's restamp regenerates it anyway. Runs the head's tool in a
|
||||
* throwaway worktree with that VERSION and compares each changed output byte
|
||||
* for byte, reading the tool's output from a file so a lingering child cannot
|
||||
* hold the gate open. A stamp (new
|
||||
* VERSION inside the output) differs, so it still classifies as stamped, and
|
||||
* so does an output the tool never writes (it is removed before the run).
|
||||
* The tool is head code: it runs with a bare environment (no tokens), no
|
||||
@@ -47,20 +50,25 @@ export function bumpVersion(version: string, level: string): string | null {
|
||||
*/
|
||||
const TOOL_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'BUN_INSTALL', 'SystemRoot', 'TEMP', 'TMP'];
|
||||
function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersion: string; tool: string; outputs: string[] }): { kind: 'same' } | { kind: 'differs'; files: string[] } | { kind: 'error'; reason: string } {
|
||||
if (!o.baseVersion.trim()) return { kind: 'error', reason: 'the base has no VERSION to regenerate the release outputs at' };
|
||||
if (!o.baseVersion.trim()) return { kind: 'error', reason: 'the merge base has no VERSION to regenerate the release outputs at' };
|
||||
let staging: string;
|
||||
try { staging = fs.mkdtempSync(path.join(os.tmpdir(), 'version-gate-regen-')); } catch (e) { return { kind: 'error', reason: `cannot create a staging directory to regenerate the release outputs: ${e instanceof Error ? e.message : String(e)}` }; }
|
||||
try {
|
||||
const add = spawnSync('git', ['worktree', 'add', '--detach', '-q', staging, o.head], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000 });
|
||||
const add = spawnSync('git', ['worktree', 'add', '--detach', '-q', staging, o.head], { cwd: o.repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
if (add.status !== 0) return { kind: 'error', reason: `cannot stage ${o.head.slice(0, 12)} to regenerate its release outputs: ${(add.stderr || '').trim().slice(0, 200)}` };
|
||||
fs.rmSync(path.join(staging, 'VERSION'), { force: true });
|
||||
fs.writeFileSync(path.join(staging, 'VERSION'), o.baseVersion);
|
||||
for (const f of o.outputs) fs.rmSync(path.join(staging, f), { force: true });
|
||||
const env: NodeJS.ProcessEnv = { GSTACK_RESTAMP_VERSION: o.baseVersion.trim(), GSTACK_RESTAMP_STAGING: '1' };
|
||||
for (const key of TOOL_ENV_KEYS) if (process.env[key] !== undefined) env[key] = process.env[key];
|
||||
const run = spawnSync('sh', ['-c', o.tool], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
const logPath = path.join(os.tmpdir(), path.basename(staging) + '.log');
|
||||
const log = fs.openSync(logPath, 'w');
|
||||
let run: ReturnType<typeof spawnSync>;
|
||||
try { run = spawnSync('sh', ['-c', o.tool], { cwd: staging, stdio: ['ignore', log, log], timeout: 120_000, env }); } finally { fs.closeSync(log); }
|
||||
const output = (() => { try { return fs.readFileSync(logPath, 'utf8'); } catch { return ''; } finally { fs.rmSync(logPath, { force: true }); } })();
|
||||
if (run.status !== 0) {
|
||||
const how = run.status === null ? (run.error ? run.error.message : `was killed by ${run.signal ?? 'a signal'} (120s limit)`) : `exited ${run.status}`;
|
||||
return { kind: 'error', reason: `release_tool \`${o.tool}\` ${how} at ${o.head.slice(0, 12)} with the base's VERSION, so its release outputs cannot be told from a stamp: ${(run.stderr || run.stdout || '').trim().slice(0, 200)}` };
|
||||
return { kind: 'error', reason: `release_tool \`${o.tool}\` ${how} at ${o.head.slice(0, 12)} with its merge base's VERSION, so its release outputs cannot be told from a stamp: ${output.trim().slice(0, 200)}` };
|
||||
}
|
||||
const files = o.outputs.filter(f => {
|
||||
const committed = spawnSync('git', ['show', `${o.head}:${f}`], { cwd: o.repoRoot, timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
@@ -70,9 +78,9 @@ function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersion: str
|
||||
});
|
||||
return files.length ? { kind: 'differs', files } : { kind: 'same' };
|
||||
} finally {
|
||||
spawnSync('git', ['worktree', 'remove', '--force', staging], { cwd: o.repoRoot, timeout: 60_000 });
|
||||
spawnSync('git', ['worktree', 'remove', '--force', staging], { cwd: o.repoRoot, timeout: 30_000 });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
spawnSync('git', ['worktree', 'prune'], { cwd: o.repoRoot, timeout: 60_000 });
|
||||
spawnSync('git', ['worktree', 'prune'], { cwd: o.repoRoot, timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +100,15 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
|
||||
const touched: string[] = changed.filter(f => f === 'VERSION');
|
||||
if (npmVersion(mergeBase) !== npmVersion(o.head)) touched.push('package.json version');
|
||||
const outputs = changed.filter(f => matchesAny(releaseOutputs, f));
|
||||
const baseVersion = show(base, 'VERSION') ?? '';
|
||||
if (!touched.length && outputs.length && typeof policy.release_tool === 'string') {
|
||||
const regen = regeneratedAtBase({ repoRoot: o.repoRoot, head: o.head, baseVersion: show(base, 'VERSION') ?? '', tool: policy.release_tool, outputs });
|
||||
// the head's VERSION is its merge base's (unchanged on this path); the base tip may have stamped since
|
||||
const regen = regeneratedAtBase({ repoRoot: o.repoRoot, head: o.head, baseVersion: show(mergeBase, 'VERSION') ?? '', tool: policy.release_tool, outputs });
|
||||
if (regen.kind === 'error') return { kind: 'invalid', reason: regen.reason };
|
||||
if (regen.kind === 'differs') touched.push(...regen.files);
|
||||
} else touched.push(...outputs);
|
||||
if (touched.length) {
|
||||
const expected = bumpVersion(show(base, 'VERSION') ?? '', policy.bump ?? 'patch');
|
||||
const expected = bumpVersion(baseVersion, policy.bump ?? 'patch');
|
||||
return expected ? { kind: 'stamped', expected } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` };
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const prVersionEnv = (process.env.PR_VERSION ?? "").trim();
|
||||
if (process.env.BASE_REF && process.env.HEAD_SHA) {
|
||||
const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA });
|
||||
if (train.kind === "unstamped") {
|
||||
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the base's VERSION, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
|
||||
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (train.kind === "invalid") {
|
||||
|
||||
@@ -183,6 +183,39 @@ describe('version gate: unstamped heads under a train policy', () => {
|
||||
expect(r.git('worktree', 'list').trim().split('\n')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a regenerated output stays unstamped after main stamps again (the regeneration is at the merge base VERSION, not the base tip)', () => {
|
||||
const r = fixture();
|
||||
branch(r);
|
||||
gen(r, 'reworded body');
|
||||
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
|
||||
write(r, 'CHANGELOG.md', withUnreleased(r));
|
||||
const head = commit(r, 'reword the digest');
|
||||
r.git('checkout', '-q', 'main');
|
||||
write(r, 'VERSION', '1.2.4.0\n');
|
||||
write(r, 'generated/digest.md', '# digest v1.2.4.0\nbody\n');
|
||||
write(r, 'CHANGELOG.md', read(r, 'CHANGELOG.md').replace('# Changelog\n\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-02\n\n- another release\n\n'));
|
||||
commit(r, 'release 1.2.4.0');
|
||||
r.git('push', '-q', 'origin', 'main');
|
||||
r.git('fetch', '-q', 'origin');
|
||||
const res = run(r, head, '1.2.3.0');
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.out).toContain('is unstamped under the base');
|
||||
});
|
||||
|
||||
test('a tool whose background child outlives it does not hold the gate open', () => {
|
||||
const r = fixture();
|
||||
branch(r);
|
||||
write(r, 'gen.js', "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\nreworded body\\n');require('child_process').spawn('sleep',['20'],{stdio:'inherit'}).unref();\n");
|
||||
write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n');
|
||||
write(r, 'CHANGELOG.md', withUnreleased(r));
|
||||
const head = commit(r, 'tool with a lingering child');
|
||||
const started = Date.now();
|
||||
const res = run(r, head);
|
||||
expect(Date.now() - started).toBeLessThan(15_000);
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.out).toContain('is unstamped under the base');
|
||||
});
|
||||
|
||||
test('the tool runs without the gate\'s tokens, and an output the tool never writes still counts as a stamp', () => {
|
||||
const r = fixture();
|
||||
branch(r);
|
||||
|
||||
Reference in new issue
Block a user