diff --git a/bin/gstack-ship-policy b/bin/gstack-ship-policy index 2c1d61d9b..206bd48c7 100755 --- a/bin/gstack-ship-policy +++ b/bin/gstack-ship-policy @@ -18,8 +18,8 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { - SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, - policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy, + POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, + minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy, } from '../lib/ship-policy'; const HELP = `gstack-ship-policy — the repo ship policy (docs/ship-policy.md) @@ -86,13 +86,18 @@ function main(argv: string[]): number { if (cmd === 'validate' || cmd === 'show-effective') { const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base'); if (typeof loaded === 'number') return loaded; + const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default'); + const introduced = Object.fromEntries(set.map(k => [k, POLICY_KEY_INTRODUCED[k]])); + const minReader = set.length ? minimumReader(introduced) : null; if (json) { - process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, errors: loaded.errors }) + '\n'); + process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; } printErrors(loaded); if (cmd === 'validate') { process.stdout.write(loaded.errors.length ? `POLICY_INVALID: ${loaded.errors.length} error(s) in ${loaded.source.label}\n` : `POLICY_VALID: ${loaded.present ? loaded.source.label : `no ${SHIP_POLICY_PATH} (defaults apply)`}\n`); + for (const k of set) process.stdout.write(`KEY: ${k} introduced=${introduced[k]}\n`); + if (minReader) process.stdout.write(`MIN_READER: gstack ${minReader} (every lane that reads this policy needs at least this version)\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; } process.stdout.write(renderEffective(loaded, { base: flag(rest, '--base') ?? resolveBaseBranch(root) })); diff --git a/lib/result-codes.ts b/lib/result-codes.ts index c3bdeaace..2a2f476f1 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -656,6 +656,42 @@ export const RESULT_CODES = { summary: 'a plan review ran without its second opinion: the outside provider did not run or failed, and the bounded native Plan reviewer was not offered or did not finish within five minutes', fix: 'repair the provider the line names (install it, or log in with `codex login`); for the native fallback use Claude Code 2.1.277 or later with background tasks enabled (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` unset), then re-run the review', }, + // Field list 2, W1: gstack-ship-queue and the ship-policy compatibility floor. + POLICY_GSTACK_TOO_OLD: { + anchor: 'policy-gstack-too-old', + summary: 'the ship policy sets min_gstack_version above this gstack, so this gstack refuses to read it', + fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for ) to the version named, then rerun', + }, + QUEUE_NOT_READY: { + anchor: 'queue-not-ready', + summary: 'the lane is not ready to report: the working tree, HEAD or CI state does not match the pushed PR head', + fix: 'commit and push the work (or wait for CI on that exact head), then rerun gstack-ship-queue ready', + }, + QUEUE_PROOF_MISSING: { + anchor: 'queue-proof-missing', + summary: 'a proof input the queue step needs is missing or does not match (gated head, base, stamp journal, version or tree)', + fix: 'supply the named input (fetch the gated head, pass the expected sha), or regate; nothing was written', + }, + QUEUE_STAMP_NOT_STAMP_ONLY: { + anchor: 'queue-stamp-not-stamp-only', + summary: 'the stamp commit differs from the gated head by more than stamps, so the gate does not cover it', + fix: 'inspect the printed residual paths; drop the stray edit, or run the gate again on the stamped head', + }, + QUEUE_VERIFY_MISMATCH: { + anchor: 'queue-verify-mismatch', + summary: 'the coordinator could not reproduce the lane’s stamp claim: the PR head, the recomputed gate-ahead merge or the gated tree differs', + fix: 'do not merge; ask the lane to rerun gstack-ship-queue stamp (or gate-ahead) on the current queue and report again', + }, + QUEUE_GATE_MISSING: { + anchor: 'queue-gate-missing', + summary: 'the policy sets full_e2e and no passing full-gate record is bound to this tree', + fix: 'run the gate the policy names (gstack-ship-queue gate-ahead, then --record) or pass the record of a passing run on this head', + }, + GATE_AHEAD_ATTEMPT_INVALID: { + anchor: 'gate-ahead-attempt-invalid', + summary: 'the gate-ahead attempt is unknown, already recorded, or its worktree no longer holds the gated commit', + fix: 'list attempts with gstack-ship-queue gate-ahead --list, then record the live one, or prepare a new attempt', + }, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index d34c8a07d..113f55637 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -16,6 +16,7 @@ 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'; @@ -58,6 +59,12 @@ export interface ShipPolicy { 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; } export const DEFAULT_POLICY: ShipPolicy = { @@ -77,6 +84,9 @@ export const DEFAULT_POLICY: ShipPolicy = { stamp_patterns: [], restamp_command: null, migrations: null, + gate_command: null, + full_e2e: false, + min_gstack_version: null, }; export type PolicyKey = Exclude; @@ -100,8 +110,47 @@ export const POLICY_EXPLANATIONS: Record = { 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 record (gstack-ship-queue gate-ahead --record) bound to a tree equal to the merging one modulo stamps; ready and verify refuse without one (QUEUE_GATE_MISSING).', + 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 POLICY_KEY_INTRODUCED: Record = { + 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, +}; + +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 { + 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; @@ -192,14 +241,20 @@ function migrationErrors(value: unknown, out: PolicyError[]): void { } 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[] { +/** + * 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; + 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; valid keys: ${POLICY_KEYS.join(', ')}` }); + 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])}` }); }; @@ -221,6 +276,12 @@ export function validatePolicy(raw: unknown): PolicyError[] { 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 ('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; @@ -244,6 +305,30 @@ export function validatePolicy(raw: unknown): PolicyError[] { 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, 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. @@ -357,7 +442,7 @@ export function examplePolicyJson(overrides: Partial = {}): string { } export function explainPolicy(): string { - return POLICY_KEYS.map(key => `${key}: ${POLICY_EXPLANATIONS[key]} default: ${JSON.stringify((DEFAULT_POLICY as any)[key])}`).join('\n') + '\n'; + 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. */ diff --git a/test/ship-policy-compat.test.ts b/test/ship-policy-compat.test.ts new file mode 100644 index 000000000..20578fd5a --- /dev/null +++ b/test/ship-policy-compat.test.ts @@ -0,0 +1,86 @@ +/** + * Field list 2, W1 round-2 obligation (ceo/outside High, dx/native Medium): + * unknown ship-policy keys stay fail-closed, new keys (gate_command, + * full_e2e) need min_gstack_version before they take a non-default value, an + * old gstack refuses a policy that names a newer minimum, and validate prints + * each key's introducing version. Includes an old client and a misspelled + * gate-reuse restriction end to end through the real binaries. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { EXIT } from '../lib/headless-artifacts'; +import { DEFAULT_POLICY, POLICY_KEY_INTRODUCED, SHIP_QUEUE_KEYS_VERSION, validatePolicy } from '../lib/ship-policy'; +import { BASE_POLICY, cleanup, makeFixtureRepo, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +const fixture = (policy: Record) => { const r = makeFixtureRepo({ policy }); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const codes = (raw: unknown, gstackVersion = '1.91.200.0') => validatePolicy(raw, { gstackVersion }).map(e => `${e.code}@${e.path}`); + +describe('policy compatibility floor', () => { + test('defaults stay valid; new keys at their defaults need nothing', () => { + expect(validatePolicy(DEFAULT_POLICY)).toEqual([]); + expect(codes({ gate_command: null, full_e2e: false })).toEqual([]); + }); + + test('gate_command or full_e2e set without min_gstack_version is POLICY_INVALID naming the introducing version', () => { + const errs = validatePolicy({ gate_command: 'bun run ci:gate', full_e2e: true }, { gstackVersion: '1.91.200.0' }); + expect(errs.map(e => e.path)).toEqual(['gate_command', 'full_e2e']); + expect(errs[0]!.message).toBe(`introduced in gstack ${SHIP_QUEUE_KEYS_VERSION}; set min_gstack_version to ${SHIP_QUEUE_KEYS_VERSION} or later so an older gstack refuses this policy by name`); + expect(codes({ gate_command: 'bun run ci:gate', full_e2e: true, min_gstack_version: SHIP_QUEUE_KEYS_VERSION })).toEqual([]); + expect(codes({ gate_command: 'bun run ci:gate', min_gstack_version: '1.91.98.0' })).toEqual(['POLICY_INVALID@gate_command', 'POLICY_INVALID@min_gstack_version']); + }); + + test('an old client: a minimum above this gstack is POLICY_GSTACK_TOO_OLD; a key from a newer gstack is an unknown key, never ignored', () => { + expect(codes({ gate_command: 'x', min_gstack_version: SHIP_QUEUE_KEYS_VERSION }, '1.91.106.0')).toEqual(['POLICY_GSTACK_TOO_OLD@min_gstack_version']); + const future = validatePolicy({ gate_reuse_requires: ['e2e'] }, { gstackVersion: '1.91.200.0' }); + expect(future.map(e => `${e.code}@${e.path}`)).toEqual(['POLICY_INVALID@gate_reuse_requires']); + expect(future[0]!.message).toContain(`this gstack reads keys introduced up to ${SHIP_QUEUE_KEYS_VERSION}`); + expect(codes({ min_gstack_version: 'soon' })).toEqual(['POLICY_INVALID@min_gstack_version']); + expect(codes({ full_e2e: 'yes', min_gstack_version: SHIP_QUEUE_KEYS_VERSION })).toEqual(['POLICY_INVALID@full_e2e']); + }); + + test('every key has an introducing version; keys up to the floor match the releases that added them', () => { + expect(Object.keys(POLICY_KEY_INTRODUCED).sort()).toEqual(Object.keys(DEFAULT_POLICY).sort()); + expect(POLICY_KEY_INTRODUCED.stamp_sensitive_gates).toBe('1.91.73.0'); + expect(POLICY_KEY_INTRODUCED.stamp_files).toBe('1.91.98.0'); + }); +}); + +describe('through the binaries', () => { + test('validate prints each set key with its introducing version and the minimum reader', () => { + const repo = fixture({ ...BASE_POLICY, gate_command: 'bun run test', full_e2e: true, min_gstack_version: SHIP_QUEUE_KEYS_VERSION }); + const r = runBin(repo, 'gstack-ship-policy', ['validate']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('KEY: bump introduced=1.91.73.0\n'); + expect(r.out).toContain(`KEY: gate_command introduced=${SHIP_QUEUE_KEYS_VERSION}\n`); + expect(r.out).toContain(`MIN_READER: gstack ${SHIP_QUEUE_KEYS_VERSION} `); + expect(JSON.parse(runBin(repo, 'gstack-ship-policy', ['validate', '--json']).out).min_reader).toBe(SHIP_QUEUE_KEYS_VERSION); + const explain = runBin(repo, 'gstack-ship-policy', ['init', '--explain']); + expect(explain.out).toMatch(new RegExp(`^full_e2e: .* default: false introduced: ${SHIP_QUEUE_KEYS_VERSION.replace(/\./g, '\\.')}$`, 'm')); + }, 60_000); + + test('a policy naming a newer gstack is refused by validate and by gstack-restamp, nothing written', () => { + const repo = fixture({ ...BASE_POLICY, min_gstack_version: '99.0.0.0' }); + const v = runBin(repo, 'gstack-ship-policy', ['validate']); + expect(v.status).toBe(EXIT.fail); + expect(v.err).toMatch(/this policy needs gstack 99\.0\.0\.0 or later; this gstack is .*\(POLICY_GSTACK_TOO_OLD\)/); + const head = repo.git('rev-parse', 'HEAD'); + const r = restamp(repo, ['--next', '--pr', '1']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain('(POLICY_GSTACK_TOO_OLD)'); + expect(repo.git('rev-parse', 'HEAD')).toBe(head); + expect(repo.git('status', '--porcelain')).toBe(''); + }, 60_000); + + test('a misspelled gate-reuse restriction fails closed: the tree receipt refuses instead of judging reuse without it', () => { + const policy: Record = { ...BASE_POLICY }; + delete policy.stamp_sensitive_gates; + policy.stamp_sensitive_gate = ['digest']; + const repo = fixture(policy); + const r = runBin(repo, 'gstack-tree-receipt', ['--gated', 'HEAD', '--base', 'main']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/unknown key .* at stamp_sensitive_gate\); fix: .*\(POLICY_INVALID\)/); + expect(r.out).not.toContain('gate-reuse: eligible'); + }, 60_000); +});