diff --git a/bin/gstack-tree-receipt b/bin/gstack-tree-receipt index 9212a2e2c..337813199 100755 --- a/bin/gstack-tree-receipt +++ b/bin/gstack-tree-receipt @@ -34,6 +34,7 @@ const HELP = `gstack-tree-receipt — tree equality modulo stamps, per-gate reus --base base branch for the policy (default origin/HEAD, else main) --policy-from base|head reviewed base copy (default) or the unreviewed working tree --rerun-command what the fix clause names for a rerun + --allow-repo-commands rerun the policy's release_tool on the current head; a release output it regenerates byte for byte is a stamp --json the receipt as one JSON object ${exitTableHelp()}`; @@ -77,6 +78,7 @@ function main(argv: string[]): number { const receipt = buildReceipt({ repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label, predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`, + regenerate: argv.includes('--allow-repo-commands'), }); if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; } process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt)); diff --git a/changelog.d/gsta258-i-tree-receipt-release-tool.md b/changelog.d/gsta258-i-tree-receipt-release-tool.md new file mode 100644 index 000000000..53a9e599c --- /dev/null +++ b/changelog.d/gsta258-i-tree-receipt-release-tool.md @@ -0,0 +1 @@ +- **The tree receipt accepts a release output its release tool regenerates.** A `release_tool` that renders content (harness-evals folds `todos.d/` into TODOS.md) writes more than version rewrites, so `gstack-tree-receipt` called the stamp `changed` and a stamped train refused it. With `--allow-repo-commands`, and in `train build --allow-repo-commands`, a `release_outputs` file is now a stamp when the tool, rerun on the current head at its version, writes it byte for byte, the same rule the release PR and the Version Gate apply. Without the flag the tool never runs and the file stays `changed`. (GST-1176) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index de2584b46..c63f2956a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -2309,7 +2309,17 @@ stamp_paths stage moves (a tentative `*_KEY_VERSION` written as the next minor, #3512; a version older than the gated one, or of another width, is a hand edit), and the replacement the current one, so a dependency pin that happens to equal the old version is `changed`; `stamp_patterns` -templates count as stamp forms too). With `stamp_files` set, a file outside it +templates count as stamp forms too). A `release_outputs` file that is more than +those rewrites (a release tool that renders content, GST-1176), or that the +stamp adds, is a stamp, rule +`release_tool`, when the policy's `release_tool`, rerun on the current head at +its version, writes it byte for byte: the release PR's and the Version Gate's +rule. The tool is repo code, so the receipt reruns it only with +`--allow-repo-commands`, and `train build --allow-repo-commands` applies it to +each stamped prefix; without the flag the file stays `changed`. The rerun is +the Version Gate's: the stamped head (CHANGELOG folded, fragments deleted), a +bare environment and a 120-second limit, so an output that depends on anything +else fails closed as `changed`. With `stamp_files` set, a file outside it is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever its diff. Any other byte is a change. A diff git cannot produce between the two heads fails closed with `EVIDENCE_IDENTITY_UNKNOWN` and git's reason; it never diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 340a98568..d30bb7acf 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. */ export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { - const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` }); + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1`, regenerate: o.allowRepoCommands }); if ('error' in r) return r.error; if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; const cl = o.policy.changelog; diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 0611d6074..88868bff1 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -20,6 +20,7 @@ import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } f import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only'; import type { ResultCodeName } from './result-codes'; import { matchesAny, type ShipPolicy } from './ship-policy'; +import { regeneratedAtBase } from './version-gate-train'; export type HunkClass = 'stamp' | 'changed'; export interface FileVerdict { path: string; rule: string; verdict: HunkClass; residual?: string; line?: number } @@ -143,6 +144,8 @@ export interface ReceiptOptions { baseRef?: string; predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null }; bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string; + /** Rerun the policy's release_tool (repo code: only under --allow-repo-commands) on the current head at its version; a release output it regenerates byte for byte is a stamp (GST-1176). */ + regenerate?: boolean; } export function fromGateAhead(file: string): GateAheadRecord { @@ -181,7 +184,14 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' }; } const diffText = d.stdout.replace(/\n$/, ''); - const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined); + const classified = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined); + const rendered = o.regenerate && o.policy.release_tool && newV ? classified.filter(f => f.verdict === 'changed' && (f.rule === 'release_outputs' || (f.rule === 'path' && f.residual === 'added' && matchesAny(o.policy.release_outputs, f.path) && (!o.policy.stamp_files.length || matchesAny(o.policy.stamp_files, f.path))))).map(f => f.path) : []; + const regen = rendered.length ? regeneratedAtBase({ repoRoot: o.repoRoot, head: currentHead, baseVersion: blob(o.repoRoot, currentHead, 'VERSION') ?? newV!, tool: o.policy.release_tool!, outputs: rendered }) : null; + const files = classified.map((f): FileVerdict => { + if (!regen || !rendered.includes(f.path)) return f; + if (regen.kind === 'same' || (regen.kind === 'differs' && !regen.files.includes(f.path))) return { path: f.path, rule: 'release_tool', verdict: 'stamp' }; + return { ...f, residual: `${f.residual}; ${regen.kind === 'error' ? regen.reason : `not what release_tool generates at ${newV}`}` }; + }); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/test/tree-receipt-release-tool.test.ts b/test/tree-receipt-release-tool.test.ts new file mode 100644 index 000000000..5469bec75 --- /dev/null +++ b/test/tree-receipt-release-tool.test.ts @@ -0,0 +1,103 @@ +/** + * GST-1176 (HARN-7, harness-evals): a release_tool that renders content, not just the version, writes release + * outputs whose hunks are more than version rewrites. The tree receipt classified them `changed`, so a stamp + * over an unstamped merge was never same-modulo-stamps. With --allow-repo-commands the receipt accepts a release + * output whose bytes equal release_tool's rerun on the current head at its version, the Version Gate's rule. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadPolicyFromHead } from '../lib/ship-policy'; +import { stampMismatch } from '../lib/ship-queue-train-stamp'; +import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const FOLD = "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();const items=fs.readdirSync('todos.d').filter(f=>f.endsWith('.md')).sort().map(f=>fs.readFileSync('todos.d/'+f,'utf8'));fs.writeFileSync('TODOS.md','# TODOS v'+v+'\\n'+items.join(''));\n"; +const POLICY = { ...BASE_POLICY, release_tool: 'node fold.js', release_outputs: ['TODOS.md'] }; + +/** HARN-7's shape: M is the unstamped merge of a PR that adds only a todos.d/ file; S stamps it with the tool's output. */ +function harn7(policy: Record = POLICY): { repo: FixtureRepo; merged: string; stamped: string } { + const repo = makeFixtureRepo({ prefix: 'tree-receipt-release-tool-', policy }); + repos.push(repo); + const fold = () => expect(spawnSync('node', ['fold.js'], { cwd: repo.work, timeout: 30_000 }).status).toBe(0); + fs.mkdirSync(path.join(repo.work, 'todos.d')); + fs.writeFileSync(path.join(repo.work, 'fold.js'), FOLD); + fs.writeFileSync(path.join(repo.work, 'todos.d', 'base.md'), '- base todo\n'); + fold(); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'todos fold'); + fs.writeFileSync(path.join(repo.work, 'todos.d', 'new.md'), '- new todo\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'merge: a PR that adds only a todos.d file'); + const merged = repo.git('rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + fold(); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + return { repo, merged, stamped: repo.git('rev-parse', 'HEAD') }; +} + +const receipt = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-tree-receipt', ['--base', 'main', ...args]); + +describe('tree receipt: a release output release_tool regenerates (GST-1176)', () => { + test('a rendered release output is a stamp when release_tool reruns to the same bytes, and only with --allow-repo-commands', () => { + const { repo, merged, stamped } = harn7(); + expect(repo.git('show', `${stamped}:TODOS.md`)).toBe('# TODOS v1.2.4.0\n- base todo\n- new todo'); + expect(receipt(repo, ['--gated', merged, '--current', stamped]).out).toMatch(/^TREE: changed \(TODOS\.md\)$/m); + const r = receipt(repo, ['--gated', merged, '--current', stamped, '--allow-repo-commands']); + expect(r.out).toMatch(/^ stamp VERSION \(version-file\)$/m); + expect(r.out).toMatch(/^ stamp TODOS\.md \(release_tool\)$/m); + expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m); + }); + + test('a release output the tool would not write stays changed, with the regeneration named', () => { + const { repo, merged } = harn7(); + fs.appendFileSync(path.join(repo.work, 'TODOS.md'), '- a hand-written line\n'); + repo.git('commit', '-qam', 'hand edit'); + const j = JSON.parse(receipt(repo, ['--gated', merged, '--current', 'HEAD', '--allow-repo-commands', '--json']).out); + expect(j.tree).toBe('changed'); + const todos = j.files.find((f: { path: string }) => f.path === 'TODOS.md'); + expect(todos).toMatchObject({ verdict: 'changed', rule: 'release_outputs' }); + expect(todos.residual).toContain('release_tool'); + }); + + test('a release output the tool adds for the first time is a stamp when the rerun writes the same bytes', () => { + const repo = makeFixtureRepo({ prefix: 'tree-receipt-release-tool-', policy: POLICY }); + repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'todos.d')); + fs.writeFileSync(path.join(repo.work, 'fold.js'), FOLD); + fs.writeFileSync(path.join(repo.work, 'todos.d', 'new.md'), '- new todo\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'merge: the first todos.d file, TODOS.md not rendered yet'); + const merged = repo.git('rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + expect(spawnSync('node', ['fold.js'], { cwd: repo.work, timeout: 30_000 }).status).toBe(0); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stamp 1.2.4.0'); + expect(receipt(repo, ['--gated', merged, '--current', 'HEAD']).out).toMatch(/^ changed TODOS\.md \(path: added\)$/m); + const r = receipt(repo, ['--gated', merged, '--current', 'HEAD', '--allow-repo-commands']); + expect(r.out).toMatch(/^ stamp TODOS\.md \(release_tool\)$/m); + expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m); + }); + + test('a release_tool that fails leaves the release output changed, with its exit named', () => { + const { repo, merged, stamped } = harn7({ ...POLICY, release_tool: 'node fold.js && exit 3' }); + const j = JSON.parse(receipt(repo, ['--gated', merged, '--current', stamped, '--allow-repo-commands', '--json']).out); + expect(j.tree).toBe('changed'); + const todos = j.files.find((f: { path: string }) => f.path === 'TODOS.md'); + expect(todos).toMatchObject({ verdict: 'changed', rule: 'release_outputs' }); + expect(todos.residual).toContain('exited 3'); + }); + + test('a stamped train prefix over the merge passes stampMismatch only when the train runs repo commands', () => { + const { repo, merged, stamped } = harn7(); + const opts = { repoRoot: repo.work, base: 'main', policy: loadPolicyFromHead(repo.work).policy, policyLabel: 'head', baseSha: merged, slots: [], plain: [], now: new Date() }; + const plainTree = repo.git('rev-parse', `${merged}^{tree}`); + const prior = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]])); + Object.assign(process.env, repo.env); + try { + expect(stampMismatch({ ...opts, allowRepoCommands: true }, merged, stamped, plainTree)).toBeNull(); + expect(stampMismatch({ ...opts, allowRepoCommands: false }, merged, stamped, plainTree)).toContain('TODOS.md'); + } finally { + for (const [k, v] of Object.entries(prior)) if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + }); +});