mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
feat(restamp): the repo's stamp rule (stamp_files, literal stamp_patterns) and restamp_command in the staging worktree
stamp_files is an allowlist applied before every stamp rule; stamp_patterns
are bounded literal {version} templates shared by the restamp writer and the
tree receipt. restamp_command runs first in the staging worktree with the
target version (argument and GSTACK_RESTAMP_VERSION), needs a clean caller
tree, skips gstack's version writer and keeps output confinement; a failure
or a wrong version writes nothing. migrations { dir, id } is the policy key
gstack-pregate regate reads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
05e6f2e828
commit
c47fd042d9
6 files changed
+298
-26
No files matched your search
+31
-3
@@ -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}'`, `<!-- x-stamp: {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 <X>` 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 <X>` 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 `<X>`, above its predecessor's entry. Duplicate headings (`CHANGELOG_DUPLICATE_HEADING`) and a top entry that is not `<X>` (`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 <X>`, 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 <X>`, 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@<sha>` or `head (unreviewed)`).
|
||||
|
||||
<a id="restamp-command"></a>
|
||||
### `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.
|
||||
|
||||
<a id="tree-receipt"></a>
|
||||
## 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
|
||||
|
||||
+57
-17
@@ -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(`(?<![A-Za-z0-9.])v${esc}(?![0-9.])`, 'g'), `v${newVersion}`],
|
||||
[new RegExp(`\\(was ${v}\\)`, 'g'), `(was ${newVersion})`],
|
||||
[new RegExp(`(since:\\s*)${v}(?![0-9.])`, 'g'), `$1${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${esc(pre)}(?<![0-9.])${v}(?![0-9.])${esc(post)}`, 'g'), `${pre}${newVersion}${post}`.replace(/\$/g, '$$$$')] as [RegExp, string]; }),
|
||||
];
|
||||
let count = 0;
|
||||
let out = text;
|
||||
@@ -304,16 +313,41 @@ function linkIgnoredDeps(repoRoot: string, staging: string): void {
|
||||
try { fs.symlinkSync(nm, path.join(staging, 'node_modules'), 'dir'); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo's own restamp (`restamp_command`), first in the staging worktree:
|
||||
* the target version as `$1` and in GSTACK_RESTAMP_VERSION. Whatever it
|
||||
* stages or commits there is folded back into worktree edits against the
|
||||
* seeded tree; its outputs are confined like release_tool's, and the
|
||||
* version file must hold the target version. Any failure writes nothing.
|
||||
*/
|
||||
function runRestampCommand(command: string, plan: Plan, staging: string, env: NodeJS.ProcessEnv, before: Set<string>, 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();
|
||||
|
||||
@@ -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<keyof ShipPolicy, '$schema'>;
|
||||
@@ -84,6 +96,10 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -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`);
|
||||
|
||||
@@ -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<string, string>, 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=.');
|
||||
});
|
||||
});
|
||||
@@ -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 `<!-- …-stamp: … -->` 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}"', '<!-- x-plugin-tree-stamp: {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<string, string> {
|
||||
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<!-- x-plugin-tree-stamp: ${v} -->\n`,
|
||||
'src/notice.ts': `export const ROWS = [\n { since: '${v}', key: 'k' },\n];\n`,
|
||||
'docs/guide.md': docs,
|
||||
};
|
||||
}
|
||||
function commitFiles(repo: FixtureRepo, files: Record<string, string>, 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('<!-- x-plugin-tree-stamp: 1.2.3.0 -->', '1.2.3.0', '1.2.4.0', ['<!-- x-plugin-tree-stamp: {version} -->']).text).toBe('<!-- x-plugin-tree-stamp: 1.2.4.0 -->');
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user