mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-queue-train.test.ts out of the Windows subset (gh shim not resolved on windows-latest) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.145.0 feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.146.0 restamp: #3295 takes v1.91.145.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
557 lines
36 KiB
TypeScript
557 lines
36 KiB
TypeScript
/**
|
||
* ship-policy — the machine-readable repo ship policy (plan C1):
|
||
* `.gstack/ship-policy.json`, read from the committed base-branch copy
|
||
* (`origin/<base>`) so a PR cannot smuggle a command into its own gate.
|
||
*
|
||
* File presence never authorizes execution: `release_tool` runs only under
|
||
* `/ship` or `--allow-repo-commands` (bin/gstack-restamp enforces that; this
|
||
* module only names the commands a policy declares). Every repo-controlled
|
||
* path (`stamp_paths`, `release_outputs`, `changelog`, `mirror`) is
|
||
* containment-checked here: no absolute paths, no `..`, globs confined to the
|
||
* repository, regex size caps, and symlink-escape checks when a glob is
|
||
* resolved against a tree. Docs: docs/ship-policy.md. Callers: bin/gstack-
|
||
* ship-policy, lib/restamp.ts, lib/tree-receipt.ts, lib/ship-receipt.ts.
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { RESULT_CODES, type ResultCodeName } from './result-codes';
|
||
import { cmpVersion, parseVersion } from './version-source';
|
||
|
||
export const SHIP_POLICY_PATH = '.gstack/ship-policy.json';
|
||
export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1';
|
||
|
||
export type BumpPolicy = 'patch' | 'ask';
|
||
export type QueueMode = 'claim-at-open' | 'stamp-at-merge';
|
||
export type HistoryPolicy = 'merge-only' | 'squash-ok';
|
||
|
||
export interface ShipPolicy {
|
||
$schema?: string;
|
||
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them. */
|
||
bump: BumpPolicy;
|
||
/** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */
|
||
queue_mode: QueueMode;
|
||
/** The repo's own release tool (for example `bun run release:restamp`); run only under /ship or --allow-repo-commands. */
|
||
release_tool: string | null;
|
||
/** Globs the release tool may write beyond the stamp files; any other output is a refusal. */
|
||
release_outputs: string[];
|
||
/** Globs where `(was X)`, `since: X` and `vX` mentions are rewritten on a restamp. */
|
||
stamp_paths: string[];
|
||
/** The CHANGELOG re-headed on a restamp; null for repos without one. */
|
||
changelog: string | null;
|
||
/** Path of a paired repo to restamp identically (tier 2). */
|
||
mirror: string | null;
|
||
/** `merge-only` refuses a squash or rebase plan in /ship. */
|
||
history: HistoryPolicy;
|
||
/** Each must be an ancestor of HEAD before push (`gstack-evidence ancestor`). */
|
||
preregistration_shas: string[];
|
||
/** Evidence labels that consume VERSION or generated bytes and always rerun on the stamped tree. */
|
||
stamp_sensitive_gates: string[];
|
||
/** Consumed by the plan reviews (D1). */
|
||
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;
|
||
/** The repo's full pre-merge gate; gstack-ship-queue prints it for the caller to run (gstack never executes it). */
|
||
gate_command: string | null;
|
||
/** true: a merge needs a passing full-gate record bound to a tree equal to the merging one modulo stamps. */
|
||
full_e2e: boolean;
|
||
/** The oldest gstack that may read this policy; a newer key set to a non-default value requires it. */
|
||
min_gstack_version: string | null;
|
||
/** Merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train); null keeps the per-PR queue. */
|
||
train: TrainPolicy | null;
|
||
}
|
||
|
||
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
|
||
export interface TrainPolicy {
|
||
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
|
||
gate_workflow: string;
|
||
/** Workflow file dispatched on train/<nonce> before the paid gate; its run must be green on the train head. */
|
||
free_workflow: string;
|
||
/** The check run the train's own gate run publishes on the train head when its census is complete and green. */
|
||
full_gate_check: string;
|
||
/** Artifact name of the train record (and the prefix of each bisect prefix's artifact). */
|
||
record_artifact: string;
|
||
/** Most PRs in one train. */
|
||
max_size: number;
|
||
/** A train under max_size dispatches once its oldest READY PR has waited this long. */
|
||
fill_hours: number;
|
||
/** How `ready` computes the screen's selection: gstack's paid selector, or not at all. */
|
||
screen_selection: 'gstack-paid' | 'none';
|
||
/** Bisect admission price of one case trial that has no recorded cost. */
|
||
trial_reservation_usd: number;
|
||
}
|
||
|
||
export const DEFAULT_POLICY: ShipPolicy = {
|
||
bump: 'ask',
|
||
queue_mode: 'claim-at-open',
|
||
release_tool: null,
|
||
release_outputs: [],
|
||
stamp_paths: [],
|
||
changelog: 'CHANGELOG.md',
|
||
mirror: null,
|
||
history: 'squash-ok',
|
||
preregistration_shas: [],
|
||
stamp_sensitive_gates: [],
|
||
pr_count: { default_pr_count: 1, split_requires_owner: true },
|
||
maintainers: [],
|
||
stamp_files: [],
|
||
stamp_patterns: [],
|
||
restamp_command: null,
|
||
migrations: null,
|
||
gate_command: null,
|
||
full_e2e: false,
|
||
min_gstack_version: null,
|
||
train: null,
|
||
};
|
||
|
||
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
|
||
export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[];
|
||
|
||
/** What each key means, printed by `init --explain` (JSON carries no comments). */
|
||
export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||
bump: 'patch | ask. `patch` skips the MINOR/MAJOR prompt in /ship Step 12.',
|
||
queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.',
|
||
release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.',
|
||
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
|
||
stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.',
|
||
changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.',
|
||
mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.',
|
||
history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.',
|
||
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
|
||
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.',
|
||
gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.',
|
||
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
|
||
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|bisect|rebuild|prune, ready --screen|--no-screen, verify --train). null keeps today\'s per-PR queue unchanged.',
|
||
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
|
||
};
|
||
|
||
/**
|
||
* The gstack release that introduced each key. Keys newer than
|
||
* POLICY_COMPAT_FLOOR need `min_gstack_version` before they take a
|
||
* non-default value, so a crew on mixed gstack versions sees one named
|
||
* refusal instead of a policy each version reads differently. Unknown keys
|
||
* always fail closed: there is no extension mechanism.
|
||
*/
|
||
export const POLICY_COMPAT_FLOOR = '1.91.98.0';
|
||
export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0';
|
||
export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
|
||
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
|
||
train: TRAIN_KEYS_VERSION,
|
||
};
|
||
|
||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||
|
||
/**
|
||
* The release this checkout reads policies as: its VERSION file, or the
|
||
* newest key it knows when that is later (an unstamped tree that carries a
|
||
* new key already reads policies written for it).
|
||
*/
|
||
export function runningGstackVersion(): string {
|
||
let own = '0.0.0.0';
|
||
try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {}
|
||
const known = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
|
||
return parseVersion(own) && !newer(known, own) ? own : known;
|
||
}
|
||
|
||
/** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */
|
||
export function minimumReader(raw: Record<string, unknown>): string {
|
||
return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey))
|
||
.map(k => POLICY_KEY_INTRODUCED[k]).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump);
|
||
}
|
||
|
||
export interface PolicyError { code: ResultCodeName; path: string; message: string }
|
||
|
||
const MAX_LIST = 64;
|
||
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 === '..');
|
||
}
|
||
|
||
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
|
||
export function containedRelative(p: string): string | null {
|
||
if (typeof p !== 'string' || p.length === 0) return 'must be a non-empty string';
|
||
if (p.length > MAX_PATTERN) return `longer than ${MAX_PATTERN} characters`;
|
||
if (/[\0\r\n]/.test(p)) return 'contains a control character';
|
||
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) return 'must be repo-relative, not absolute';
|
||
if (p.startsWith('~')) return 'must not start with ~';
|
||
if (hasDotDot(p)) return 'must not contain a `..` segment';
|
||
return null;
|
||
}
|
||
|
||
/** Glob → anchored RegExp. `**` spans directories, `*` and `?` stay inside one segment. Size-capped. */
|
||
export function globToRegExp(glob: string): RegExp {
|
||
let out = '^';
|
||
for (let i = 0; i < glob.length; i++) {
|
||
const ch = glob[i]!;
|
||
if (ch === '*') {
|
||
if (glob[i + 1] === '*') {
|
||
i += 1;
|
||
if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?'; } else out += '.*';
|
||
} else out += '[^/]*';
|
||
} else if (ch === '?') out += '[^/]';
|
||
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
out += '$';
|
||
if (out.length > MAX_REGEX) throw new Error(`pattern expands past ${MAX_REGEX} characters`);
|
||
return new RegExp(out);
|
||
}
|
||
|
||
export function matchesAny(globs: readonly string[], rel: string): boolean {
|
||
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
||
return globs.some(g => globToRegExp(g).test(norm));
|
||
}
|
||
|
||
function listErrors(key: string, value: unknown, out: PolicyError[], check: (item: string) => string | null): void {
|
||
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: key, message: 'must be an array of strings' }); return; }
|
||
if (value.length > MAX_LIST) out.push({ code: 'POLICY_CONTAINMENT', path: key, message: `more than ${MAX_LIST} entries` });
|
||
value.forEach((item, i) => {
|
||
if (typeof item !== 'string') { out.push({ code: 'POLICY_INVALID', path: `${key}[${i}]`, message: 'must be a string' }); return; }
|
||
const why = check(item);
|
||
if (why) out.push({ code: 'POLICY_CONTAINMENT', path: `${key}[${i}]`, message: `${JSON.stringify(item)} ${why}` });
|
||
});
|
||
}
|
||
|
||
function globError(g: string): string | null {
|
||
const why = containedRelative(g);
|
||
if (why) return why;
|
||
try { globToRegExp(g); } catch (e: any) { return e.message; }
|
||
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}` }); }
|
||
}
|
||
|
||
const TRAIN_FIELDS: Record<keyof TrainPolicy, (v: unknown) => string | null> = {
|
||
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
|
||
free_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as free-tests.yml'),
|
||
full_gate_check: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
|
||
record_artifact: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(v) ? null : 'must be an artifact name ([A-Za-z0-9_.-]{1,64})'),
|
||
max_size: v => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 6 ? null : 'must be an integer from 1 to 6'),
|
||
fill_hours: v => (typeof v === 'number' && v > 0 && v <= 24 ? null : 'must be a number of hours above 0 and at most 24'),
|
||
screen_selection: v => (v === 'gstack-paid' || v === 'none' ? null : 'must be gstack-paid | none'),
|
||
trial_reservation_usd: v => (typeof v === 'number' && v > 0 && v <= 100 ? null : 'must be dollars above 0 and at most 100'),
|
||
};
|
||
|
||
function trainErrors(value: unknown, out: PolicyError[]): void {
|
||
const t = value as Record<string, unknown> | null;
|
||
if (!t || typeof t !== 'object' || Array.isArray(t)) { out.push({ code: 'POLICY_INVALID', path: 'train', message: `must be { ${Object.keys(TRAIN_FIELDS).join(', ')} } or null` }); return; }
|
||
for (const k of Object.keys(t)) if (!(k in TRAIN_FIELDS)) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'unknown key' });
|
||
for (const [k, check] of Object.entries(TRAIN_FIELDS)) {
|
||
if (!(k in t)) { out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'missing (every train field is required)' }); continue; }
|
||
const why = check(t[k]);
|
||
if (why) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: `${JSON.stringify(t[k])} ${why}` });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schema and containment check of a parsed policy object. Unknown keys are
|
||
* errors (a typo must not silently mean "default", and a key from a newer
|
||
* gstack must not be ignored by an older one).
|
||
*/
|
||
export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = {}): PolicyError[] {
|
||
const errors: PolicyError[] = [];
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'POLICY_INVALID', path: '', message: 'policy must be a JSON object' }];
|
||
const obj = raw as Record<string, unknown>;
|
||
const newest = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
|
||
for (const key of Object.keys(obj)) {
|
||
if (key !== '$schema' && !POLICY_KEYS.includes(key as PolicyKey)) errors.push({ code: 'POLICY_INVALID', path: key, message: `unknown key (this gstack reads keys introduced up to ${newest}; a key from a newer gstack needs an upgrade, a misspelled one a fix); valid keys: ${POLICY_KEYS.join(', ')}` });
|
||
}
|
||
compatErrors(obj, opts.gstackVersion ?? runningGstackVersion(), errors);
|
||
const oneOf = (key: string, values: readonly string[]) => {
|
||
if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` });
|
||
};
|
||
oneOf('bump', ['patch', 'ask']);
|
||
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
|
||
oneOf('history', ['merge-only', 'squash-ok']);
|
||
if ('release_tool' in obj && obj.release_tool !== null) {
|
||
const t = obj.release_tool;
|
||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
|
||
else if (t.length > MAX_COMMAND) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: `longer than ${MAX_COMMAND} characters` });
|
||
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 ('gate_command' in obj && obj.gate_command !== null) {
|
||
const t = obj.gate_command;
|
||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'gate_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: 'gate_command', message: `must be one line under ${MAX_COMMAND} characters` });
|
||
}
|
||
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
|
||
if ('train' in obj && obj.train !== null) trainErrors(obj.train, 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;
|
||
if (typeof obj[key] !== 'string') { errors.push({ code: 'POLICY_INVALID', path: key, message: 'must be a string or null' }); continue; }
|
||
const p = obj[key] as string;
|
||
const why = key === 'mirror' ? mirrorPathError(p) : containedRelative(p);
|
||
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: key, message: `${JSON.stringify(p)} ${why}` });
|
||
}
|
||
if ('preregistration_shas' in obj) listErrors('preregistration_shas', obj.preregistration_shas, errors, s => (SHA_RE.test(s) ? null : 'is not a 7-64 character hex commit id'));
|
||
if ('stamp_sensitive_gates' in obj) listErrors('stamp_sensitive_gates', obj.stamp_sensitive_gates, errors, s => (/^[A-Za-z0-9_.:-]{1,64}$/.test(s) ? null : 'is not an evidence label ([A-Za-z0-9_.:-]{1,64})'));
|
||
if ('maintainers' in obj) listErrors('maintainers', obj.maintainers, errors, s => (/^[A-Za-z0-9-]{1,39}$/.test(s) ? null : 'is not a GitHub login'));
|
||
if ('pr_count' in obj) {
|
||
const pc = obj.pr_count as Record<string, unknown> | null;
|
||
if (!pc || typeof pc !== 'object' || Array.isArray(pc)) errors.push({ code: 'POLICY_INVALID', path: 'pr_count', message: 'must be { default_pr_count, split_requires_owner }' });
|
||
else {
|
||
if (!Number.isInteger(pc.default_pr_count) || (pc.default_pr_count as number) < 1) errors.push({ code: 'POLICY_INVALID', path: 'pr_count.default_pr_count', message: 'must be an integer >= 1' });
|
||
if (typeof pc.split_requires_owner !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'pr_count.split_requires_owner', message: 'must be a boolean' });
|
||
for (const k of Object.keys(pc)) if (!['default_pr_count', 'split_requires_owner'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `pr_count.${k}`, message: 'unknown key' });
|
||
}
|
||
}
|
||
return errors;
|
||
}
|
||
|
||
/**
|
||
* min_gstack_version: a well-formed version, not newer than this gstack
|
||
* (POLICY_GSTACK_TOO_OLD), and at least the introducing version of every
|
||
* post-floor key the file sets to a non-default value.
|
||
*/
|
||
function compatErrors(obj: Record<string, unknown>, running: string, errors: PolicyError[]): void {
|
||
const min = obj.min_gstack_version;
|
||
if (min !== undefined && min !== null && (typeof min !== 'string' || !parseVersion(min))) {
|
||
errors.push({ code: 'POLICY_INVALID', path: 'min_gstack_version', message: 'must be a version (MAJOR.MINOR.PATCH[.MICRO]) or null' });
|
||
return;
|
||
}
|
||
if (typeof min === 'string' && parseVersion(running) && newer(min, running)) {
|
||
errors.push({ code: 'POLICY_GSTACK_TOO_OLD', path: 'min_gstack_version', message: `this policy needs gstack ${min} or later; this gstack is ${running}` });
|
||
return;
|
||
}
|
||
for (const key of POLICY_KEYS) {
|
||
const since = POLICY_KEY_INTRODUCED[key];
|
||
if (!(key in obj) || !newer(since, POLICY_COMPAT_FLOOR)) continue;
|
||
if (JSON.stringify(obj[key]) === JSON.stringify((DEFAULT_POLICY as any)[key])) continue;
|
||
if (typeof min === 'string' && !newer(since, min)) continue;
|
||
errors.push({ code: 'POLICY_INVALID', path: key, message: `introduced in gstack ${since}; set min_gstack_version to ${since} or later so an older gstack refuses this policy by name` });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A mirror is a sibling checkout, so leading `..` segments are allowed; an
|
||
* interior `..`, an absolute path or the repo itself (`.`) are not.
|
||
*/
|
||
function mirrorPathError(p: string): string | null {
|
||
if (!p || p.length > MAX_PATTERN) return 'must be a non-empty relative path';
|
||
if (/[\0\r\n]/.test(p)) return 'contains a control character';
|
||
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p)) return 'must be relative to the repository root';
|
||
const segs = p.split(/[\\/]+/).filter(s => s && s !== '.');
|
||
if (segs.length === 0) return 'must not be the repository itself';
|
||
let i = 0;
|
||
while (i < segs.length && segs[i] === '..') i += 1;
|
||
if (segs.slice(i).includes('..')) return 'must not contain an interior `..` segment';
|
||
if (i === segs.length) return 'must name a directory, not only parents';
|
||
return null;
|
||
}
|
||
|
||
/** Merge a parsed file over the defaults, recording per-key provenance. */
|
||
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
|
||
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
|
||
const values: Record<string, string> = {};
|
||
for (const key of POLICY_KEYS) {
|
||
if (raw && key in raw) { (policy as any)[key] = raw[key]; values[key] = sourceLabel; }
|
||
else values[key] = 'default';
|
||
}
|
||
if (raw && typeof raw.$schema === 'string') policy.$schema = raw.$schema;
|
||
return { policy, values };
|
||
}
|
||
|
||
export interface PolicySource { kind: 'base' | 'head' | 'none'; ref: string | null; sha: string | null; label: string }
|
||
export interface LoadedPolicy {
|
||
policy: ShipPolicy;
|
||
present: boolean;
|
||
source: PolicySource;
|
||
errors: PolicyError[];
|
||
values: Record<string, string>;
|
||
}
|
||
export type PolicyLoad = LoadedPolicy | { unavailable: true; base: string; detail: string };
|
||
|
||
function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err: string } {
|
||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
||
}
|
||
|
||
/** `--base` when given, else origin/HEAD's branch, else `main`. */
|
||
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
|
||
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
|
||
const r = git(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
|
||
if (r.ok && r.out) return r.out.replace(/^origin\//, '');
|
||
return 'main';
|
||
}
|
||
|
||
function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
|
||
if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], values: mergePolicy(null, source.label).values };
|
||
let raw: unknown;
|
||
try { raw = JSON.parse(rawText); }
|
||
catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], values: {} }; }
|
||
const errors = validatePolicy(raw);
|
||
const merged = mergePolicy(errors.length ? null : (raw as Record<string, unknown>), source.label);
|
||
return { policy: merged.policy, present: true, source, errors, values: merged.values };
|
||
}
|
||
|
||
/**
|
||
* The reviewed policy: `.gstack/ship-policy.json` as committed on
|
||
* `origin/<base>`. An unresolvable base is `unavailable` (POLICY_SOURCE_
|
||
* UNAVAILABLE); the caller must never fall back to the working tree silently.
|
||
*/
|
||
export function loadPolicyFromBase(repoRoot: string, base: string): PolicyLoad {
|
||
const ref = `origin/${base.replace(/^origin\//, '')}`;
|
||
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
||
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
|
||
const source: PolicySource = { kind: 'base', ref, sha: sha.out, label: `${ref}@${sha.out.slice(0, 12)}:${SHIP_POLICY_PATH}` };
|
||
const exists = git(repoRoot, ['cat-file', '-e', `${sha.out}:${SHIP_POLICY_PATH}`]);
|
||
if (!exists.ok) return finish(null, { ...source, kind: 'none', label: `${ref}@${sha.out.slice(0, 12)} (no ${SHIP_POLICY_PATH})` });
|
||
const show = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
|
||
return finish(show.ok ? show.out : null, source);
|
||
}
|
||
|
||
/** The working-tree policy: unreviewed, labeled so in every receipt. */
|
||
export function loadPolicyFromHead(repoRoot: string): LoadedPolicy {
|
||
const file = path.join(repoRoot, SHIP_POLICY_PATH);
|
||
const source: PolicySource = { kind: 'head', ref: null, sha: null, label: `head (unreviewed) ${SHIP_POLICY_PATH}` };
|
||
if (!fs.existsSync(file)) return finish(null, { ...source, kind: 'none', label: `head (no ${SHIP_POLICY_PATH})` });
|
||
return finish(fs.readFileSync(file, 'utf8'), source);
|
||
}
|
||
|
||
/** The one POLICY_SOURCE_UNAVAILABLE line, with the base branch substituted into the fix clause. */
|
||
export function policyUnavailableLine(context: string, base: string, detail: string): string {
|
||
const row = RESULT_CODES.POLICY_SOURCE_UNAVAILABLE;
|
||
return `${context}: ${row.summary} (${detail}); fix: ${row.fix.replace('<base>', base)} (POLICY_SOURCE_UNAVAILABLE)`;
|
||
}
|
||
|
||
export function isUnavailable(load: PolicyLoad): load is { unavailable: true; base: string; detail: string } {
|
||
return (load as any).unavailable === true;
|
||
}
|
||
|
||
/** Commands a policy declares; their presence never authorizes execution. */
|
||
export function declaredCommands(policy: ShipPolicy): Array<{ key: string; command: string; cwd: string }> {
|
||
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;
|
||
}
|
||
|
||
/** The example `init` writes: every key at its default plus `$schema`. */
|
||
export function examplePolicyJson(overrides: Partial<ShipPolicy> = {}): string {
|
||
const body: Record<string, unknown> = { $schema: SHIP_POLICY_SCHEMA };
|
||
for (const key of POLICY_KEYS) body[key] = key in overrides ? (overrides as any)[key] : (DEFAULT_POLICY as any)[key];
|
||
return JSON.stringify(body, null, 2) + '\n';
|
||
}
|
||
|
||
export function explainPolicy(): string {
|
||
return POLICY_KEYS.map(key => `${key}: ${POLICY_EXPLANATIONS[key]} default: ${JSON.stringify((DEFAULT_POLICY as any)[key])} introduced: ${POLICY_KEY_INTRODUCED[key]}`).join('\n') + '\n';
|
||
}
|
||
|
||
/** `key=value (source)` lines plus the `policy:` provenance line, as show-effective prints them. */
|
||
export function renderEffective(load: LoadedPolicy, opts: { base?: string } = {}): string {
|
||
const lines = [`policy: ${load.source.kind === 'base' ? 'base' : load.source.kind === 'head' ? 'head (unreviewed)' : 'none (defaults)'} ${load.source.label}`];
|
||
for (const key of POLICY_KEYS) lines.push(`${key}=${JSON.stringify((load.policy as any)[key])} (${load.values[key] ?? 'default'})`);
|
||
return lines.join('\n') + '\n' + shipConsequences(load.policy, opts.base ?? '<base>').map(l => `ship: ${l}`).join('\n') + '\n';
|
||
}
|
||
|
||
/** What `/ship` does differently under this policy: the workflow prose the skill prints instead of carrying (docs/ship-policy.md#ship). */
|
||
export function shipConsequences(policy: ShipPolicy, base: string): string[] {
|
||
const out: string[] = [];
|
||
if (policy.history === 'merge-only') out.push('history merge-only: never rebase, squash or force-push this branch; `gstack-ship-policy check-history --plan <merge|squash|rebase>` refuses a rewrite plan (HISTORY_POLICY_VIOLATION)');
|
||
if (policy.queue_mode === 'stamp-at-merge') {
|
||
out.push(`queue_mode stamp-at-merge: Step 12 items 1-4 are replaced by \`gstack-restamp --next --base ${base} --pr <pr-number> --expect-base <base-sha>\` when this PR is next in the queue (\`--after <predecessor-pr>\` gates ahead in a throwaway worktree when it is not); follow its next: line; claims are ignored`);
|
||
out.push(`shared merge queue: when a coordinator runs the queue, \`gstack-ship-queue ready --pr <n>\`, \`gate-ahead --after <pr> --pr <n>\` and \`stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>\` replace the calls above; stamp only when your slot is called, send the READY block and the QUEUE_STAMP: line; the coordinator runs \`gstack-ship-queue verify\` (docs/ship-policy.md#run-a-shared-merge-queue)${policy.gate_command ? `; gate command: \`${policy.gate_command}\`` : ''}`);
|
||
out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr <pr> --bundle <bundle> --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator\'s call');
|
||
} else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)');
|
||
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written');
|
||
if (policy.preregistration_shas.length) out.push(`preregistration: \`gstack-evidence ancestor --from-policy --base ${base}\` must print only ANCESTOR: ok before push; the ship receipt records the result`);
|
||
if (policy.release_tool) out.push(`release_tool: \`${policy.release_tool}\` runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs)`);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Files under `globs` inside a committed tree, as `git ls-tree` lists them.
|
||
* Symlinks (mode 120000) never match: a stamp write through a link would
|
||
* escape the tree the glob confines it to.
|
||
*/
|
||
export function listTreeFiles(repoRoot: string, treeish: string): Array<{ path: string; mode: string }> {
|
||
const r = git(repoRoot, ['ls-tree', '-r', '-z', treeish]);
|
||
if (!r.ok) return [];
|
||
return r.out.split('\0').filter(Boolean).map(line => {
|
||
const [meta, p] = line.split('\t');
|
||
return { path: p ?? '', mode: meta?.split(' ')[0] ?? '' };
|
||
}).filter(e => e.path);
|
||
}
|
||
|
||
export function stampFilesInTree(repoRoot: string, treeish: string, globs: readonly string[]): string[] {
|
||
if (globs.length === 0) return [];
|
||
return listTreeFiles(repoRoot, treeish).filter(e => e.mode !== '120000' && matchesAny(globs, e.path)).map(e => e.path);
|
||
}
|
||
|
||
/**
|
||
* A working-tree path a stamp may write: inside `root` after realpath (a
|
||
* symlink out of the repository is refused even when the glob matched).
|
||
*/
|
||
export function assertInsideRoot(root: string, rel: string): string | null {
|
||
const realRoot = fs.realpathSync(root);
|
||
const abs = path.resolve(realRoot, rel);
|
||
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
|
||
let probe = abs;
|
||
while (!fs.existsSync(probe)) {
|
||
const parent = path.dirname(probe);
|
||
if (parent === probe) break;
|
||
probe = parent;
|
||
}
|
||
let real: string;
|
||
try { real = fs.realpathSync(probe); } catch { return null; }
|
||
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
|
||
return null;
|
||
}
|