diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 9eab2d38c..a49319b5f 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -36,6 +36,8 @@ import { ensureTmpExcluded } from '../lib/restamp'; import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes'; import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context'; import { cancelActive } from '../lib/pregate/supervise'; +import { slugFromEnvironment } from '../lib/bin-context'; +import { resolveStateRoot } from '../lib/state-root'; import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate'; import { GATE_AHEAD_REL } from '../lib/restamp'; import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard'; @@ -145,7 +147,8 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise/` lanes a local run satisfies (a + * platform-agnostic toolchain, or a repo whose CI is off): + * their tests run here instead of becoming requires-remote * guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo * command that must pass when a touched file matches `when` * (manifest checks, CLI goldens); never downgradable @@ -30,6 +33,10 @@ * remote_gate the repo's remote test gate command: under 8 cores the test * tier is a requires-remote obligation naming it, and * `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`) + * + * A repo that keeps `.gstack/` out of git can hold the same file at + * `/projects//pregate.json`, honoured only when the base + * carries none. It lives outside the repo, so a PR still cannot reach it. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -52,6 +59,7 @@ export interface PregateConfig { selection: Record; warn: string[]; strays: { allow: string[] }; + local: string[]; guards: Guard[]; serial_patterns: string[]; price_file: string | null; @@ -59,12 +67,13 @@ export interface PregateConfig { remote_gate: string | null; } export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number } -export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; +export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; export interface ConfigError { code: ResultCodeName; path: string; message: string } const MAX_LIST = 256; const MAX_COMMAND = 512; -const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; +const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; +const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/; const MAX_GUARDS = 32; const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s']; @@ -153,7 +162,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] const sel = obj.selection; if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of / → command' }); else for (const [lane, command] of Object.entries(sel as Record)) { - if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); + if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); const why = commandError(command); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why }); } @@ -176,6 +185,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] } } } + if ('local' in obj) { + if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of /' }); + else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be /` }); }); + } return errors; } @@ -186,10 +199,10 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []): const errors = validateConfig(raw, knownChecks); if (errors.length) return { config: DEFAULT_CONFIG, errors }; const o = raw as Partial; - return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; + return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; } -export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string }; +export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none' } | { unavailable: true; base: string; detail: string }; function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); @@ -211,6 +224,23 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' }; } +/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */ +export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null { + let text: string; + try { text = fs.readFileSync(file, 'utf8'); } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + // An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish). + return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' }; + } + const load = parseConfig(text, knownChecks); + // `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing. + let raw: unknown = null; + try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ } + const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record)[k] != null) : []; + const errors = [...load.errors, ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))]; + return { ...load, errors, present: true, label: `user ${file}`, kind: 'user' }; +} + export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } { return (load as any).unavailable === true; } @@ -224,6 +254,7 @@ export function exampleConfigJson(): string { selection: {}, warn: [], strays: { allow: [] }, + local: [], }; return JSON.stringify(example, null, 2) + '\n'; } @@ -236,11 +267,13 @@ export function explainConfig(): string { 'selection: / → the runner-owned command that lists that lane’s test files when its matrix is planner-computed', `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`, 'strays.allow: root files or new top-level directories that are intentional', + 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`, 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)', + 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (outside the repo; a PR cannot reach it)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 0238261b1..966fa2d39 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -15,7 +15,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes'; import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation'; import { policyUnavailableLine } from '../ship-policy'; -import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config'; +import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config'; import type { CheckResult, PregateContext, TouchedFile } from './types'; import { discoverLanes } from './workflows'; import type { RegistryError } from '../regen'; @@ -27,6 +27,8 @@ export interface BuildContextOptions { stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null; /** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */ invocation?: InvocationOptions; + /** `/projects//pregate.json`: read only when the base carries no pregate.json. */ + userConfigPath?: string; } export const DEFAULT_STALL_TIMEOUT_S = 300; @@ -110,9 +112,10 @@ export function buildContext(o: BuildContextOptions): ContextLoad { if (!baseSha.ok || !baseSha.out) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } }; const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out; const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out; - const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks); + let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks); + if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') configLoad = loadConfigFromUser(o.userConfigPath, o.knownChecks) ?? configLoad; if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } }; - if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors } }; + if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } }; const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base); if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } }; if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } }; diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 9a6844d39..06dd9a934 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -122,12 +122,13 @@ export function selectLanes(ctx: PregateContext): Selection { const set = laneFileSet(ctx, lane); if (set !== 'all') laneSets[lane.id] = set; const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t)); - if (lane.tier === 'platform') { + if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`); + else if (lane.tier === 'platform') { if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null }); continue; } if (set === 'all') anyLocalAll = true; - if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); } + if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); } } if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t); for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t); diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts new file mode 100644 index 000000000..9055a4a4a --- /dev/null +++ b/test/pregate-user-policy.test.ts @@ -0,0 +1,131 @@ +/** + * The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/` + * out of git declares its policy at `/projects//pregate.json`; + * the base branch's copy always wins, and `--policy-from head` never reads the + * user file. A platform lane named in `local` runs here instead of becoming + * requires-remote. Every assertion holds on any host platform: each lane the + * fixture selects is either named in `local` or asserted only by label. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { loadConfigFromUser, validateConfig } from '../lib/pregate/config'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const SLUG = 'userpolicy'; +const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard']; + +/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */ +function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType } { + const repo = makeFixtureRepo({ pregate: null }); + repos.push(repo); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG }); + return { repo, userPolicy, run }; +} +const writePolicy = (file: string, policy: Record) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy })); + +describe('pregate.json local', () => { + // Value: protects=`local` accepts only / lane ids, shared with selection keys; fails_when=a bare job name or a + // string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects + test('takes / ids only', () => { + expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]); + expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']); + expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']); + expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']); + expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']); + }); +}); + +describe('loadConfigFromUser', () => { + // Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults; + // fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none; + // no_claim=EACCES specifically (a directory stands in for an unreadable file) + test('ENOENT is null; an unreadable file refuses at its path', () => { + const { userPolicy } = setup(); + expect(loadConfigFromUser(userPolicy)).toBeNull(); + fs.mkdirSync(userPolicy); + const load = loadConfigFromUser(userPolicy); + expect(load && 'errors' in load ? load.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]); + expect(load && 'kind' in load ? [load.kind, load.label] : null).toEqual(['user', `user ${userPolicy}`]); + }); +}); + +describe('gstack-pregate with a user-owned policy', () => { + // Value: protects=with no base copy the user file is the policy and its `local` lanes run here, while an unnamed platform lane stays + // requires-remote; fails_when=the user fallback or the `local` branch in selectLanes is dropped; why_new=both are new; + // seam=none; no_claim=a real CI host (lanes are judged by the fixture's workflows) + test('the user file is read when the base has none, and a local lane runs here instead of requires-remote', () => { + const { userPolicy, run } = setup(); + expect(run().out).toContain('(no .gstack/pregate.json)'); + + writePolicy(userPolicy, { local: ['free-tests.yml/unit'] }); + const partial = run(); + expect(partial.out).toContain(`policy user ${userPolicy}`); + expect(partial.out).toContain('requires-remote windows.yml/windows-shard (windows)'); + expect(partial.out).not.toContain('requires-remote free-tests.yml/unit'); + + writePolicy(userPolicy, { local: LOCAL_ALL }); + const all = run(['--explain']); + expect(all.status).toBe(EXIT.ok); + expect(all.out).toContain('Tiers ran: unit; requires-remote: none'); + expect(all.out).toContain('lane windows.yml/windows-shard: windows lane satisfied locally (pregate.json local)'); + expect(all.out).toMatch(/^tests {6}lanes {5}pass /m); + }, 60_000); + + // Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix + // that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted + // and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself + test('an invalid, sealed or unreadable user file refuses and names the file', () => { + const { userPolicy, run } = setup(); + writePolicy(userPolicy, { local: ['not-a-lane'] }); + const invalid = run(); + expect(invalid.status).toBe(EXIT.refused); + expect(invalid.err).toContain('PREGATE_CONFIG_INVALID'); + expect(invalid.err).toContain(`user ${userPolicy}`); + expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`); + + for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) { + writePolicy(userPolicy, { [key]: value }); + const r = run(); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain(`covers gstack-pregate run only; ${key}`); + } + // A null price_file is the documented default, not a policy, so it is accepted. + writePolicy(userPolicy, { price_file: null, local: LOCAL_ALL }); + expect(run().err).not.toContain('PREGATE_CONFIG_INVALID'); + + fs.rmSync(userPolicy); + fs.mkdirSync(userPolicy); + const unreadable = run(); + expect(unreadable.status).toBe(EXIT.refused); + expect(unreadable.err).toContain('cannot read the user-owned policy'); + }, 60_000); + + // Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never + // read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the + // fallback is new; seam=none; no_claim=none beyond protects + test('--policy-from head and a base copy never read the user file', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: ['not-a-lane'] }); + + const head = run(['--policy-from', 'head']); + expect(head.err).not.toContain('PREGATE_CONFIG_INVALID'); + expect(head.out).not.toContain('policy user'); + + commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' }); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const based = run(); + expect(based.err).not.toContain('PREGATE_CONFIG_INVALID'); + expect(based.out).toContain('origin/main@'); + expect(based.out).not.toContain('policy user'); + }, 60_000); +});