feat(ship-policy): gate_command, full_e2e and min_gstack_version; unknown keys stay fail-closed; validate prints introducing versions

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-16 23:22:57 +00:00
1 parent 6cf53f9fed
commit 878a7123d7
4 files changed
+219 -7

No files matched your search

+8 -3
View File
@@ -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) }));
+36
View File
@@ -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 <skill>) 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<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+89 -4
View File
@@ -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<keyof ShipPolicy, '$schema'>;
@@ -100,8 +110,47 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
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<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,
};
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;
@@ -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<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; 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<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.
@@ -357,7 +442,7 @@ export function examplePolicyJson(overrides: Partial<ShipPolicy> = {}): 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. */
+86
View File
@@ -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<string, unknown>) => { 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<string, unknown> = { ...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);
});