From 088bfc5914f8001a3d49b3a0c35d6f7cb800268c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 31 Oct 2026 04:29:20 -0700 Subject: [PATCH 1/2] v1.91.364.0 fix: pregate --policy-from head only adds lanes, and timeout_s reaches preflight and finish (GST-419, GST-420) (#3676) * 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> * fix: pre-landing review fixes (head record fingerprints the trusted user policy; remote test timeout) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: --remote under a bootstrap head policy is refused (Step 11 adversarial) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: pregate timeout order, PREGATE_TIMEOUT_S, head-policy follow-ups in TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate gstack-doctor-components.sh for PREGATE_HEAD_POLICY_LOOSENS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 19 ++++ TODOS.md | 5 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor-components.sh | 1 + bin/gstack-pregate | 19 ++-- docs/pregate.md | 51 +++++++---- docs/troubleshooting.md | 37 ++++++-- lib/pregate/checks/lanes.ts | 11 +-- lib/pregate/config.ts | 14 ++- lib/pregate/context.ts | 20 +++-- lib/pregate/head-policy.ts | 72 +++++++++++++++ lib/pregate/index.ts | 2 +- lib/pregate/types.ts | 2 + lib/result-codes-release.ts | 5 ++ lib/ship-preflight.ts | 22 +++-- package.json | 2 +- scripts/ubicloud/pregate-remote.sh | 3 +- test/pregate-dart.test.ts | 14 ++- test/pregate-head-policy.test.ts | 139 +++++++++++++++++++++++++++++ test/pregate-remote.test.ts | 41 ++++++++- test/pregate-user-policy.test.ts | 13 +-- test/ship-preflight.test.ts | 41 +++++++++ 23 files changed, 466 insertions(+), 71 deletions(-) create mode 100644 lib/pregate/head-policy.ts create mode 100644 test/pregate-head-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e7bb06a..cf72afddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.364.0] - 2026-10-31 + +**`--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 ` 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 ()` 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`. With no trusted policy at all, `--remote` under head is refused, because a bootstrap head file's own `remote_gate` would be clearing its own obligation. +- **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. + ## [1.91.363.0] - 2026-10-31 **A passing test lane that leaves Bun's `.bun/` folder behind now grades FRESH, `--check-prose` no longer reads the Approvals table as a claim, and `gstack-post pr-body` drops a stale intro.** Three /ship false alarms from GST-421 and GST-412 (HARN-1, HARN-12, GSTA-2, GEVA-69, gstack-evals#90). diff --git a/TODOS.md b/TODOS.md index 595e117e4..778672f8b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -13,6 +13,11 @@ - **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1. - **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1. +### P2/P3: pregate head-policy follow-ups (filed 2026-10-31, from the GST-419/GST-420 /ship review) +- **`finish` fallback drops head-policy lanes (P2)** — `lib/ship-release.ts` reruns pregate on the stamped tree under the base policy, so after a version bump a lane the branch added under `--policy-from head` is gone and `finish` reports `PREGATE: fail (rerun on the stamped tree)`. GSTA-194 owns that file and has the note. **Effort:** S. **Priority:** P2. +- **The 3600 s preflight and `finish` wrapper deadline (P3)** — it caps the whole pregate run, so several runners that each fit `timeout_s` can still be cut off together. Derive the wrapper deadline from `timeout_s` and the lane count, or document the cap. **Effort:** S. **Priority:** P3. +- **A bootstrap head policy can still certify itself without `--remote` (P3)** — with no trusted policy, the head file may set `remote_gate: null`, or produce an obligation that `gstack-pregate clear --receipt` accepts from the branch's own gate. Decide what a bootstrap `pregate.json` may certify, then enforce it in `verify` from the recorded policy label instead of per entry point. The `--remote` refusal also prints the generic `PREGATE_HEAD_POLICY_LOOSENS` fix line, which does not apply when there is no trusted policy. **Effort:** S. **Priority:** P3. + ### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review) - **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3. diff --git a/VERSION b/VERSION index a049b61d8..a2f1e59ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.363.0 +1.91.364.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 733af9142..4886998eb 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.363.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.364.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 2e599a47e..dd65a98f8 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -323,6 +323,7 @@ gstack_result_anchor() { QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;; PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;; PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;; + PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;; APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;; APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;; diff --git a/bin/gstack-pregate b/bin/gstack-pregate index a7995aa41..d11a0730c 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -67,15 +67,17 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do --only restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')}) --base 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 pregate.json path (default ${PREGATE_OUT_REL}) - --timeout 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 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 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= tests= failed=0 line with n > 0 --spend-cap 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 (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; } remote = { spendCapUsd: cap }; diff --git a/docs/pregate.md b/docs/pregate.md index 944c3c921..b3b5ef4ed 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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)); `` 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 `) 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 `/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming @@ -321,9 +334,9 @@ Two stages with separate clocks, both printed: the project's `engines.bun` (the row prints `runtime: bun from `), so a Capy host's older image Bun cannot flip the verdict. - Every test-tier command (the lanes runner, each guard, the `--remote` gate) - runs supervised: see [supervision](#supervision). `--timeout` bounds each - runner process (the unit-file group, each serial file, each guard without - its own `timeout_s`), not their total; the stall timeout catches a hung or + runs supervised: see [supervision](#supervision). `--timeout` (else the + policy's `timeout_s`, else 600 s) bounds each runner process (the unit-file + group, each serial file, each guard without its own `timeout_s`), not their total; the stall timeout catches a hung or silent one sooner. - Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend` (`gstack-config set test_backend ubicloud`) records the local test tier as a @@ -399,9 +412,10 @@ test-tier command: (`--stall-timeout `, else `stall_timeout_s`, else 300; `0` disables): the check is `incomplete` with `PREGATE_STALLED` and names the last file started; the next serial file still runs; -- is killed as **timed out** when that runner process reaches `--timeout` - (per runner process, so many serial files that each fit still pass); the - check is `incomplete` and the next serial file still runs. +- is killed as **timed out** when that runner process reaches its timeout + (`--timeout `, else `timeout_s`, else 600; per runner process, so many + serial files that each fit still pass): the check is `incomplete`, its detail + names which of the three set the timeout, and the next serial file still runs. A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores SIGTERM still dies). SIGINT or SIGTERM to `gstack-pregate` itself SIGKILLs every @@ -429,11 +443,12 @@ the supervision above. Close the obligation one of two ways: `gstack-pregate clear --remote test_backend/ --run-url `, which has no machine-checkable receipt and is recorded as `attested:`; or - `gstack-pregate --stage tests --remote --spend-cap --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 - `PREGATE_SPEND_CAP_USD`, and must print + being tested), `PREGATE_FILES_FILE` (the selected files, one per line), + `PREGATE_SPEND_CAP_USD` and `PREGATE_TIMEOUT_S` (this run's per-process + timeout in whole seconds), and must print `PREGATE_REMOTE: tree= tests= failed= [spent=]`, reporting the tree it actually tested. The obligation clears only when that tree is `PREGATE_TREE`, `tests` > 0, `failed` = 0 and the gate exits 0; anything else @@ -548,10 +563,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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b3875c7cc..64b9f3bf1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3376,7 +3376,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 @@ -3400,13 +3403,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. + + +### `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. ### `PREGATE_LANE_FAILED` diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 7d5feaf76..759b3588a 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -344,7 +344,7 @@ function unfinished(ctx: PregateContext, r: Awaited> 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= tests= failed= [spent=]`; * 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 { * 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 }; diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index bbd409ee7..9a4748705 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -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; - 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= tests= failed=) (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: /projects//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)', diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 02829b208..050d33566 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -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,10 +212,10 @@ 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; @@ -222,6 +223,13 @@ export function buildContext(o: BuildContextOptions): ContextLoad { if (!user && !rejected) absentUserPolicy = o.userConfigPath; } if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } }; + if (o.policyFrom === 'head' && !configLoad.errors.length) { + if (o.remote && configLoad.kind === 'none') return { refused: { code: 'PREGATE_HEAD_POLICY_LOOSENS', message: '--remote under --policy-from head needs a trusted policy (the base copy or the user-owned file); with none, the head file is a bootstrap and its remote_gate cannot clear its own obligation' } }; + 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 } }; @@ -239,8 +247,8 @@ export function buildContext(o: BuildContextOptions): ContextLoad { return { 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, + config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + 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, diff --git a/lib/pregate/head-policy.ts b/lib/pregate/head-policy.ts new file mode 100644 index 000000000..9ef2dd062 --- /dev/null +++ b/lib/pregate/head-policy.ts @@ -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; + +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)[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 } { + const loosens: string[] = FROZEN.filter(k => !same(ref[k], head[k])).map(k => `${k} differs from the trusted policy`); + const adds: Record = {}; + 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(', ')}`), + }; +} diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 925c88f84..4a579e57e 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -182,7 +182,7 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre * environment: the policy file and its digest, or under the defaults the path that was looked for and absent. */ function userPolicyChange(file: PregateFile): string | null { - const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null; + const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : file.policy?.startsWith('head ') ? / over user (.+)$/.exec(file.policy)?.[1] ?? null : null; if (recorded) { if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`; let text: string; diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index e357242f7..0a4e8a731 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -49,6 +49,8 @@ export interface PregateContext { platform: NodeJS.Platform; allowRepoCommands: boolean; timeoutMs: number; + /** Where timeoutMs came from: `--timeout`, `timeout_s from `, 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. */ diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 96a2f66ea..ebc29e157 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -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; diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index cca22b3ca..d52b90edb 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -15,11 +15,14 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste gstack-ship-preflight --base [--lane