mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
pregate: --policy-from head only adds lanes; timeout_s policy default; preflight forwards --policy-from (GST-419, GST-420)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
047dad78ba
commit
ff71dec209
17 files changed
+423
-59
No files matched your search
+11
-8
@@ -67,15 +67,17 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
|
||||
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
|
||||
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
|
||||
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
|
||||
--out <file> pregate.json path (default ${PREGATE_OUT_REL})
|
||||
--timeout <s> timeout for each runner process (default 600): the unit-file group, each serial file, each guard
|
||||
without timeout_s; the stall timeout catches a silent one sooner
|
||||
--timeout <s> timeout for each runner process (default timeout_s from the base .gstack/pregate.json, else 600):
|
||||
the unit-file group, each serial file, each guard without timeout_s, the remote gate; the stall
|
||||
timeout catches a silent one sooner
|
||||
--stall-timeout <s> kill a test command's process group after this long with no output and record the check
|
||||
incomplete (PREGATE_STALLED); default stall_timeout_s from the base .gstack/pregate.json,
|
||||
else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands and
|
||||
--policy-from base); it clears the requires-remote obligation only on its
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands;
|
||||
under --policy-from head the gate is still the base copy's); it clears the requires-remote obligation only on its
|
||||
PREGATE_REMOTE: tree=<tested tree> tests=<n> failed=0 line with n > 0
|
||||
--spend-cap <usd> the cap --remote passes to the gate (default \$GSTACK_SPEND_CAP_USD); spend is reported as
|
||||
spent, reserved and unknown
|
||||
@@ -139,14 +141,15 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
if (!['base', 'head'].includes(policyFrom)) { process.stderr.write('gstack-pregate: --policy-from needs base|head\n'); return EXIT.usage; }
|
||||
const allow = rest.includes('--allow-repo-commands');
|
||||
if (policyFrom === 'head' && !allow) { process.stderr.write('gstack-pregate: --policy-from head needs --allow-repo-commands (the working-tree pin files are unreviewed)\n'); return EXIT.refused; }
|
||||
const timeoutMs = Number(flag(rest, '--timeout') ?? 600) * 1000;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const timeoutFlag = flag(rest, '--timeout');
|
||||
const timeoutMs = timeoutFlag === undefined ? undefined : Number(timeoutFlag) * 1000;
|
||||
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const stallFlag = flag(rest, '--stall-timeout');
|
||||
const stallTimeoutMs = stallFlag === undefined ? undefined : Number(stallFlag) * 1000;
|
||||
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
|
||||
let remote: { spendCapUsd: number } | null = null;
|
||||
if (rest.includes('--remote')) {
|
||||
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
|
||||
if (!allow) { process.stderr.write('gstack-pregate: --remote runs the trusted policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none; a head policy may not change it) and needs --allow-repo-commands\n'); return EXIT.refused; }
|
||||
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
|
||||
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
|
||||
remote = { spendCapUsd: cap };
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
**`--policy-from head` now only adds test lanes, and the repo can set pregate's per-process timeout.** Two /ship tooling gaps cost other crews a rerun each. GEVA-2 (gstack-evals#93) had a PR that added its own pregate lane, but `gstack-ship-preflight` could not pass `--policy-from head`, so Step 5 failed with `PREGATE_NO_LANE` and the lane called runLanes by hand (GST-419). HARN-1 (HARN-12) had lanes of about 780 s, while preflight and the `finish` fallback ran pregate at its 600 s default, so the result was always `PREGATE: incomplete` (GST-420). There was also a hole behind the first gap: `--policy-from head` took the branch's whole `.gstack/pregate.json`, so a branch could delete its own lanes.
|
||||
|
||||
### Added
|
||||
- **`timeout_s` in `.gstack/pregate.json`** (1 to 3600 s): gstack-pregate's per-process timeout when `--timeout` is not passed, so preflight, `finish` and plain runs all pick it up. The remote gate gets the same value as `PREGATE_TIMEOUT_S`. A timed-out lane names where its timeout came from (`--timeout`, `timeout_s from <policy>` or the 600 s default).
|
||||
- **`gstack-ship-preflight --policy-from head`** forwards to Step 5's pregate. When pregate does not pass, preflight prints up to three `PREGATE_DETAIL:` lines with pregate's own reasons, and a refusal reads `PREGATE: refused (<code>)` instead of `unreadable`.
|
||||
|
||||
### Changed
|
||||
- **A head policy may only add test lanes** (`PREGATE_HEAD_POLICY_LOOSENS`). It may add a `dependencies` glob with at least one test, or add tests to a glob the trusted policy already maps. Anything else that differs from the trusted policy refuses the run and names each difference. The trusted policy is the base copy, or the user-owned file when the base has none. A branch behind its base is measured from the copy at its merge base and runs the base tip's policy plus its additions. A head file with no trusted policy anywhere is still a bootstrap.
|
||||
- **`--remote` works with `--policy-from head`** once that check passes, so a lane on a machine with under 8 cores can clear its obligation. It still runs the trusted copy's `remote_gate`.
|
||||
- **Under `--policy-from head`, an invalid user-owned policy now refuses** (`PREGATE_CONFIG_INVALID`) instead of being ignored, because it is the policy the head file is compared with.
|
||||
- **A `[]` declaration added only on the branch is refused under `--policy-from head`**, the Dart device-test workaround included: declare `"**/integration_test/**": []` on the base branch first. The `PREGATE_NO_LANE` fix line and docs say so.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-head-policy.test.ts`: a head policy that adds a lane passes; one that removes or swaps a lane, adds `[]`, changes the runner, downgrades a check, allowlists a finding or deletes the file is refused; reorders are not changes; a stale branch keeps the base's newer lanes; the user-owned policy is compared when the base has none; a bootstrap still runs; `timeout_s` bounds each runner process, `--timeout` overrides it, and out-of-range values are invalid.
|
||||
- `test/ship-preflight.test.ts`: `--policy-from head` reaches pregate, a removed lane stops with `PREGATE_HEAD_POLICY_LOOSENS`, and `timeout_s` reaches preflight's pregate with the cause printed.
|
||||
- `test/pregate-remote.test.ts`: `--remote` under head runs the base gate with the `timeout_s` deadline (or `--timeout`), and a head policy that changes the gate is refused. `test/pregate-dart.test.ts` and `test/pregate-user-policy.test.ts` follow the two behavior changes above.
|
||||
+23
-10
@@ -25,9 +25,20 @@ passes. Standalone, `gstack-regen check` lists the commands and exits 3
|
||||
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
|
||||
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
|
||||
changes a pin file) is accepted only with `--allow-repo-commands` and the record
|
||||
says `policy head (unreviewed)`. So a lane or allowlist entry a branch adds to
|
||||
its own `pregate.json` counts only once it is on the base; `PREGATE_NO_LANE`'s
|
||||
fix line says so when the branch edits the file
|
||||
says `policy head (unreviewed)`. Under it the branch's `pregate.json` may only
|
||||
add test lanes over the trusted policy (the base copy, or the user-owned file
|
||||
when the base has none): a new `dependencies` glob with at least one test, or
|
||||
more tests on a glob the trusted policy already maps. A removed or shrunk glob,
|
||||
a new `[]` declaration, or any other key that differs (runner, guards, warn,
|
||||
allowlist, timeouts, the remote gate, ...) refuses the run with
|
||||
[`PREGATE_HEAD_POLICY_LOOSENS`](troubleshooting.md#pregate-head-policy-loosens).
|
||||
A branch behind its base is measured from the copy at its merge base and runs
|
||||
the base tip's policy plus its additions. With no trusted policy at all, the
|
||||
head file is a bootstrap and runs as written. `.gstack/generated.json` is still
|
||||
read from the branch under `--policy-from head`. So an allowlist entry or a
|
||||
`[]` declaration a branch adds counts only once it is on the base, and a lane it
|
||||
adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line
|
||||
says so when the branch edits the file
|
||||
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with
|
||||
`POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute
|
||||
paths, at most 64 per list); commands are one line under 512 bytes.
|
||||
@@ -75,6 +86,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
|
||||
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
|
||||
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
|
||||
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
|
||||
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
|
||||
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
|
||||
|
||||
@@ -86,7 +98,7 @@ state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
|
||||
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
|
||||
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
|
||||
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
|
||||
(never under `--policy-from head`), it lives outside the repository so a PR still
|
||||
(under `--policy-from head` it is the trusted policy the head file is compared with), it lives outside the repository so a PR still
|
||||
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
|
||||
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
|
||||
ship receipt treat an edited or deleted user file, or one that appears (or a
|
||||
@@ -207,7 +219,8 @@ Two stages with separate clocks, both printed:
|
||||
`*_test.dart` files under a `test/` directory are tests, except under `integration_test/` and
|
||||
`test_driver/`: they need a device, so pregate never runs them, and a touched
|
||||
one has no lane until you declare `"**/integration_test/**": []` (and
|
||||
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
|
||||
`"**/test_driver/**": []`) under `dependencies` on the base branch and let CI run them
|
||||
(a `[]` only on the branch is refused under `--policy-from head`); Dart files, device tests
|
||||
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
|
||||
does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a
|
||||
test when it sits under `<crate>/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming
|
||||
@@ -429,7 +442,7 @@ the supervision above. Close the obligation one of two ways:
|
||||
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
|
||||
has no machine-checkable receipt and is recorded as `attested:<url>`; or
|
||||
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
|
||||
which runs the declared `remote_gate` now (never under `--policy-from head`;
|
||||
which runs the declared `remote_gate` now (under `--policy-from head`, still the trusted copy's gate;
|
||||
a `test_backend` without `remote_gate` cannot be run this way). The gate runs
|
||||
in the repository root with `PREGATE_TREE` (the working-tree fingerprint
|
||||
being tested), `PREGATE_FILES_FILE` (the selected files, one per line) and
|
||||
@@ -548,10 +561,10 @@ are named and left to the paid PR gate; a selection of only paid files reports
|
||||
`tests=0`, which never clears the obligation. The command is the same either way
|
||||
(`--remote` always needs a cap; the keyless path spends nothing).
|
||||
|
||||
pregate kills the gate at its `--timeout` (default 600 s) with a 5 s grace, so
|
||||
the script finishes before that deadline: it reads the deadline from
|
||||
`PREGATE_TIMEOUT_S`, else from the `gstack-pregate` command it runs under (its
|
||||
`--timeout`, else 600), reserves 120 s for teardown plus 15 s margin, and tells
|
||||
pregate kills the gate at its `--timeout` (default the policy's `timeout_s`, else
|
||||
600 s) with a 5 s grace, so the script finishes before that deadline: it reads the
|
||||
deadline from `PREGATE_TIMEOUT_S`, which pregate sets to that same timeout, else from
|
||||
the `gstack-pregate` command it runs under (its `--timeout`, else 600), reserves 120 s for teardown plus 15 s margin, and tells
|
||||
the VM to stop at whichever comes first, that point or the cap's wall (at most
|
||||
45 minutes, minus 3 for teardown). A deadline that leaves the VM under 300 s
|
||||
refuses before any VM (`PREGATE_REMOTE_TIMEOUT_TOO_SHORT`). At create time it
|
||||
|
||||
+30
-7
@@ -3361,7 +3361,10 @@ for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or
|
||||
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
|
||||
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
|
||||
never runs them: declare `"**/integration_test/**": []` (and
|
||||
`"**/test_driver/**": []`) under `dependencies` and let CI run them.
|
||||
`"**/test_driver/**": []`) under `dependencies` on the base branch and let CI run
|
||||
them. A `[]` declaration added only on the branch is refused under
|
||||
`--policy-from head` ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)),
|
||||
so land it in its own PR first.
|
||||
With no policy anywhere (no base copy, no user-owned file) an unmapped file is
|
||||
the warning [`PREGATE_NO_POLICY`](#pregate-no-policy) with a starter policy
|
||||
printed, not this error; `gstack-pregate init` writes that draft and lists the
|
||||
@@ -3385,13 +3388,33 @@ two routes:
|
||||
|
||||
1. Land the policy change in its own PR first (the reviewed route), then merge
|
||||
the base into this branch and rerun.
|
||||
2. Rerun with `--policy-from head --allow-repo-commands`. The run reads the
|
||||
branch's copy and its record says `policy head (unreviewed)`, so a
|
||||
reviewer sees that the gate ran under a policy nobody has reviewed yet;
|
||||
`gstack-ship-queue` never accepts a head-policy run as a remote mirror.
|
||||
2. Rerun with `--policy-from head --allow-repo-commands` (in /ship,
|
||||
`gstack-ship-preflight --tests-only --policy-from head`). The run uses the
|
||||
trusted policy plus the lanes the branch adds, and its record says
|
||||
`policy head (unreviewed)`, so a reviewer sees that the gate ran under a
|
||||
policy nobody has reviewed yet; `gstack-ship-queue` never accepts a
|
||||
head-policy run as a remote mirror. A head policy that does anything but add
|
||||
test lanes is refused ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)).
|
||||
|
||||
An allowlist entry the branch adds to its own `pregate.json` is read the same
|
||||
way: it clears a finding only once it is on the base.
|
||||
An allowlist entry or a `[]` declaration the branch adds to its own
|
||||
`pregate.json` counts only once it is on the base.
|
||||
|
||||
<a id="pregate-head-policy-loosens"></a>
|
||||
### `PREGATE_HEAD_POLICY_LOOSENS`
|
||||
|
||||
**Meaning.** Under `--policy-from head`, the branch's `.gstack/pregate.json`
|
||||
does more than add test lanes over the trusted policy (the base branch's copy,
|
||||
or the user-owned file when the base has none). The message lists each
|
||||
difference: a `dependencies` glob removed, a test dropped from a glob, a new
|
||||
`[]` declaration, a deleted file, or another key (`runner`, `guards`, `warn`,
|
||||
`allowlist`, `timeout_s`, `remote_gate`, ...) that differs. A branch behind
|
||||
its base is compared with the copy at its merge base, so a lane the base added
|
||||
later is not a difference. Nothing ran and no record was written.
|
||||
|
||||
**Fix.** Keep every key of the trusted policy as it is and only add
|
||||
`dependencies` globs with at least one test, or tests on an existing glob. Land
|
||||
any other change, a `[]` declaration included, in its own PR on the base branch
|
||||
first, then merge the base into this branch and rerun.
|
||||
|
||||
<a id="pregate-lane-failed"></a>
|
||||
### `PREGATE_LANE_FAILED`
|
||||
|
||||
@@ -344,7 +344,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
const killed = r.runs.find(x => x.outcome === 'exited' && x.exit === null);
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `the runner process ended without an exit code (killed by ${killed?.signal ?? 'a signal'}; ${total} files; last file started: ${killed?.lastFile ?? 'none reported'}${rest})`, fix: `read ${r.log}; a runner killed by a signal (out of memory, an outside kill) never passes; rerun, or split the selection` };
|
||||
}
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--timeout'} applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
}
|
||||
|
||||
function describe(sel: Selection, noPolicy = false): string[] {
|
||||
@@ -401,7 +401,8 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
|
||||
|
||||
/**
|
||||
* `--remote`: run the declared remote gate now, under the spend cap. The
|
||||
* gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and
|
||||
* gate gets PREGATE_TREE, PREGATE_FILES_FILE, PREGATE_SPEND_CAP_USD and
|
||||
* PREGATE_TIMEOUT_S (this run's per-process timeout, whole seconds) and
|
||||
* must print `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`;
|
||||
* the obligation clears only when that tree is the tested tree, tests > 0,
|
||||
* failed = 0 and the gate exited 0. Anything else leaves it uncleared and
|
||||
@@ -417,7 +418,7 @@ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult)
|
||||
const filesFile = path.join(dir, 'files.txt');
|
||||
fs.writeFileSync(log, '');
|
||||
fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n');
|
||||
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
|
||||
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap), PREGATE_TIMEOUT_S: String(Math.floor(ctx.timeoutMs / 1000)) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
|
||||
const output = readRange(log, r.start, r.end);
|
||||
const reported = parseRemoteReport(output);
|
||||
const spent = reported?.spent;
|
||||
@@ -527,10 +528,10 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
* routes; it never switches to the head policy on its own.
|
||||
*/
|
||||
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
|
||||
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
|
||||
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) on ${ctx.base} and let CI run them` : '';
|
||||
const fix = `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
|
||||
if (ctx.configKind === 'head' || !ctx.touched.some(t => t.path === PREGATE_CONFIG_PATH && t.status !== 'D')) return fix;
|
||||
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands, which records the run as policy head (unreviewed); otherwise ${fix}`;
|
||||
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands (gstack-ship-preflight forwards it; a head policy may only add test lanes, and a [] declaration must land on ${ctx.base} first), which records the run as policy head (unreviewed); otherwise ${fix}`;
|
||||
}
|
||||
|
||||
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
|
||||
+11
-3
@@ -30,6 +30,9 @@
|
||||
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
|
||||
* stall_timeout_s seconds without test output before the lanes check kills
|
||||
* the runner's process group (default 300; 0 disables)
|
||||
* timeout_s seconds each runner process may take (the unit-file group,
|
||||
* each serial file, each guard without its own timeout_s, the
|
||||
* remote gate) when `--timeout` is not passed (default 600)
|
||||
* 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=…`)
|
||||
@@ -78,17 +81,18 @@ export interface PregateConfig {
|
||||
serial_patterns: string[];
|
||||
price_file: string | null;
|
||||
stall_timeout_s: number | null;
|
||||
timeout_s: number | null;
|
||||
remote_gate: string | null;
|
||||
allowlist: AllowlistEntry[];
|
||||
}
|
||||
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
|
||||
export interface AllowlistEntry { finding: string; paths?: string[]; pattern?: string; reason: string }
|
||||
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, allowlist: [] };
|
||||
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, timeout_s: null, remote_gate: null, allowlist: [] };
|
||||
export interface ConfigError { code: ResultCodeName; path: string; message: string }
|
||||
|
||||
export const MAX_LIST = 256;
|
||||
const MAX_COMMAND = 512;
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate', 'allowlist'];
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'];
|
||||
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'];
|
||||
@@ -172,6 +176,9 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
if ('stall_timeout_s' in obj && obj.stall_timeout_s !== null && (!Number.isInteger(obj.stall_timeout_s) || (obj.stall_timeout_s as number) < 0 || (obj.stall_timeout_s as number) > 86_400)) {
|
||||
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'stall_timeout_s', message: 'must be an integer number of seconds from 0 (disabled) to 86400, or null' });
|
||||
}
|
||||
if ('timeout_s' in obj && obj.timeout_s !== null && (!Number.isInteger(obj.timeout_s) || (obj.timeout_s as number) < 1 || (obj.timeout_s as number) > 3600)) {
|
||||
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'timeout_s', message: 'must be an integer number of seconds from 1 to 3600 (preflight and finish allow gstack-pregate one hour), or null' });
|
||||
}
|
||||
if ('remote_gate' in obj && obj.remote_gate !== null) {
|
||||
const why = commandError(obj.remote_gate);
|
||||
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'remote_gate', message: why });
|
||||
@@ -244,7 +251,7 @@ 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<PregateConfig>;
|
||||
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, allowlist: o.allowlist ?? [] }, 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, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [] }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
|
||||
@@ -324,6 +331,7 @@ export function explainConfig(): string {
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (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)',
|
||||
'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600',
|
||||
'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=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
|
||||
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
|
||||
|
||||
+13
-6
@@ -18,7 +18,8 @@ import { resolveStateRoot } from '../state-root';
|
||||
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
|
||||
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
|
||||
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import { headPolicy } from './head-policy';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import type { CheckResult, PregateContext, TouchedFile } from './types';
|
||||
import { discoverLanes } from './workflows';
|
||||
import { createScratch, scratchRunId } from './run-scratch';
|
||||
@@ -47,7 +48,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
|
||||
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
|
||||
}
|
||||
export interface ContextRefusal {
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
message: string;
|
||||
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
|
||||
fix?: string;
|
||||
@@ -211,17 +212,23 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { 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;
|
||||
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
const explain: string[] = [];
|
||||
let absentUserPolicy: string | undefined;
|
||||
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
|
||||
if (o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
|
||||
const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
|
||||
const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
|
||||
if (user) configLoad = user;
|
||||
else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`);
|
||||
if (!user && !rejected) absentUserPolicy = o.userConfigPath;
|
||||
if (!user && !rejected && o.policyFrom === 'base') absentUserPolicy = o.userConfigPath;
|
||||
}
|
||||
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
|
||||
if (o.policyFrom === 'head' && !configLoad.errors.length) {
|
||||
const head = headPolicy(o.repoRoot, configLoad, mergeBase, base, o.knownChecks ?? []);
|
||||
if ('loosens' in head) return { refused: { code: 'PREGATE_HEAD_POLICY_LOOSENS', message: head.loosens.join('; ') } };
|
||||
configLoad = head.load;
|
||||
explain.push(...head.explain);
|
||||
}
|
||||
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 } };
|
||||
@@ -240,7 +247,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default',
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* pregate/head-policy — `--policy-from head` (GST-419): a branch's own
|
||||
* `.gstack/pregate.json` may only add test lanes over the trusted policy (the
|
||||
* base branch's copy, or the user-owned file when the base has none). A lane
|
||||
* is a `dependencies` glob with a non-empty test list, or extra tests on a glob
|
||||
* the trusted policy already maps. Everything else (a removed or shrunk glob, a
|
||||
* new `[]` declaration, the runner, guards, warn, allowlist, timeouts, the
|
||||
* remote gate, ...) must equal the trusted copy, or the run is refused with
|
||||
* PREGATE_HEAD_POLICY_LOOSENS. A branch behind its base is compared with the
|
||||
* copy at its merge base and runs the base tip's policy plus its additions, so
|
||||
* a lane the base gained later never reads as a removal. With no trusted policy
|
||||
* at all, the head file is a bootstrap and runs as written. `sealed` is not
|
||||
* compared: its guard reads the base branch's copy itself.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { PREGATE_CONFIG_PATH, loadConfigFromHead, parseConfig, type ConfigLoad, type PregateConfig } from './config';
|
||||
|
||||
type Loaded = Exclude<ConfigLoad, { unavailable: true }>;
|
||||
|
||||
const FROZEN = ['runner', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'] as const;
|
||||
|
||||
const canonical = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])]));
|
||||
return v;
|
||||
};
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
||||
|
||||
/** Each way `head` loosens `ref`, and the tests it adds per glob. */
|
||||
export function headPolicyDiff(ref: PregateConfig, head: PregateConfig, base: string): { loosens: string[]; adds: Record<string, string[]> } {
|
||||
const loosens: string[] = FROZEN.filter(k => !same(ref[k], head[k])).map(k => `${k} differs from the trusted policy`);
|
||||
const adds: Record<string, string[]> = {};
|
||||
for (const [glob, tests] of Object.entries(ref.dependencies)) {
|
||||
const now = head.dependencies[glob];
|
||||
if (!now) { loosens.push(`dependencies[${JSON.stringify(glob)}] is removed`); continue; }
|
||||
const dropped = tests.filter(t => !now.includes(t));
|
||||
if (dropped.length) loosens.push(`dependencies[${JSON.stringify(glob)}] drops ${dropped.join(', ')}`);
|
||||
const extra = now.filter(t => !tests.includes(t));
|
||||
if (extra.length) adds[glob] = extra;
|
||||
}
|
||||
for (const [glob, tests] of Object.entries(head.dependencies)) {
|
||||
if (glob in ref.dependencies) continue;
|
||||
if (!tests.length) loosens.push(`dependencies[${JSON.stringify(glob)}] declares no lane needed; a [] declaration must land on ${base} first`);
|
||||
else adds[glob] = tests;
|
||||
}
|
||||
return { loosens, adds };
|
||||
}
|
||||
|
||||
/** The policy at the merge base when it carries a valid copy; the branch's additions are measured from there. */
|
||||
function forkCopy(repoRoot: string, mergeBase: string, knownChecks: readonly string[]): PregateConfig | null {
|
||||
const r = spawnSync('git', ['show', `${mergeBase}:${PREGATE_CONFIG_PATH}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
if (r.status !== 0 || r.error) return null;
|
||||
const parsed = parseConfig(r.stdout, knownChecks);
|
||||
return parsed.errors.length ? null : parsed.config;
|
||||
}
|
||||
|
||||
/** The policy a `--policy-from head` run uses over `trusted`, or what the head file loosens. */
|
||||
export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string, base: string, knownChecks: readonly string[]): { load: Loaded; explain: string[] } | { loosens: string[] } {
|
||||
const head = loadConfigFromHead(repoRoot, knownChecks) as Loaded;
|
||||
if (trusted.kind === 'none' || head.errors.length) return { load: head, explain: [] };
|
||||
if (head.kind === 'none') return { loosens: [`${PREGATE_CONFIG_PATH} is absent at head while ${trusted.label} declares a policy`] };
|
||||
const ref = (trusted.kind === 'base' && forkCopy(repoRoot, mergeBase, knownChecks)) || trusted.config;
|
||||
const { loosens, adds } = headPolicyDiff(ref, head.config, base);
|
||||
if (loosens.length) return { loosens };
|
||||
const dependencies = { ...trusted.config.dependencies };
|
||||
for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])];
|
||||
const n = Object.keys(adds).length;
|
||||
return {
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export interface PregateContext {
|
||||
platform: NodeJS.Platform;
|
||||
allowRepoCommands: boolean;
|
||||
timeoutMs: number;
|
||||
/** Where timeoutMs came from: `--timeout`, `timeout_s from <policy>`, or the 600 s default. */
|
||||
timeoutSource?: string;
|
||||
/** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
|
||||
stallTimeoutMs: number;
|
||||
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
|
||||
|
||||
@@ -341,4 +341,9 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the watcher did not start, or a local error stopped it before a verdict',
|
||||
fix: 'pass a PR number or URL the current gh login can read (--repo owner/name when the checkout is not that repository), stop the watcher the line names before starting another for the same PR, or fix the named local error',
|
||||
},
|
||||
PREGATE_HEAD_POLICY_LOOSENS: {
|
||||
anchor: 'pregate-head-policy-loosens',
|
||||
summary: 'under --policy-from head, the branch’s .gstack/pregate.json removes, shrinks or loosens something the trusted policy declares; a head policy may only add test lanes',
|
||||
fix: 'keep every key of the trusted policy as it is and only add dependencies globs with tests (or tests on an existing glob); land any other change, a [] declaration included, in its own PR on the base branch first',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
+16
-6
@@ -15,11 +15,14 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
|
||||
|
||||
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
|
||||
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
|
||||
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
|
||||
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
|
||||
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
|
||||
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
|
||||
|
||||
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
|
||||
RUNTIME_LINES, PREGATE, one LANE line per lane, then NEXT: lines. STOP: names what the agent must
|
||||
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), one LANE line per lane,
|
||||
then NEXT: lines. STOP: names what the agent must
|
||||
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||||
|
||||
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
|
||||
@@ -100,11 +103,16 @@ function merge(cwd: string, base: string, r: Report): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
|
||||
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
|
||||
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
r.add(`PREGATE: ${verdict}`);
|
||||
if (/^(fail|incomplete|unreadable)/.test(verdict)) { r.stop(`pregate ${verdict}: fix the failing checks it lists (gstack-pregate --allow-repo-commands), then rerun --tests-only`); return; }
|
||||
if (/^(fail|incomplete|unreadable|refused)/.test(verdict)) {
|
||||
for (const line of pg.stderr.split('\n').filter(l => l.startsWith('gstack-pregate: ')).slice(0, 3)) r.add(`PREGATE_DETAIL: ${line.length > 300 ? `${line.slice(0, 220)} … ${line.slice(-80)}` : line}`);
|
||||
r.stop(`pregate ${verdict}: fix what PREGATE_DETAIL names (gstack-pregate --allow-repo-commands${policyFrom === 'head' ? ' --policy-from head' : ''}), then rerun --tests-only`);
|
||||
return;
|
||||
}
|
||||
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
|
||||
const results = (await runLanes(cwd, list)).map(laneLine);
|
||||
for (const l of results) r.add(l.line);
|
||||
@@ -123,11 +131,13 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
if (!base || !out) return { text: 'gstack-ship-preflight: --audit needs --base <base> --out <dir>\n', exit: 2 };
|
||||
return { text: audit(cwd, base, out).render(), exit: 0 };
|
||||
}
|
||||
const policyFrom = flag('--policy-from') ?? 'base';
|
||||
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
|
||||
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
|
||||
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
|
||||
const r = new Report();
|
||||
if (has('--tests-only') || integrate(cwd, base, r)) {
|
||||
if (list.length) await tests(cwd, base, list, r);
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, r);
|
||||
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
|
||||
}
|
||||
return { text: r.render(), exit: r.exit };
|
||||
|
||||
@@ -117,7 +117,8 @@ usd() { awk -v m="$1" -v p="$ppm" 'BEGIN { s = m * p; c = int(s * 100); if (c <
|
||||
if [ "$wall_min" -lt "$min_min" ]; then
|
||||
stop 5 PREGATE_REMOTE_CAP_TOO_LOW "worst case \$$(usd "$min_min") ($min_min min x \$$ppm/min on $size) exceeds the spend cap \$$cap" "nothing created, nothing spent" "rerun with --spend-cap $(usd "$min_min") or more"
|
||||
fi
|
||||
# The enclosing gstack-pregate kills this gate at its --timeout (default 600 s)
|
||||
# The enclosing gstack-pregate kills this gate at its --timeout (default the
|
||||
# policy's timeout_s, else 600 s; pregate passes it as PREGATE_TIMEOUT_S)
|
||||
# with a 5 s grace, so the VM must be told to stop early enough for teardown to
|
||||
# be confirmed first: 120 s reserved for teardown plus 15 s margin. The deadline
|
||||
# comes from PREGATE_TIMEOUT_S, else from the gstack-pregate process this gate
|
||||
|
||||
@@ -553,8 +553,9 @@ describe('gstack-pregate on a Dart repo with a committed policy', () => {
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=a touched device test (integration_test/) is never run locally, fails with a fix naming .gstack/pregate.json and the
|
||||
// `[]` remedy, and that documented declaration clears it; fails_when=the docs' remedy stops working or the fix loses the device hint;
|
||||
// why_new=device tests were only checked at isTestFile; seam=none; no_claim=CI running the device test
|
||||
// `[]` remedy, and that documented declaration clears it once it is on the base (a branch-only [] under --policy-from head is refused,
|
||||
// GST-419); fails_when=the docs' remedy stops working, the fix loses the device hint, or a head [] runs; why_new=device tests were only
|
||||
// checked at isTestFile; seam=none; no_claim=CI running the device test
|
||||
test('a touched integration test (root or nested package) has no local lane until declared []', () => {
|
||||
const repo = dartRepo();
|
||||
commit(repo, { 'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() {}\n" });
|
||||
@@ -570,7 +571,14 @@ describe('gstack-pregate on a Dart repo with a committed policy', () => {
|
||||
expect(stuck.out).toContain('FAIL lanes — packages/core/integration_test/core_it_test.dart has no test lane');
|
||||
expect(stuck.out).toContain('in .gstack/pregate.json; pregate does not run device tests, so declare "**/integration_test/**": []');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...DART_PREGATE, dependencies: { ...DART_PREGATE.dependencies, '**/integration_test/**': [] } }) + '\n' });
|
||||
const declared = run(repo, env, ['--policy-from', 'head']);
|
||||
const headOnly = run(repo, env, ['--policy-from', 'head']);
|
||||
expect(headOnly.status).toBe(EXIT.refused);
|
||||
expect(headOnly.err).toContain('a [] declaration must land on main first');
|
||||
expect(headOnly.err).toContain('(PREGATE_HEAD_POLICY_LOOSENS');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
commit(repo, { 'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() { }\n", 'packages/core/integration_test/core_it_test.dart': "void main() { }\n" });
|
||||
const declared = run(repo, env);
|
||||
expect(declared.status).toBe(EXIT.ok);
|
||||
expect(declared.out).not.toContain('has no test lane');
|
||||
expect(declared.out).toContain('nothing touched selects a test');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* GST-419 and GST-420 (GSTA-195): `--policy-from head` may only add test lanes over the
|
||||
* trusted policy (the base branch's copy, or the user-owned file when the base has none),
|
||||
* and the base policy's `timeout_s` is gstack-pregate's per-process timeout when
|
||||
* `--timeout` is not passed.
|
||||
*/
|
||||
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 { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const SLUG = 'headpolicy';
|
||||
const pregate = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...args], { GSTACK_PROJECT_SLUG: SLUG });
|
||||
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
|
||||
const policy = (deps: Record<string, string[]>, extra: Record<string, unknown> = {}) => JSON.stringify({ ...BASE_PREGATE, dependencies: deps, ...extra }, null, 2) + '\n';
|
||||
const NEW_LANE = { ...BASE_PREGATE.dependencies, 'lib/orphan.js': ['test/util.test.js'] };
|
||||
|
||||
function branchWithOrphanEdit(base?: Record<string, unknown> | null): FixtureRepo {
|
||||
const repo = makeFixtureRepo(base === undefined ? {} : { pregate: base });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
return repo;
|
||||
}
|
||||
|
||||
describe('gstack-pregate --policy-from head: additive only (GST-419)', () => {
|
||||
// Value: protects=a branch that adds the lane for a file it touches can gate under its own policy; fails_when=the head lane is ignored or refused;
|
||||
// why_new=GEVA-2 (gstack-evals#93) hit PREGATE_NO_LANE and had to call runLanes by hand; seam=import graph; no_claim=queue treatment of head records
|
||||
test('a head policy that only adds a lane gates the touched file; the base policy still reports no lane', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
const viaBase = pregate(repo, []);
|
||||
expect(viaBase.status).toBe(EXIT.fail);
|
||||
expect(viaBase.out).toContain('FAIL lanes — lib/orphan.js has no test lane');
|
||||
const viaHead = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(viaHead.err).not.toContain('PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(viaHead.status).toBe(EXIT.ok);
|
||||
expect(lanes(repo).status).toBe('pass');
|
||||
expect(viaHead.out).toMatch(/^pregate: policy head \(unreviewed\) .*adds 1 lane/m);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the trust boundary: a head policy cannot remove, shrink or loosen anything the base declares; fails_when=any of these runs under head;
|
||||
// why_new=--policy-from head took the whole head file; seam=none; no_claim=generated.json, which head mode still reads from the branch
|
||||
test.each([
|
||||
['removes a lane', policy({ 'docs/**': [], 'README.md': [] }), 'dependencies["bin/tool"]'],
|
||||
['swaps a lane for another test', policy({ ...BASE_PREGATE.dependencies, 'bin/tool': ['test/util.test.js'] }), 'dependencies["bin/tool"]'],
|
||||
['adds a [] declaration', policy({ ...BASE_PREGATE.dependencies, 'lib/orphan.js': [] }), 'dependencies["lib/orphan.js"]'],
|
||||
['changes the runner', policy(NEW_LANE, { runner: 'node --test --test-only {files}' }), 'runner'],
|
||||
['downgrades a check', policy(NEW_LANE, { warn: ['strays', 'literals'] }), 'warn'],
|
||||
['allowlists a finding', policy(NEW_LANE, { allowlist: [{ finding: 'pii', paths: ['lib/**'], reason: 'x' }] }), 'allowlist'],
|
||||
['deletes the file', null, '.gstack/pregate.json'],
|
||||
])('a head policy that %s is refused', (_what, head, names) => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': head });
|
||||
const r = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('(PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(r.err).toContain(names);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/pregate.json'))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a reordered list is not a change; fails_when=key or list order counts as loosening; why_new=set equality; seam=none; no_claim=none
|
||||
test('reordering lists and adding a test to an existing lane is additive', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...BASE_PREGATE, strays: { allow: ['Makefile'] }, warn: ['strays'], dependencies: { 'README.md': [], 'docs/**': [], 'bin/tool': ['test/util.test.js', 'test/tool-cli.test.js'], 'lib/orphan.js': ['test/util.test.js'] } }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a branch behind the base is compared with the policy it forked from and runs the base tip plus its additions; fails_when=a lane the base added later reads as a removal;
|
||||
// why_new=stamp-at-merge preflight skips the base merge; seam=merge-base; no_claim=none
|
||||
test('a stale branch keeps the lanes the base added after it forked', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { '.gstack/pregate.json': policy({ ...BASE_PREGATE.dependencies, 'lib/util.js': ['test/util.test.js'] }) }, 'base adds a lane');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
const r = pregate(repo, ['--policy-from', 'head', '--explain']);
|
||||
expect(r.err).not.toContain('PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('head policy adds: dependencies["lib/orphan.js"]');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=with no base copy, the user-owned policy is the trusted one a head policy is compared with; fails_when=head mode drops the user guard;
|
||||
// why_new=head mode never read the user file; seam=state root; no_claim=none
|
||||
test('with no base copy, a head policy that drops the user-owned guard is refused; one that adds a lane runs', () => {
|
||||
const repo = branchWithOrphanEdit(null);
|
||||
const user = { ...BASE_PREGATE, guards: [{ id: 'always', when: ['lib/**'], command: 'true' }] };
|
||||
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
fs.writeFileSync(userPolicy, JSON.stringify(user));
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
const dropped = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(dropped.status).toBe(EXIT.refused);
|
||||
expect(dropped.err).toContain('guards');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...user, dependencies: NEW_LANE }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a PR that introduces the first policy still bootstraps under head; fails_when=the bootstrap path is refused; why_new=rule scope; seam=none; no_claim=none
|
||||
test('with no base copy and no user-owned file, a head policy is a bootstrap and runs as written', () => {
|
||||
const repo = branchWithOrphanEdit(null);
|
||||
commit(repo, { '.gstack/pregate.json': policy({ 'lib/orphan.js': ['test/util.test.js'], 'docs/**': [] }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('timeout_s in the base policy (GST-420)', () => {
|
||||
const SLOW = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 4000)); });\n";
|
||||
// Value: protects=the repo's per-process timeout reaches every caller that does not pass --timeout (preflight, finish); fails_when=pregate keeps its 600 s default;
|
||||
// why_new=HARN-1 (HARN-12) lanes of ~780 s were always incomplete under preflight; seam=supervise; no_claim=remote wall clock
|
||||
test('timeout_s bounds each runner process when --timeout is absent, and --timeout still overrides it', () => {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, timeout_s: 2 } }); repos.push(repo);
|
||||
commit(repo, { 'test/slow.serial.test.js': SLOW });
|
||||
expect(pregate(repo, []).status).toBe(EXIT.fail);
|
||||
expect(lanes(repo)).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE' });
|
||||
expect(lanes(repo).detail).toContain('a runner process timed out after 2000 ms (timeout_s from origin/main');
|
||||
expect(pregate(repo, ['--timeout', '30']).status).toBe(EXIT.ok);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=timeout_s stays inside the one-hour wrapper deadline preflight and finish use; fails_when=values over 3600 or under 1 validate;
|
||||
// why_new=new key; seam=none; no_claim=none
|
||||
test.each([[0], [3601], [1.5], ['600']])('timeout_s %p is invalid', (value) => {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, timeout_s: value } }); repos.push(repo);
|
||||
const r = pregate(repo, []);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('timeout_s');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const GATE = 'node scripts/remote-gate.js';
|
||||
/** A fake remote gate: reports the tree it was handed (or GATE_TREE), GATE_TESTS / GATE_FAILED, and records its env. */
|
||||
const GATE_SCRIPT = [
|
||||
"const fs = require('node:fs');",
|
||||
"fs.writeFileSync('../gate-env.json', JSON.stringify({ tree: process.env.PREGATE_TREE, cap: process.env.PREGATE_SPEND_CAP_USD, files: fs.readFileSync(process.env.PREGATE_FILES_FILE, 'utf8') }));",
|
||||
"fs.writeFileSync('../gate-env.json', JSON.stringify({ tree: process.env.PREGATE_TREE, cap: process.env.PREGATE_SPEND_CAP_USD, timeout: process.env.PREGATE_TIMEOUT_S, files: fs.readFileSync(process.env.PREGATE_FILES_FILE, 'utf8') }));",
|
||||
"if (process.env.GATE_SILENT) process.exit(0);",
|
||||
"const spent = process.env.GATE_SPENT ? ` spent=${process.env.GATE_SPENT}` : '';",
|
||||
"console.log(`PREGATE_REMOTE: tree=${process.env.GATE_TREE || process.env.PREGATE_TREE} tests=${process.env.GATE_TESTS ?? 3} failed=${process.env.GATE_FAILED ?? 0}${spent}`);",
|
||||
@@ -129,10 +129,9 @@ describe('gstack-pregate --remote (C-c)', () => {
|
||||
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
|
||||
const pregateFile = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
||||
|
||||
test('needs --allow-repo-commands, the base policy and a spend cap', () => {
|
||||
test('needs --allow-repo-commands and a spend cap', () => {
|
||||
const repo = gated();
|
||||
expect(remote(repo, ['--remote', '--spend-cap', '5']).status).toBe(EXIT.refused);
|
||||
expect(remote(repo, ['--remote', '--spend-cap', '5', '--allow-repo-commands', '--policy-from', 'head']).status).toBe(EXIT.refused);
|
||||
const noCap = remote(repo, ['--remote', '--allow-repo-commands']);
|
||||
expect(noCap.status).toBe(EXIT.usage);
|
||||
expect(noCap.err).toContain('pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)');
|
||||
@@ -150,10 +149,31 @@ describe('gstack-pregate --remote (C-c)', () => {
|
||||
expect(lanes(repo).detail).toContain('spend: spent $0.37, reserved $0, unknown $0 (cap $4)');
|
||||
expect(r.err).toContain('PREGATE_REMOTE_SPEND: spent $0.37, reserved $0, unknown $0 (cap $4)');
|
||||
const env = JSON.parse(fs.readFileSync(path.join(repo.root, 'gate-env.json'), 'utf8'));
|
||||
expect(env).toEqual({ tree: file.tree, cap: '4', files: 'test/util.test.js\n' });
|
||||
expect(env).toEqual({ tree: file.tree, cap: '4', timeout: '600', files: 'test/util.test.js\n' });
|
||||
expect(runBin(repo, 'gstack-pregate', ['verify']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
// Value: protects=a head policy that only adds lanes can clear the remote obligation on a small machine, with the base copy's gate and the
|
||||
// policy's timeout_s as the gate's deadline (PREGATE_TIMEOUT_S); a head policy that changes the gate is refused (GST-419, GST-420);
|
||||
// fails_when=--remote refuses head mode, the gate deadline ignores timeout_s or --timeout, or a head remote_gate runs; why_new=head mode
|
||||
// could never clear on under-8-core crew machines; seam=fake gate; no_claim=the Ubicloud VM
|
||||
test('under --policy-from head the base gate runs with the head lanes and the timeout_s deadline; a head policy that changes the gate is refused', () => {
|
||||
const policy = { ...BASE_PREGATE, remote_gate: GATE, timeout_s: 900 };
|
||||
const repo = gated(policy);
|
||||
const lane = { ...BASE_PREGATE.dependencies, 'lib/orphan.js': ['test/util.test.js'] };
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ ...policy, dependencies: lane }) + '\n' });
|
||||
const env = () => JSON.parse(fs.readFileSync(path.join(repo.root, 'gate-env.json'), 'utf8'));
|
||||
expect(remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2']).status).toBe(EXIT.ok);
|
||||
expect(env().timeout).toBe('900');
|
||||
expect(env().files).toContain('test/util.test.js');
|
||||
expect(remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2', '--timeout', '1200']).status).toBe(EXIT.ok);
|
||||
expect(env().timeout).toBe('1200');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...policy, remote_gate: 'node scripts/other-gate.js', dependencies: lane }) + '\n' });
|
||||
const changed = remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2']);
|
||||
expect(changed.status).toBe(EXIT.refused);
|
||||
expect(changed.err).toContain('remote_gate differs from the trusted policy');
|
||||
});
|
||||
|
||||
test('a wrong tree, zero tests, a failure, a missing line or a non-zero exit leaves the obligation uncleared and the check incomplete; unreported spend counts at the cap', () => {
|
||||
const repo = gated();
|
||||
const cases: Array<[Record<string, string>, string]> = [
|
||||
|
||||
@@ -174,16 +174,17 @@ describe('gstack-pregate with a user-owned policy', () => {
|
||||
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', () => {
|
||||
// Value: protects=the user file is consulted only with no base copy: a committed base copy never reads it, and under --policy-from head it
|
||||
// is the trusted policy the head file is compared with, so an invalid one refuses instead of vanishing (GST-419); fails_when=a base
|
||||
// copy is overridden by the user file, or head mode drops an unreadable user policy; why_new=the fallback is new, and head mode
|
||||
// now compares with it; seam=none; no_claim=none beyond protects
|
||||
test('a base copy never reads the user file; under --policy-from head an invalid user file refuses', () => {
|
||||
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');
|
||||
expect(head.status).toBe(EXIT.refused);
|
||||
expect(head.err).toContain('PREGATE_CONFIG_INVALID');
|
||||
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' });
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
|
||||
@@ -208,3 +208,44 @@ describe('GSTA-167 part 4a: RUNTIME_LINES and REVIEW_DEPTH share one owner', ()
|
||||
expect(plan.consumed).toEqual(['data/used.json']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-preflight Step 5 pregate: head policy and timeout_s (GST-419, GST-420)', () => {
|
||||
const POLICY = { runner: 'node --test {files}', dependencies: { 'README.md': [], 'CHANGELOG.md': [] } };
|
||||
const PASSING = "const test = require('node:test');\ntest('x', () => {});\n";
|
||||
const base = (extra: Record<string, unknown> = {}) => ({ '.gstack/pregate.json': JSON.stringify({ ...POLICY, ...extra }) + '\n', 'test/x.test.js': PASSING });
|
||||
// Value: protects=a branch that adds the lane for a file it touches passes Step 5 through preflight with --policy-from head; fails_when=preflight drops the flag and pregate reports PREGATE_NO_LANE;
|
||||
// why_new=GEVA-2 (gstack-evals#93) had to call runLanes by hand; seam=gstack-pregate --policy-from head; no_claim=queue treatment of head records
|
||||
test('--policy-from head reaches pregate: the branch lane counts, and without the flag PREGATE_DETAIL names the missing lane', () => {
|
||||
const f = fx(base(), { 'src/a.ts': 'export const a = 2;\n', '.gstack/pregate.json': JSON.stringify({ ...POLICY, dependencies: { ...POLICY.dependencies, 'src/a.ts': ['test/x.test.js'] } }) + '\n' });
|
||||
const viaBase = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]);
|
||||
expect(viaBase.status).toBe(3);
|
||||
expect(lineOf(viaBase.stdout, 'PREGATE_DETAIL')).toContain('with no test lane: src/a.ts');
|
||||
expect(lineOf(viaBase.stdout, 'PREGATE_DETAIL')).toContain('(PREGATE_NO_LANE');
|
||||
const viaHead = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--lane', LANE]);
|
||||
expect(lineOf(viaHead.stdout, 'PREGATE')).toMatch(/^PREGATE: pass/);
|
||||
expect(viaHead.status).toBe(0);
|
||||
expect(lineOf(viaHead.stdout, 'LANE')).toMatch(/^LANE: tests pass /);
|
||||
});
|
||||
|
||||
// Value: protects=the head-policy rule holds through preflight: a head policy that removes a lane is refused and named; fails_when=preflight passes or hides the refusal as unreadable;
|
||||
// why_new=preflight now forwards --policy-from; seam=PREGATE_HEAD_POLICY_LOOSENS; no_claim=none
|
||||
test('--policy-from head with a head policy that removes a lane stops with PREGATE_HEAD_POLICY_LOOSENS', () => {
|
||||
const f = fx(base(), { 'src/a.ts': 'export const a = 2;\n', '.gstack/pregate.json': JSON.stringify({ ...POLICY, dependencies: { 'src/a.ts': ['test/x.test.js'] } }) + '\n' });
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--lane', LANE]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'PREGATE')).toBe('PREGATE: refused (PREGATE_HEAD_POLICY_LOOSENS)');
|
||||
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('dependencies["README.md"] is removed');
|
||||
expect(r.stdout).not.toContain('LANE: tests');
|
||||
});
|
||||
|
||||
// Value: protects=the repo's timeout_s reaches preflight's pregate and the cause is printed; fails_when=preflight's pregate runs at the 600 s default or prints only the verdict;
|
||||
// why_new=HARN-1 (HARN-12) lanes of ~780 s were always PREGATE: incomplete under preflight; seam=gstack-pregate timeout_s; no_claim=the finish fallback, which runs the same pregate default
|
||||
test('timeout_s from the base policy bounds Step 5 pregate and PREGATE_DETAIL names it', () => {
|
||||
const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 4000)); });\n";
|
||||
const f = fx(base({ timeout_s: 2 }), { 'test/slow.serial.test.js': slow });
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'PREGATE')).toMatch(/^PREGATE: (tests |all )?incomplete/);
|
||||
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('timed out after 2000 ms (timeout_s from origin/main');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user