mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
6cf53f9fed
commit
878a7123d7
4 files changed
+219
-7
No files matched your search
@@ -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) }));
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user