diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e1e7ef715..10bc6eb9c 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -43,6 +43,10 @@ prints this table. JSON carries no comments, so examples live in | `stamp_sensitive_gates` | evidence labels | `[]` | Gates that read VERSION or generated bytes (gstack's own `tests` lane does, through the agents-digest freshness test). The tree receipt never marks them reusable. | | `pr_count` | `{ default_pr_count, split_requires_owner }` | `{1, true}` | The PR-count row in plan reviews. | | `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. | +| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. | +| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, ``); matched literally at a version boundary, never as a regex. The restamp writer and the tree receipt use them beside the built-in forms. | +| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | +| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | Containment, checked by `validate` and again at write time: paths and globs are repo-relative with no `..` segment, under 256 characters, at most 64 per list, @@ -161,10 +165,11 @@ or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact Stages, each journaled in `.gstack/tmp/restamp-journal.json`: -1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles). +0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command). +1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, above its predecessor's entry. Duplicate headings (`CHANGELOG_DUPLICATE_HEADING`) and a top entry that is not `` (`CHANGELOG_TOP_MISMATCH`) are errors. -4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites within the declared globs. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was `, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. +4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was `, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. 5. **mirror**: tier 2; declared and validated, reported as not run. 6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage). @@ -199,6 +204,26 @@ implementation (`bun run release:restamp` still runs the PR's package script), so the receipt names the policy revision, the command, its working directory and the trust scope (`policy: base origin/main@` or `head (unreviewed)`). + +### `restamp_command`: the repo owns the stamp + +A repo whose own tool knows its stamp locations sets `restamp_command`. +`gstack-restamp` runs it as the first stage inside its staging worktree, a +clean checkout of HEAD, with the allocated version as the last argument and in +`GSTACK_RESTAMP_VERSION`. The caller's HEAD, index and worktree must be clean +first (`RESTAMP_CONFLICT`, nothing written); the generic path keeps its +working-tree fingerprint seed. Anything the command stages or commits in the +staging worktree is folded back into plain edits; then output confinement +applies as for the release tool (`RESTAMP_OUTPUT_OUTSIDE_ALLOWED`), the +version file must hold the target version (`RESTAMP_VERSION_SOURCE`), and a +non-zero exit is `RESTAMP_RELEASE_TOOL_FAILED`. In every failure nothing +reaches the branch. gstack's version writer is skipped; the CHANGELOG and +`stamp_paths` stages still run (a no-op when the tool already did the work). +Read from the base branch and run only with `--allow-repo-commands`, like +`release_tool`. A tool that fetches, merges the base branch or needs a version +of its own choosing cannot run here; give it a mode that takes the version and +writes only stamps. + ## The tree receipt @@ -225,7 +250,10 @@ the CHANGELOG (a heading move or re-heading, compared as an order-independent multiset of entries), `stamp_paths` and `release_outputs` (every removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite with X the gated version and the replacement the current one, so a dependency pin -that happens to equal the old version is `changed`). Any other byte is a change. +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 +is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever +its diff. Any other byte is a change. Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always `rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps diff --git a/lib/restamp.ts b/lib/restamp.ts index 4f03bf22c..5c9800263 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -6,7 +6,10 @@ * (bin/gstack-version-bump write, composed, never grown), the policy's * release_tool under its contract (enumerated allowed outputs, pre/post * images), the CHANGELOG re-heading (lib/changelog-check.ts), the exact - * `(was X)` / `since: X` / `vX` rewrites inside `stamp_paths`. The branch is + * `(was X)` / `since: X` / `vX` rewrites inside `stamp_paths`. A policy + * `restamp_command` (the repo's own restamp) runs first instead of the + * version writer, on a clean checkout of HEAD (the caller's tree must be + * clean), under the same output confinement. The branch is * written only after every stage validated, file by file against the * fingerprinted preimage, with every write journaled in * .gstack/tmp/restamp-journal.json so a failure after any stage rolls back @@ -24,7 +27,7 @@ import * as path from 'node:path'; import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check'; import { type ResultCodeName } from './result-codes'; import { - SHIP_POLICY_PATH, assertInsideRoot, declaredCommands, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, matchesAny, + SHIP_POLICY_PATH, VERSION_SLOT, assertInsideRoot, declaredCommands, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, matchesAny, type LoadedPolicy, type QueueMode, type ShipPolicy, } from './ship-policy'; import { bumpVersion, cmpVersion, extractVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source'; @@ -34,7 +37,7 @@ export const GATE_AHEAD_REL = '.gstack/tmp/gate-ahead.json'; const BIN_DIR = path.resolve(import.meta.dir, '..', 'bin'); const NPM_LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json']; -export type StageName = 'version' | 'release_tool' | 'changelog' | 'stamp_paths' | 'mirror' | 'apply'; +export type StageName = 'restamp_command' | 'version' | 'release_tool' | 'changelog' | 'stamp_paths' | 'mirror' | 'apply'; export interface JournalWrite { path: string; pre_sha256: string | null; post_sha256: string | null; applied: boolean } export interface StageRecord { name: StageName; status: 'pending' | 'done' | 'skipped' | 'failed'; detail?: string; writes?: JournalWrite[] } export interface Journal { @@ -197,7 +200,7 @@ function allowedOutput(plan: Plan, root: string, rel: string): boolean { const p = plan.policy; if (versionFiles(root, plan.versionRel).includes(rel)) return true; if (p.changelog && rel === p.changelog) return true; - return matchesAny(p.stamp_paths, rel) || matchesAny(p.release_outputs, rel); + return matchesAny(p.stamp_paths, rel) || matchesAny(p.stamp_files, rel) || matchesAny(p.release_outputs, rel); } interface Hunk { removed: string[]; added: string[] } @@ -272,13 +275,19 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string) return 'version' in v ? v.version : fallback; } -/** Exact `(was X)`, `since: X`, `vX` rewrites; anything else in the file stays. */ -export function rewriteStamps(text: string, oldVersion: string, newVersion: string): { text: string; count: number } { - const esc = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +/** + * Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal + * `stamp_patterns` templates (one `{version}` slot, matched at a version + * boundary); anything else in the file stays. + */ +export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } { + const esc = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const v = esc(oldVersion); const patterns: Array<[RegExp, string]> = [ - [new RegExp(`\\(was ${esc}\\)`, 'g'), `(was ${newVersion})`], - [new RegExp(`(since:\\s*)${esc}(?![0-9.])`, 'g'), `$1${newVersion}`], - [new RegExp(`(? { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${esc(pre)}(?, s: StageRecord): void { + const seeded = git(staging, ['write-tree']).out; + const r = spawnSync('sh', ['-c', `${command} "$1"`, 'sh', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 600_000, env: { ...env, GSTACK_RESTAMP_VERSION: plan.newVersion } }); + git(staging, ['read-tree', seeded]); + if (r.status !== 0) { s.status = 'failed'; s.detail = `${r.stderr ?? ''}${r.stdout ?? ''}`.trim().slice(0, 300); throw new Refusal('RESTAMP_RELEASE_TOOL_FAILED', `restamp_command exited ${r.status}: ${s.detail}; nothing written to the branch`); } + const outside = changedPaths(staging).filter(p => !before.has(p) && !allowedOutput(plan, staging, p)); + if (outside.length) { s.status = 'failed'; s.detail = outside.join(', '); throw new Refusal('RESTAMP_OUTPUT_OUTSIDE_ALLOWED', `restamp_command wrote outside the allowed set: ${outside.join(', ')}; nothing written to the branch`); } + const file = path.join(staging, plan.versionRel); + const got = fs.existsSync(file) ? extractVersion(fs.readFileSync(file, 'utf8'), plan.versionRel) : ''; + if (got !== plan.newVersion) { s.status = 'failed'; s.detail = `${plan.versionRel}=${got || '(missing)'}`; throw new Refusal('RESTAMP_VERSION_SOURCE', `restamp_command left ${plan.versionRel} at ${got || '(missing)'}, expected ${plan.newVersion}; nothing written`); } + s.status = 'done'; s.detail = `${command} ${plan.newVersion}`; +} + function runStages(o: RestampOptions, plan: Plan, staging: string, since: string, stages: StageRecord[]): void { const stage = (name: StageName): StageRecord => { const s: StageRecord = { name, status: 'pending' }; stages.push(s); return s; }; const injected = (name: string) => { if (o.failAfter === name) throw new Error(`injected failure after ${name}`); }; const env = { ...(o.env ?? process.env), GSTACK_RESTAMP_STAGING: '1' }; const before = new Set(changedPaths(staging)); + const command = plan.policy.restamp_command; + if (command) runRestampCommand(command, plan, staging, env, before, stage('restamp_command')); const v = stage('version'); - const w = spawnSync('bun', [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env }); - if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); } - v.status = 'done'; v.detail = `wrote ${plan.newVersion}`; + if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; } + else { + const w = spawnSync('bun', [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env }); + if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); } + v.status = 'done'; v.detail = `wrote ${plan.newVersion}`; + } injected('version'); const rt = stage('release_tool'); @@ -342,9 +376,11 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string injected('changelog'); const sp = stage('stamp_paths'); - if (plan.policy.stamp_paths.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; } + const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files]; + const templates = plan.policy.stamp_patterns; + if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; } else { - const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(plan.policy.stamp_paths, f)); + const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f)); for (const rel of files) { const why = assertInsideRoot(staging, rel); if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`); @@ -362,8 +398,8 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string let count = 0; const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => { let text = line; - for (const old of explicit) { const r = rewriteStamps(text, old, plan.newVersion); text = r.text; count += r.count; } - if (own?.has(line)) for (const old of tentative) { const r = rewriteStamps(text, old, plan.newVersion); text = r.text; count += r.count; } + for (const old of explicit) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; } + if (own?.has(line)) for (const old of tentative) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; } return text; }).join('\n'); if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; } @@ -468,6 +504,10 @@ export function restamp(o: RestampOptions): RestampResult { } if (o.after) return gateAhead(o, plan, stages, common); + if (plan.policy.restamp_command) { + const dirty = git(o.repoRoot, ['status', '--porcelain', '--untracked-files=all']).out.split('\n').filter(Boolean).map(l => l.slice(3)); + if (dirty.length) throw new Refusal('RESTAMP_CONFLICT', `restamp_command needs a clean HEAD, index and worktree (it runs on a clean checkout of HEAD); commit or remove: ${dirty.slice(0, 10).join(', ')}${dirty.length > 10 ? ' …' : ''}; nothing written`); + } const wt = spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000 }); const wtree = (wt.stdout ?? '').trim(); diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 1765ac23f..d34c8a07d 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -50,6 +50,14 @@ export interface ShipPolicy { pr_count: { default_pr_count: number; split_requires_owner: boolean }; /** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */ maintainers: string[]; + /** The repo's stamp rule: when non-empty, only these files can be stamps (the allowlist applies before every stamp rule). */ + stamp_files: string[]; + /** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */ + stamp_patterns: string[]; + /** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */ + restamp_command: string | null; + /** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */ + migrations: { dir: string; id: string } | null; } export const DEFAULT_POLICY: ShipPolicy = { @@ -65,6 +73,10 @@ export const DEFAULT_POLICY: ShipPolicy = { stamp_sensitive_gates: [], pr_count: { default_pr_count: 1, split_requires_owner: true }, maintainers: [], + stamp_files: [], + stamp_patterns: [], + restamp_command: null, + migrations: null, }; export type PolicyKey = Exclude; @@ -84,6 +96,10 @@ export const POLICY_EXPLANATIONS: Record = { stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.', pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.', maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.', + stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.', + stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.', + restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.', + migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.', }; export interface PolicyError { code: ResultCodeName; path: string; message: string } @@ -93,6 +109,9 @@ const MAX_PATTERN = 256; const MAX_REGEX = 512; const MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; +const MAX_TEMPLATES = 16; +const MAX_TEMPLATE = 128; +export const VERSION_SLOT = '{version}'; function hasDotDot(p: string): boolean { return p.split(/[\\/]+/).some(seg => seg === '..'); @@ -149,6 +168,30 @@ function globError(g: string): string | null { return null; } +function templateErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } + if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); + value.forEach((t, i) => { + const at = `stamp_patterns[${i}]`; + if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); + else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); + }); +} + +function migrationErrors(value: unknown, out: PolicyError[]): void { + const m = value as Record | null; + if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; } + for (const k of Object.keys(m)) if (k !== 'dir' && k !== 'id') out.push({ code: 'POLICY_INVALID', path: `migrations.${k}`, message: 'unknown key' }); + const dirWhy = typeof m.dir === 'string' ? containedRelative(m.dir) : 'must be a repo-relative directory'; + if (dirWhy) out.push({ code: 'POLICY_CONTAINMENT', path: 'migrations.dir', message: dirWhy }); + if (typeof m.id !== 'string' || !m.id || m.id.length > MAX_PATTERN) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `must be a regex of 1-${MAX_PATTERN} characters` }); return; } + try { + const groups = new RegExp(`${m.id}|`).exec('')!.length - 1; + if (groups !== 1) out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `needs exactly one capture group for the migration number (has ${groups})` }); + } catch (e: any) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `not a valid regex: ${e.message}` }); } +} + /** Schema and containment check of a parsed policy object. Unknown keys are errors (a typo must not silently mean "default"). */ export function validatePolicy(raw: unknown): PolicyError[] { const errors: PolicyError[] = []; @@ -170,6 +213,14 @@ export function validatePolicy(raw: unknown): PolicyError[] { else if (/[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: 'contains a newline or NUL' }); } if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError); + if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError); + if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors); + if ('restamp_command' in obj && obj.restamp_command !== null) { + const t = obj.restamp_command; + if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' }); + else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'restamp_command', message: `must be one line under ${MAX_COMMAND} characters` }); + } + if ('migrations' in obj && obj.migrations !== null) migrationErrors(obj.migrations, errors); if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError); for (const key of ['changelog', 'mirror'] as const) { if (!(key in obj) || obj[key] === null) continue; @@ -294,6 +345,7 @@ export function declaredCommands(policy: ShipPolicy): Array<{ key: string; comma const out: Array<{ key: string; command: string; cwd: string }> = []; if (policy.release_tool) out.push({ key: 'release_tool', command: policy.release_tool, cwd: '.' }); if (policy.release_tool && policy.mirror) out.push({ key: 'mirror.release_tool', command: policy.release_tool, cwd: policy.mirror }); + if (policy.restamp_command) out.push({ key: 'restamp_command', command: policy.restamp_command, cwd: '.' }); return out; } diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index f549aabfe..edfb3a5f1 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -4,7 +4,9 @@ * tree: its fingerprint can name objects unreachable once the gate VM is * gone), classifies the diff hunk by hunk with per-file-type rules, decides * per gate whether its evidence is reusable, and refuses `eligible` when the - * recorded predecessor head moved. Tree identity alone is not gate evidence: + * recorded predecessor head moved. The repo's stamp rule wins: with + * `stamp_files` set, only those files can be stamps, and `stamp_patterns` + * (literal `{version}` templates) join the built-in stamp forms. Tree identity alone is not gate evidence: * the receipt also compares the execution identity the gate ran under (from * a `gstack-evidence bundle`) with the current one. */ @@ -48,13 +50,13 @@ function blob(cwd: string, treeish: string, rel: string): string | null { } /** Every removed/added pair differs only by the exact stamp rewrites (old -> new). */ -function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string): string | null { +function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): string | null { for (const h of hunks) { if (h.removed.length !== h.added.length) return `${h.removed.length} line(s) removed, ${h.added.length} added`; for (let i = 0; i < h.removed.length; i++) { const r = h.removed[i]!; const a = h.added[i]!; - const rewritten = rewriteStamps(r, oldV, newV); + const rewritten = rewriteStamps(r, oldV, newV, templates); if (rewritten.count === 0 || rewritten.text !== a) return `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`; } } @@ -71,6 +73,7 @@ function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: str const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' }); if (f.binary) return changed('binary', 'binary content differs'); if (f.status !== 'M') return changed('path', `${f.status === 'A' ? 'added' : f.status === 'D' ? 'deleted' : 'renamed'}`); + if (policy.stamp_files.length && !matchesAny(policy.stamp_files, f.path)) return changed('stamp_files', 'not in the repo\'s stamp_files allowlist'); const before = blob(repoRoot, gated, f.path); const after = blob(repoRoot, current, f.path); const base = path.basename(f.path); @@ -93,9 +96,9 @@ function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: str if (policy.changelog && f.path === policy.changelog) { return before !== null && after !== null && normalizeChangelog(before) === normalizeChangelog(after) ? stamp('changelog-heading') : changed('changelog', 'entry text changed (only a heading move or re-heading is a stamp)'); } - if (oldV && newV && oldV !== newV && (matchesAny(policy.stamp_paths, f.path) || matchesAny(policy.release_outputs, f.path))) { - const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : 'release_outputs'; - const why = stampOnlyHunks(f.hunks, oldV, newV); + const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null; + if (oldV && newV && oldV !== newV && rule) { + const why = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns); return why ? changed(rule, why) : stamp(rule); } return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`); diff --git a/test/restamp-command-boundary.test.ts b/test/restamp-command-boundary.test.ts index ca26b5ac2..cd5d672d4 100644 --- a/test/restamp-command-boundary.test.ts +++ b/test/restamp-command-boundary.test.ts @@ -134,3 +134,72 @@ describe('release-tool contract', () => { expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1); }); }); + +describe('restamp_command (field wave CD3): the repo owns the stamp inside the staging worktree', () => { + const RESTAMP_JS = [ + "const fs = require('fs'); const { execSync } = require('child_process');", + "if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) { console.error('FAIL: the working tree is not clean'); process.exit(1); }", + "const v = process.argv[2];", + "if (!v || v !== process.env.GSTACK_RESTAMP_VERSION) { console.error('version argument and GSTACK_RESTAMP_VERSION disagree'); process.exit(2); }", + "if (process.env.FIXTURE_FAIL) process.exit(7);", + "fs.writeFileSync('VERSION', (process.env.FIXTURE_WRONG_VERSION ? '9.9.9.9' : v) + '\\n');", + "const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.version = v.split('.').slice(0, 3).join('.'); fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n');", + "fs.writeFileSync('generated/digest.md', '# digest v' + v + '\\nbody\\n');", + "if (process.env.FIXTURE_STRAY) fs.writeFileSync('stray.txt', 'x\\n');", + "execSync('git add -A'); execSync('git -c user.name=r -c user.email=r@example.com -c commit.gpgsign=false commit -q -m restamp');", + ].join('\n') + '\n'; + const POLICY = { ...BASE_POLICY, release_tool: null, restamp_command: 'node restamp.js' }; + + function delegated(): FixtureRepo { + const repo = fixture({ policy: POLICY }); + fs.writeFileSync(path.join(repo.work, 'restamp.js'), RESTAMP_JS); + repo.git('add', 'restamp.js'); repo.git('commit', '-q', '-m', 'repo restamp tool'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'feat-a'); + return repo; + } + + test('it runs first, on a clean checkout of HEAD, with the allocated version as argument and GSTACK_RESTAMP_VERSION; gstack\'s version writer is skipped', () => { + const repo = delegated(); + const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 1.2.4.0'); + expect(r.out).toContain('stage restamp_command: done (node restamp.js 1.2.4.0)'); + expect(r.out).toContain('stage version: skipped (restamp_command owns the version)'); + expect(r.out.indexOf('stage restamp_command:')).toBeLessThan(r.out.indexOf('stage version:')); + expect(fs.readFileSync(path.join(repo.work, 'VERSION'), 'utf8')).toBe('1.2.4.0\n'); + expect(fs.readFileSync(path.join(repo.work, 'generated', 'digest.md'), 'utf8')).toBe('# digest v1.2.4.0\nbody\n'); + expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1); + }); + + test('a failure, a wrong version, an output outside the allowed set or a dirty caller tree writes nothing to the branch', () => { + const cases: Array<[Record, RegExp, number]> = [ + [{ FIXTURE_FAIL: '1' }, /restamp_command exited 7.*nothing written to the branch.*\(RESTAMP_RELEASE_TOOL_FAILED\)/, EXIT.refused], + [{ FIXTURE_WRONG_VERSION: '1' }, /restamp_command left VERSION at 9\.9\.9\.9, expected 1\.2\.4\.0.*nothing written.*\(RESTAMP_VERSION_SOURCE\)/, EXIT.refused], + [{ FIXTURE_STRAY: '1' }, /restamp_command wrote outside the allowed set: stray\.txt.*\(RESTAMP_OUTPUT_OUTSIDE_ALLOWED\)/, EXIT.refused], + ]; + for (const [env, err, status] of cases) { + const repo = delegated(); + const before = treeOf(repo); + const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], env); + expect(r.status).toBe(status); + expect(r.err).toMatch(err); + expect(treeOf(repo)).toBe(before); + expect(fs.readFileSync(path.join(repo.work, 'VERSION'), 'utf8')).toBe('1.2.3.0\n'); + } + const dirty = delegated(); + fs.writeFileSync(path.join(dirty.work, 'notes.txt'), 'uncommitted\n'); + const before = treeOf(dirty); + const d = runBin(dirty, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']); + expect(d.status).toBe(EXIT.fail); + expect(d.err).toMatch(/restamp_command needs a clean HEAD, index and worktree.*notes\.txt.*nothing written.*\(RESTAMP_CONFLICT\)/); + expect(treeOf(dirty)).toBe(before); + expect(fs.existsSync(journal(dirty))).toBe(false); + }); + + test('without --allow-repo-commands it is listed and refused like release_tool', () => { + const repo = delegated(); + const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main']); + expect(r.status).toBe(EXIT.refused); + expect(r.out).toContain('COMMAND: restamp_command="node restamp.js" cwd=.'); + }); +}); diff --git a/test/stamp-templates.test.ts b/test/stamp-templates.test.ts new file mode 100644 index 000000000..25aa244de --- /dev/null +++ b/test/stamp-templates.test.ts @@ -0,0 +1,80 @@ +/** + * Field wave CD3: which files count as stamps is the repo's rule. Ship policy + * `stamp_files` is an explicit allowlist applied before any stamp rule; + * `stamp_patterns` are literal templates with one `{version}` slot (no free + * regex), shared by the restamp writer and the tree-receipt classifier. + * Field incident: a pure gbrain restamp from 0.60.188.0 to 0.60.189.0 read + * `TREE: changed` for 13 of 16 files because `since: '…'`, `"version": "…"` + * and `` were outside gstack's three built-in forms. + */ +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 { rewriteStamps } from '../lib/restamp'; +import { validatePolicy } from '../lib/ship-policy'; +import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const TEMPLATES = ['"version": "{version}"', '', "since: '{version}'"]; +const STAMP_POLICY = { ...BASE_POLICY, release_tool: null, stamp_paths: ['docs/**'], stamp_files: ['VERSION', 'package.json', 'plugin/**', 'src/notice.ts'], stamp_patterns: TEMPLATES }; + +function stampedFiles(v: string, docs = 'Added in v1.2.4.0.\n'): Record { + return { + VERSION: `${v}\n`, + 'plugin/.claude-plugin/plugin.json': `{\n "name": "x",\n "version": "${v}",\n "description": "d"\n}\n`, + 'plugin/README.md': `# plugin\n\n`, + 'src/notice.ts': `export const ROWS = [\n { since: '${v}', key: 'k' },\n];\n`, + 'docs/guide.md': docs, + }; +} +function commitFiles(repo: FixtureRepo, files: Record, msg: string): string { + for (const [rel, body] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), body); } + const pkg = JSON.parse(fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8')); + pkg.version = files.VERSION!.trim().split('.').slice(0, 3).join('.'); + fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', msg); + return repo.git('rev-parse', 'HEAD'); +} + +describe('stamp templates (pure)', () => { + test('rewriteStamps applies literal {version} templates beside the three built-in forms, at version boundaries only', () => { + expect(rewriteStamps("{ since: '1.2.3.0', x }", '1.2.3.0', '1.2.4.0', ["since: '{version}'"])).toEqual({ text: "{ since: '1.2.4.0', x }", count: 1 }); + expect(rewriteStamps('"version": "1.2.3.0",', '1.2.3.0', '1.2.4.0', ['"version": "{version}"']).count).toBe(1); + expect(rewriteStamps('', '1.2.3.0', '1.2.4.0', ['']).text).toBe(''); + expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(0); + expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0); + }); + + test('policy: stamp_patterns are bounded literal templates with exactly one {version}; stamp_files are contained globs', () => { + const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`); + expect(codes(STAMP_POLICY)).toEqual([]); + expect(codes({ stamp_patterns: ['no slot'] })).toEqual(['POLICY_INVALID:stamp_patterns[0]']); + expect(codes({ stamp_patterns: ['{version} and {version}'] })).toEqual(['POLICY_INVALID:stamp_patterns[0]']); + expect(codes({ stamp_patterns: ['x'.repeat(130) + '{version}'] })).toEqual(['POLICY_CONTAINMENT:stamp_patterns[0]']); + expect(codes({ stamp_patterns: Array.from({ length: 17 }, (_, i) => `k${i}: {version}`) })).toEqual(['POLICY_CONTAINMENT:stamp_patterns']); + expect(codes({ stamp_files: ['../VERSION'] })).toEqual(['POLICY_CONTAINMENT:stamp_files[0]']); + }); +}); + +describe('tree receipt under the repo stamp rule', () => { + test('a pure restamp across a manifest, a stamp comment and a since row is same-modulo-stamps; a doc vX mention outside stamp_files is a difference', () => { + const repo = makeFixtureRepo({ prefix: 'stamp-templates-', policy: STAMP_POLICY }); repos.push(repo); + makePr(repo, 'pr-a', { changelog: false }); + const gated = commitFiles(repo, stampedFiles('1.2.4.0'), 'stamp 1.2.4.0'); + commitFiles(repo, stampedFiles('1.2.5.0'), 'restamp 1.2.5.0'); + const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', gated]); + expect(r.out).toMatch(/^ {2}stamp {3}plugin\/\.claude-plugin\/plugin\.json \(stamp_files\)$/m); + expect(r.out).toMatch(/^ {2}stamp {3}plugin\/README\.md \(stamp_files\)$/m); + expect(r.out).toMatch(/^ {2}stamp {3}src\/notice\.ts \(stamp_files\)$/m); + expect(r.out).toMatch(/^ {2}stamp {3}VERSION \(version-file\)$/m); + expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m); + commitFiles(repo, stampedFiles('1.2.5.0', 'Added in v1.2.5.0.\n'), 'doc mention'); + const d = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', gated]); + expect(d.status).toBe(EXIT.fail); + expect(d.out).toMatch(/^ {2}changed docs\/guide\.md \(stamp_files: not in the repo's stamp_files allowlist\)$/m); + expect(d.out).toMatch(/^TREE: changed \(docs\/guide\.md\)$/m); + }); +});