diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 5f8157d96..32b36a04f 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -26,6 +26,9 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + # The head's release_tool runs inside the gate (lib/version-gate-train.ts); + # no token may sit in the checkout it stages. The base fetch below is public. + persist-credentials: false # The gate's own code comes from the PR's merge commit (github.sha): a PR # that edits the gate runs its edit, and an open PR picks up a gate fix diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e76a7db2..56be910b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.237.0] - 2026-10-26 + +**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 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 enforced with SIGKILL so a tool that ignores TERM cannot hold the gate open, output read from a file, first 4 KB, so a lingering child cannot either) 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 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, nor does one that traps TERM (`regeneratedAtBase` is exported for that test); 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.236.0] - 2026-10-26 **The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it. diff --git a/VERSION b/VERSION index 8ee33a151..e5ae7d3bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.236.0 +1.91.237.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 825fcd404..ccda422c1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.236.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.237.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, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index df66dad81..bb5d1af9e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -168,6 +168,19 @@ gate: command="" full_e2e=no gate-ahead=none SHIP_RECEIPT: pr=42 head= … ``` +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 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 +`package.json` version change classifies the head as stamped, and it must then +carry base + bump. The tool runs as head code in a throwaway worktree with a +bare environment and no tokens; one that fails at the head is an error, never +a pass. + It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD is not the PR's pushed head, CI at that exact head is not green, or the pregate result is not a current pass, and (`QUEUE_RECEIPT_MISSING`, diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 3574542f9..e809d9408 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -8,6 +8,9 @@ * stamped one to the version the restamp allocates (base + the policy bump). */ import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { parseChangelog } from './changelog-check'; import { matchesAny } from './ship-policy'; @@ -30,6 +33,62 @@ export function bumpVersion(version: string, level: string): string | null { return v.map((n, i) => (i < at ? n : i === at ? n + 1 : 0)).join('.'); } +/** + * 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 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 (first 4 KB) so a lingering + * child cannot hold the gate open, and killing a tool that outlives the limit + * with SIGKILL so one that ignores TERM cannot either. 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 + * node_modules link, and a short timeout, and its outputs are read only from + * the staging worktree. + */ +const TOOL_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'BUN_INSTALL', 'SystemRoot', 'TEMP', 'TMP']; +export function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersion: string; tool: string; outputs: string[]; timeoutMs?: number }): { kind: 'same' } | { kind: 'differs'; files: string[] } | { kind: 'error'; reason: string } { + const timeoutMs = o.timeoutMs ?? 120_000; + 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: 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 logPath = path.join(os.tmpdir(), path.basename(staging) + '.log'); + const log = fs.openSync(logPath, 'w'); + let run: ReturnType; + try { run = spawnSync('sh', ['-c', o.tool], { cwd: staging, stdio: ['ignore', log, log], timeout: timeoutMs, killSignal: 'SIGKILL', env }); } finally { fs.closeSync(log); } + const output = (() => { + try { const fd = fs.openSync(logPath, 'r'); try { const buf = Buffer.alloc(4096); return buf.subarray(0, fs.readSync(fd, buf)).toString('utf8'); } finally { fs.closeSync(fd); } } catch { return ''; } finally { fs.rmSync(logPath, { force: true }); } + })(); + if (run.status !== 0) { + const timedOut = run.signal !== null || (run.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT'; + const how = run.status !== null ? `exited ${run.status}` : timedOut ? `was killed at the ${Math.round(timeoutMs / 1000)}s limit` : (run.error?.message ?? 'did not exit'); + 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 }); + let generated: Buffer | null = null; + try { generated = fs.readFileSync(path.join(staging, f)); } catch { generated = null; } + return committed.status !== 0 || generated === null || !committed.stdout.equals(generated); + }); + return files.length ? { kind: 'differs', files } : { kind: 'same' }; + } finally { + 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: 30_000 }); + } +} + export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string }): TrainHead { const base = `origin/${o.baseRef.replace(/^origin\//, '')}`; const show = (rev: string, file: string) => git(o.repoRoot, ['show', `${rev}:${file}`]); @@ -42,10 +101,19 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } }; const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean); const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : []; - const touched = changed.filter(f => f === 'VERSION' || matchesAny(releaseOutputs, f)); + // touched: what marks the head as stamped (the labels only feed the length check below) + 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') { + // 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` }; } diff --git a/package.json b/package.json index 7378a5753..60a0744f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.236", + "version": "1.91.237", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/compare-pr-version.ts b/scripts/compare-pr-version.ts index 6e6c324b4..281736f61 100644 --- a/scripts/compare-pr-version.ts +++ b/scripts/compare-pr-version.ts @@ -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, package.json and the release outputs match its merge base, 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") { diff --git a/test/compare-pr-version.test.ts b/test/compare-pr-version.test.ts index 8a38963db..eb4a23ff4 100644 --- a/test/compare-pr-version.test.ts +++ b/test/compare-pr-version.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; -import { bumpVersion } from '../lib/version-gate-train'; +import { bumpVersion, regeneratedAtBase } from '../lib/version-gate-train'; const ROOT = path.resolve(import.meta.dir, '..'); const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts'); @@ -168,6 +168,136 @@ describe('version gate: unstamped heads under a train policy', () => { } }); + describe('a release output regenerated at the base VERSION is not a stamp (GSTA-96: an unstamped PR could not reword the agents digest)', () => { + const gen = (r: FixtureRepo, body: string) => write(r, 'gen.js', `const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\n${body}\\n');\n`); + + test('a generator change with its regenerated output at the base VERSION passes as unstamped', () => { + 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 res = run(r, commit(r, 'reword the digest')); + expect(res.code).toBe(0); + expect(res.out).toContain('is unstamped under the base'); + 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('a tool that ignores TERM is killed at the limit and reported, never waited for', () => { + const r = fixture(); + branch(r); + write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n'); + const head = commit(r, 'output without a working tool'); + const started = Date.now(); + const res = regeneratedAtBase({ repoRoot: r.work, head, baseVersion: '1.2.3.0\n', tool: "trap '' TERM; echo stubborn; sleep 30", outputs: ['generated/digest.md'], timeoutMs: 2_000 }); + expect(Date.now() - started).toBeLessThan(10_000); + expect(res.kind).toBe('error'); + expect((res as { reason: string }).reason).toContain('was killed at the 2s limit'); + expect((res as { reason: string }).reason).toContain('stubborn'); + expect(r.git('worktree', 'list').trim().split('\n')).toHaveLength(1); + }); + + 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); + 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'+(process.env.GH_TOKEN??''));\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 that would echo a token'); + const next = path.join(r.root, 'next.json'); + fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed: [] })); + const leak = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3320'], { + cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: '1.2.3.0', BASE_REF: 'main', HEAD_SHA: head, GH_TOKEN: 'ghs_secret' }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe', + }); + expect(leak.exitCode).toBe(0); + expect(leak.stdout.toString()).toContain('is unstamped under the base'); + write(r, 'generated/extra.md', 'not generated\n'); + const res = run(r, commit(r, 'second output by hand')); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + }); + + test('an output that is not what the generator produces at the base VERSION still counts as a stamp', () => { + for (const edit of [ + (r: FixtureRepo) => write(r, 'generated/digest.md', '# digest v1.2.3.0\nhand-edited body\n'), + (r: FixtureRepo) => { gen(r, 'reworded body'); write(r, 'generated/digest.md', '# digest v1.2.4.0\nreworded body\n'); }, + ]) { + const r = fixture(); + branch(r); + write(r, 'CHANGELOG.md', withUnreleased(r)); + edit(r); + const res = run(r, commit(r, 'not a regeneration')); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + } + }); + + test('a VERSION bump without the regenerated output is a stamp, never an unstamped head', () => { + const r = fixture(); + branch(r); + write(r, 'VERSION', '1.2.4.0\n'); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const res = run(r, commit(r, 'bump without digest'), '1.2.3.0'); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + expect(res.out).not.toContain('is unstamped under the base'); + }); + + test('a release tool that fails at the head is an error, not a pass', () => { + const r = fixture(); + branch(r); + write(r, 'gen.js', 'process.stderr.write("gen broke\\n");process.exit(3);\n'); + write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n'); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const res = run(r, commit(r, 'broken generator')); + expect(res.code).toBe(1); + expect(res.out).toContain('release_tool `node gen.js` exited 3'); + expect(res.out).toContain('gen broke'); + }); + + test('without a release_tool a changed output keeps counting as a stamp', () => { + const r = fixture({ ...BASE_POLICY, release_tool: null, train: TRAIN }); + branch(r); + write(r, 'generated/digest.md', '# digest v1.2.3.0\nreworded body\n'); + write(r, 'CHANGELOG.md', withUnreleased(r)); + const res = run(r, commit(r, 'no tool')); + expect(res.code).toBe(1); + expect(res.out).toContain('must carry v1.2.4.0'); + }); + }); test('negative controls: CHANGELOG edits outside [Unreleased] fail an unstamped head', () => { const cases: Array<(r: FixtureRepo) => string> = [ r => withUnreleased(r).replace('- older', '- older, rewritten'),