Stop the pre-merge check from accepting a half-finished run (GST-2194) (#4258)

* Make pregate verify refuse a record from a partial run (GST-2194)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Update pregate and queue test fixtures to complete records

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Document that verify needs every check and that a staged rerun keeps the rest

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Skip the partial-record pregate tests on Windows, where the fixture lane is a remote obligation

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Give the crew queue tests complete pregate records

The train, train-ops and wave tests wrote one-check pregate records, which verify now refuses as a partial run. They now write every registered check, as test/gstack-ship-queue.test.ts already does (CI free-suite run 42718782737).

Review-Finding: lib/pregate/index.ts:251:functional-contract reader=core
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Explain the missing-checks pregate refusal in troubleshooting

Documents that verify refuses a record missing registered checks, and that a fix commit needs a full gstack-pregate run (Step 14.5 docs audit).

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-10 01:33:31 +00:00
1 parent 00cb80cce8
commit 56c4e873b2
18 files changed
+250 -50

No files matched your search

+10 -5
View File
@@ -5,7 +5,7 @@
* gstack-pregate [run] [--stage preflight|tests|guards|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, every registered check, no fail/incomplete, obligations cleared
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* [--flaky-exception "<test file>::<case>" … --pr <n> [--grant <approval id>]] a run with failures, under the owner's grant
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
@@ -33,7 +33,7 @@ import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/p
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildRunCheck } from '../lib/pregate/run-check';
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, clearDecision, clearObligation, keepSameTreeClears, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, clearDecision, clearObligation, keepSameTreeClears, mergeSameTreeChecks, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { baseBranchOf, describeSubstitution } from '../lib/pregate/remote-receipt';
@@ -51,7 +51,8 @@ import { fromGateAhead } from '../lib/tree-receipt';
const HELP = `gstack-pregate — cheap checks before anything paid or remote (docs/pregate.md)
run (default) stage 1 mechanical preflight (${CHECKS.filter(c => c.stage === 'preflight').map(c => c.id).join(', ')}), then stage 2 test execution (${CHECKS.filter(c => c.stage === 'tests').map(c => c.id).join(', ')})
verify publication check against ${PREGATE_OUT_REL}: current tree, no fail or incomplete, every requires-remote obligation cleared
verify publication check against ${PREGATE_OUT_REL}: current tree, every registered check present (checks=N of M;
a --only or --stage record is PREGATE_INCOMPLETE), no fail or incomplete, every requires-remote obligation cleared
clear record the receipt that clears one requires-remote obligation: pregate.json/remote_gate needs
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree; a tree that
differs from pregate.json's only in the base branch's stamp rule is accepted and recorded as a
@@ -77,7 +78,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--stage preflight|tests|guards|all default all; guards is the pre-push housekeeping run (docs/pregate.md#prepush):
only ${PREPUSH_CHECK_IDS.join(' and ')}, each for the files the diff touched, written to ${PREGATE_GUARDS_OUT_REL}
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')});
with --stage preflight|tests, the record keeps the checks this run skipped only from a record at
the same head, tree, base and policy (PREGATE_MERGED: kept …); otherwise verify refuses it
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
@@ -204,7 +207,9 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
});
}
const file = await runPregate(ctx, { stage, only, run: flag(rest, '--run') });
const kept = keepSameTreeClears(fs.existsSync(out) ? readPregateFile(out).file : null, file);
const before = fs.existsSync(out) ? readPregateFile(out).file : null;
const merged = stage === 'guards' ? null : mergeSameTreeChecks(before, file);
const kept = [...(merged ? [merged] : []), ...keepSameTreeClears(before, file)];
let written: string;
try { written = writePregateFile(root, out, file); }
catch (e: any) { process.stderr.write(resultLine('gstack-pregate', 'ARTIFACT_SCHEMA', e.message) + '\n'); return EXIT.fail; }
@@ -0,0 +1,4 @@
---
important: security
---
- **`gstack-pregate verify` no longer passes a record from a partial run.** A `--only <check>` or `--stage preflight|tests` run used to replace `.gstack/tmp/pregate.json` with only the checks it ran, and `verify` (so also `gstack-ship-queue ready` and `gstack-ship-receipt write`) accepted it as a full pass. So following pregate's own "rerun `--stage tests`" hint after a red full run could clear a secrets failure while the token was still in HEAD, and `--only strays` alone verified as green. Now `verify` refuses with `PREGATE_INCOMPLETE` unless the record holds every registered check, and it names what is missing. The PREGATE line prints `checks=N of M`. A staged or `--only` rerun at the same head, tree, base and policy keeps the checks it skipped from the record it replaces (it prints `PREGATE_MERGED: kept …`), so rerunning one stage keeps an earlier failure and an earlier pass. Any other partial run needs a full `gstack-pregate --allow-repo-commands` at that tree (GST-2194).
+7 -4
View File
@@ -14,6 +14,9 @@ import { cleanup, runBin } from '../../test/helpers/restamp-fixture';
import {
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainJson, trainFixture, type Rule, type TrainFixture,
} from '../../test/helpers/ship-queue-train-fixture';
import { CHECK_IDS } from '../../lib/pregate/index';
const FULL_CHECKS = CHECK_IDS.map(id => ({ id, status: 'pass' }));
const fixtures: TrainFixture[] = [];
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
@@ -28,7 +31,7 @@ function readyFixture() {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
return { fx, head, pregate };
}
const receiptOf = (fx: TrainFixture, pr: string, head: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `${pr}-${head}.json`), 'utf8'));
@@ -277,11 +280,11 @@ describe('train prune --stale and train watch', () => {
const h3 = openPr(fx, '3');
fx.write(d => { d.ci[h3] = [{ status: 'completed', conclusion: 'success' }]; });
const pregate = path.join(fx.repo.root, 'pregate3.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h3, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h3, verdict: 'pass', checks: FULL_CHECKS }));
expect(queue(fx, ['ready', '--pr', '3', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
const h4 = openPr(fx, '4');
fx.write(d => { d.ci[h4] = [{ status: 'completed', conclusion: 'success' }]; });
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h4, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h4, verdict: 'pass', checks: FULL_CHECKS }));
expect(queue(fx, ['ready', '--pr', '4', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
const receipt = queue(fx, ['train', 'forget', '--receipt', `3@${h3}`]);
expect([receipt.status, line(receipt.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: READY receipt #3 at ${h3.slice(0, 12)} (screen no-screen)`]);
@@ -409,7 +412,7 @@ describe('ready --dispatch-ci', () => {
d.rules['windows-free-tests.yml'] = { ci: 'windows-free-tests', headSha: head };
});
const tree = JSON.parse(fs.readFileSync(pregate, 'utf8')).tree;
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'requires-remote', checks: [{ id: 'unit', status: 'pass' }], requires_remote: [
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'requires-remote', checks: FULL_CHECKS, requires_remote: [
{ lane: 'free-tests.yml/free-tests', platform: 'linux', workflow: 'free-tests.yml', job: 'free-tests', tests: ['test/a.test.ts'], receipt: null },
{ lane: 'windows-free-tests.yml/windows-free-tests', platform: 'windows', workflow: 'windows-free-tests.yml', job: 'windows-free-tests', tests: ['test/a.test.ts'], receipt: null },
{ lane: 'macos-free-tests.yml/macos', platform: 'macos', workflow: 'macos-free-tests.yml', job: 'macos', tests: ['test/b.test.ts'], receipt: null }] }));
+9 -6
View File
@@ -26,6 +26,9 @@ import {
GREEN_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, mergeRefCommit, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey,
type Rule, type TrainFixture,
} from '../../test/helpers/ship-queue-train-fixture';
import { CHECK_IDS } from '../../lib/pregate/index';
const FULL_CHECKS = CHECK_IDS.map(id => ({ id, status: 'pass' }));
const fixtures: TrainFixture[] = [];
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
@@ -75,7 +78,7 @@ describe('the policy gate', () => {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const today = queue(fx, ['ready', '--pr', '1', '--pregate', pregate]);
expect(today.status).toBe(EXIT.ok);
expect(today.out.split('\n')[0]).toBe(`READY #1 ${head} STAMP_VERSION=unstamped`);
@@ -98,7 +101,7 @@ describe('the policy gate', () => {
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'fail', checks: [{ id: 'unit', status: 'fail' }] }));
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate]).status).toBe(EXIT.refused);
expect(fs.existsSync(path.join(fx.state, 'ship-queue'))).toBe(false);
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate]);
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toMatch(new RegExp(`^READY_RECEIPT: .*ship-queue/ready/[^/]+/1-${head}\\.json \\(notes pushed to origin refs/notes/gstack-trains\\)$`, 'm'));
@@ -112,7 +115,7 @@ describe('the policy gate', () => {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const req = { GSTACK_PLAN_RECORD: 'require' };
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate], req);
expect(missing.status).toBe(EXIT.refused);
@@ -136,7 +139,7 @@ describe('ready under a train policy: the screen choice', () => {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
return { fx, head, pregate };
}
@@ -190,7 +193,7 @@ describe('ready under a train policy: the screen choice', () => {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toContain(`the Version Gate refuses ${head.slice(0, 12)}: CHANGELOG.md drops released heading 1.2.3.0`);
@@ -253,7 +256,7 @@ describe('ready under a train policy: the screen choice', () => {
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/pull/1/head');
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
const pregate2 = path.join(repo.root, 'pregate2.json');
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: FULL_CHECKS }));
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([stale.status, stale.err]).toEqual([EXIT.ok, '']);
expect(line(stale.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${moved.slice(0, 12)} onto main@[0-9a-f]{12} \\(refs/pull/1/merge [0-9a-f]{12} merges ${head.slice(0, 12)}, not ${moved.slice(0, 12)}\\); would push screen/1-${moved.slice(0, 12)} \\(dry run\\)$`));
+4 -1
View File
@@ -15,6 +15,9 @@ import { EXIT } from '../../lib/headless-artifacts';
import { WAVE_HELP, areaKey, groupWaves, isHubFile, type PlanPr } from '../lib/ship-queue-wave';
import { cleanup, runBin } from '../../test/helpers/restamp-fixture';
import { line, openPr, queue, readyReceipt, shippedBody, trainFixture, type TrainFixture } from '../../test/helpers/ship-queue-train-fixture';
import { CHECK_IDS } from '../../lib/pregate/index';
const FULL_CHECKS = CHECK_IDS.map(id => ({ id, status: 'pass' }));
const fixtures: TrainFixture[] = [];
const make = () => { const f = trainFixture(); fixtures.push(f); return f; };
@@ -273,7 +276,7 @@ describe('ready and train build read the wave', () => {
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const ok = queue(fx, ['ready', '--pr', '10', '--pregate', pregate, '--no-screen']);
expect(ok.status).toBe(EXIT.ok);
expect(line(ok.out, 'WAVE:')).toContain(`WAVE: ${nonce} area=`);
+25 -7
View File
@@ -445,12 +445,27 @@ The record is `.gstack/tmp/pregate.json` (`gstack-artifact validate --as pregate
(Step 18) refuses unless the record is current for the tree being published
(`PREGATE_STALE`: rerun after the final stamp), green (`PREGATE_LANE_FAILED`,
`PREGATE_INCOMPLETE`, or `PREGATE_ENVIRONMENT` when every incomplete check is
the machine's) and every `requires-remote` obligation carries a receipt
the machine's), every `requires-remote` obligation carries a receipt
(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, wait for it to finish green, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>`). The ship
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>`), and it holds
every registered check. The `PREGATE:` line prints `checks=N of M`. A record
from a `--only` or `--stage preflight|tests` run that is missing checks is
`PREGATE_INCOMPLETE` ("pregate.json holds checks=1 of 8 (missing: …)"): run
`gstack-pregate --allow-repo-commands` with neither flag at that tree. The ship
receipt then carries `pregate` and `requires_remote`. `gstack-pregate verify`
runs the same check standalone.
<a id="same-tree-checks"></a>
**A staged rerun keeps the rest.** A `--only` or `--stage preflight|tests` run
keeps the checks it skipped from the record it replaces, when that record has
the same head, tree, base commit, policy and registry. It prints
`PREGATE_MERGED: kept <check>=<status>,… from the record at this tree …`. So
rerunning `--stage tests` after a full run at an unchanged tree keeps an earlier
`secrets` failure and an earlier pass, and the full run's obligations come along
when `lanes` is one of the kept checks. A record for another head or tree, or
an earlier `--stage guards` run, keeps nothing. After a fix commit moves the
tree, a staged run alone is not a full pass.
**By tree.** Every record `gstack-pregate` writes is also mirrored, byte for
byte, to `<state root>/pregate/by-tree/<tree>.json` (the state root from
docs/state-root.md). `gstack-ship-queue ready` reads the checkout's
@@ -461,11 +476,14 @@ printing `PREGATE: … (from the state root's pregate/by-tree/<tree12>.json,
recorded by another checkout)`. This is for the lane whose `gstack-pregate
--remote` had to run from a separate clone (a worktree cannot host it): the
clone's record clears the lane's own checkout as long as both hold the same
tree. Only a run that holds the tests stage stands in (what `--stage tests
--remote` writes, `checks=[lanes]`; a `--only timing` or preflight-only run
is "a run without the tests stage"), and it must be recorded against
`origin/<base>` and not under `--policy-from head`;
otherwise the `PREGATE:` line says why the mirror cannot stand in. A record
tree. Only a complete run stands in: the clone runs the full
`gstack-pregate --remote` (not `--stage tests --remote`, whose `checks=[lanes]`
record verify refuses `PREGATE_INCOMPLETE`), or the lane clears its own record
with `gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>`.
A `--only timing` or preflight-only run is "a run without the tests stage",
and the record must be recorded against `origin/<base>` and not under
`--policy-from head`; otherwise the `PREGATE:` line says why the mirror cannot
stand in. A record
whose own checks failed or stayed incomplete is final and no mirror overrides
it. An explicit `--pregate <file>` that verifies is used as before; a mirror
for a different tree changes nothing. Writing the mirror is best effort (a
+12 -3
View File
@@ -4406,7 +4406,10 @@ first, then merge the base into this branch and rerun.
`set -o pipefail`, or reported zero tests (ZERO-RUN).
**Fix.** Read the printed lane log, fix the failure, rerun
`gstack-pregate --stage tests`. A `| tail`-hidden exit is still an exit.
`gstack-pregate --stage tests`. A `| tail`-hidden exit is still an exit. A fix
that changes the tree leaves the staged rerun nothing to keep, so finish with a
full `gstack-pregate --allow-repo-commands` before `verify`
([docs/pregate.md](pregate.md#same-tree-checks)).
<a id="pregate-incomplete"></a>
### `PREGATE_INCOMPLETE`
@@ -4414,7 +4417,11 @@ first, then merge the base into this branch and rerun.
**Meaning.** A check timed out, a runner process was killed by a signal (out of
memory, an outside kill) and ended without an exit code, repo commands were not
allowed, no runner is declared or detected, or the working tree changed while
the check ran. Incomplete is never pass and blocks publication. A tool missing from PATH, a
the check ran. `gstack-pregate verify` also refuses a record that is missing
registered checks ("pregate.json holds checks=1 of 8 (missing: …)"): a
`--only` or `--stage preflight|tests` run with no record at the same head, tree,
base and policy to keep the rest from
([docs/pregate.md](pregate.md#same-tree-checks)). Incomplete is never pass and blocks publication. A tool missing from PATH, a
network or quota error before any test ran, or a full disk is
[`PREGATE_ENVIRONMENT`](#pregate-environment) instead, so the machine's failure
reads apart from the diff's.
@@ -4423,7 +4430,9 @@ reads apart from the diff's.
package the message says has none (or declare `runner`), raise `--timeout`, or
keep the tree still, then rerun. A runner killed by a signal never passes: read
the lane log, then rerun or split the selection. A tool missing from PATH is
[`PREGATE_ENVIRONMENT`](#pregate-environment), with its own fix.
[`PREGATE_ENVIRONMENT`](#pregate-environment), with its own fix. A record
missing checks needs a full `gstack-pregate --allow-repo-commands` (neither
`--only` nor `--stage`) at that tree.
<a id="pregate-stalled"></a>
### `PREGATE_STALLED`
+29 -2
View File
@@ -163,7 +163,7 @@ export function renderTable(file: PregateFile, opts: { lines?: boolean } = { lin
return [
`stage check status time detail`,
...rows, ...body, ...clocks, ...remote,
`PREGATE: ${file.verdict} tree=${file.tree.slice(0, 12)} head=${(file.head ?? '').slice(0, 12)} checks=${file.checks.length}`,
`PREGATE: ${file.verdict} tree=${file.tree.slice(0, 12)} head=${(file.head ?? '').slice(0, 12)} checks=${file.checks.length} of ${CHECK_IDS.length}`,
].join('\n') + '\n';
}
@@ -213,7 +213,9 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R
/**
* Publication check: the recorded tree must equal the tree now (results
* current after final generation and stamping), no check may be fail or
* incomplete, and every requires-remote obligation needs its receipt. The
* incomplete, every requires-remote obligation needs its receipt, and the
* record must hold every registered check (a `--only` or `--stage` run is
* not a full pass). The
* user-owned policy behind the run (or its absence, under the defaults) must
* still hold, since it lives outside the tree.
*/
@@ -245,6 +247,8 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
// A flaky exception was granted for one head, tree, run attempt and case list; recorded for another, or edited, it clears nothing.
const exception = exceptionProblem(file);
if (exception) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: exception, current, uncleared: [] };
const missing = CHECK_IDS.filter(id => !file.checks.some(c => c.id === id));
if (missing.length) return { ok: false, code: 'PREGATE_INCOMPLETE', message: `pregate.json holds checks=${CHECK_IDS.length - missing.length} of ${CHECK_IDS.length} (missing: ${missing.join(', ')}); a --only or --stage run is not a full pass: run gstack-pregate --allow-repo-commands with neither at tree ${file.tree.slice(0, 12)}`, current, uncleared };
const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`);
const excepted = exceptionSummary(file);
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}${excepted ? `; ${excepted}` : ''}`, current, uncleared };
@@ -488,6 +492,29 @@ export function clearObligation(file: PregateFile, lane: string, receipt: string
return file;
}
/**
* A `--only` or `--stage preflight|tests` run keeps the checks it did not run from the record it replaces when that
* record has the same head, tree, base commit, policy and registry (GST-2194), so a staged rerun neither drops an earlier
* failure nor loses an earlier pass. Only `lanes` records requires-remote obligations, so the earlier obligations and
* exceptions come along exactly when its `lanes` result does. Any other record keeps nothing, and verify then refuses
* the partial record PREGATE_INCOMPLETE. Returns the PREGATE_MERGED line, or null when nothing was kept.
*/
export function mergeSameTreeChecks(prev: PregateFile | null, file: PregateFile): string | null {
if (!prev || prev.head !== file.head || prev.tree !== file.tree || prev.base !== file.base || prev.policy !== file.policy || prev.policy_digest !== file.policy_digest || prev.policy_user_path !== file.policy_user_path || prev.registry !== file.registry) return null;
const ran = new Set(file.checks.map(c => c.id));
const kept = prev.checks.filter(c => !ran.has(c.id) && CHECK_IDS.includes(c.id));
if (!kept.length) return null;
file.checks = [...file.checks, ...structuredClone(kept)].sort((a, b) => CHECK_IDS.indexOf(a.id) - CHECK_IDS.indexOf(b.id));
if (kept.some(c => c.id === 'lanes')) {
file.requires_remote = structuredClone(prev.requires_remote ?? []);
if (prev.exceptions?.length) file.exceptions = structuredClone(prev.exceptions); else delete file.exceptions;
}
if (kept.some(c => c.stage === 'preflight')) file.stage1_ms ??= prev.stage1_ms;
if (kept.some(c => c.stage === 'tests')) file.stage2_ms ??= prev.stage2_ms;
file.verdict = verdictOf(file);
return `PREGATE_MERGED: kept ${kept.map(c => `${c.id}=${c.status}`).join(',')} from the record at this tree ${file.tree.slice(0, 12)} and head ${(file.head ?? '').slice(0, 12)} (recorded ${prev.generated_at ?? 'earlier'}; docs/pregate.md#same-tree-checks)`;
}
/**
* A rerun at the same tree keeps the clears its predecessor recorded (GST-1627): `gstack-ship-release accept` reruns the
* pregate on the final tree after the lane cleared macos-named-regressions and windows-smoke from CI runs at that tree, and
+14 -11
View File
@@ -21,6 +21,9 @@ import type { TreeReceipt } from '../lib/tree-receipt';
import { BASE_POLICY, BIN, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
import { coordinatorClone, ghQueueShim } from './helpers/ship-queue-fixture';
import { shippedBody } from './helpers/ship-queue-train-fixture';
import { CHECK_IDS } from '../lib/pregate/index';
const FULL_CHECKS = CHECK_IDS.map(id => ({ id, status: 'pass' }));
const repos: FixtureRepo[] = [];
const fixture = (policy?: Record<string, unknown>, prefix?: string) => { const r = makeFixtureRepo({ ...(policy ? { policy } : {}), ...(prefix ? { prefix } : {}) }); repos.push(r); return r; };
@@ -251,7 +254,7 @@ describe('ready', () => {
function pregateFor(repo: FixtureRepo, head: string): string {
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const file = path.join(repo.root, 'pregate.json');
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
return file;
}
const green = { check_runs: [{ status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'skipped' }] };
@@ -267,7 +270,7 @@ describe('ready', () => {
expect(lines[0]).toBe(`READY #1 ${head} STAMP_VERSION=unstamped`);
expect(lines.slice(1, 9)).toEqual([
'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped', `base: origin/main@${repo.git('rev-parse', 'origin/main')}`,
`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=1)`, expect.stringMatching(/^PREGATE: pass tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=1$/),
`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=1)`, expect.stringMatching(new RegExp(`^PREGATE: pass tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=${CHECK_IDS.length}$`)),
'gate: command=none full_e2e=no gate-ahead=none', `SHIP_RECEIPT: pr=1 head=${head.slice(0, 12)} version=- base=main`,
]);
const unshipped = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(head, green, 'no receipt'));
@@ -294,11 +297,11 @@ describe('ready', () => {
expect(stalePregate.status).toBe(EXIT.refused);
expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
// A lane that cleared its pregate from a separate clone (gstack-pregate --remote cannot run in a worktree) holds the verdict under
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a run
// that holds the tests stage (what `--stage tests --remote` writes: checks=[lanes]) against origin/main under a reviewed policy stands in: a
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a
// complete run (every registered check, as `gstack-pregate --remote` writes) against origin/main under a reviewed policy stands in: a
// --only timing run, another base or a head policy is named on the PREGATE line instead.
const stateRoot = path.join(repo.root, 'state-root');
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: [{ id: 'lanes', status: 'pass' }] };
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: FULL_CHECKS };
const fromSuffix = `(from the state root's pregate/by-tree/${'TREE'}.json, recorded by another checkout)`;
const movedShim = ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } });
const movedTree = runBin(repo, 'gstack-wtree', []).out.trim();
@@ -322,13 +325,13 @@ describe('ready', () => {
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...complete }));
const fromMirror = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([fromMirror.status, fromMirror.err]).toEqual([EXIT.ok, '']);
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=${CHECK_IDS.length} ${suffix}`);
const missingFromMirror = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([missingFromMirror.status, missingFromMirror.err]).toEqual([EXIT.ok, '']);
expect(line(missingFromMirror.out, 'PREGATE:')).toContain(suffix);
const current = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, moved)], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([current.status, current.err]).toEqual([EXIT.ok, '']);
expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1`);
expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=${CHECK_IDS.length}`);
// Value: protects=a same-tree record that only waits on a remote receipt (the small-machine run) is cleared by the clone's mirror, an
// unreadable record falls through to the mirror, and a record whose own checks failed or stayed incomplete is never overridden by a green mirror,
// including an incomplete check the environment caused (PREGATE_ENVIRONMENT, #3480: a secrets scanner that never finished is not a scanned diff);
@@ -339,7 +342,7 @@ describe('ready', () => {
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'requires-remote', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: [obligation] }));
const uncleared = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([uncleared.status, uncleared.err]).toEqual([EXIT.ok, '']);
expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=${CHECK_IDS.length} ${suffix}`);
fs.writeFileSync(local, '{ not json');
const unreadable = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([unreadable.status, unreadable.err]).toEqual([EXIT.ok, '']);
@@ -530,7 +533,7 @@ describe('full_e2e gate evidence (review #3235 P1-1)', () => {
const head = makePr(repo, 'feature');
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: { check_runs: [{ status: 'completed', conclusion: 'success' }] } } });
const missing = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh);
expect(missing.status).toBe(EXIT.refused);
@@ -552,7 +555,7 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
const head = makePr(repo, 'pr-a');
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}), ...(workflowRuns ? { workflowRuns: { [head]: workflowRuns } } : {}) });
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
}
@@ -714,7 +717,7 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts,
const head = makePr(repo, 'pr-a');
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: FULL_CHECKS }));
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head), mergeable, headRefName: 'pr-a', ...pr } }, checks: { [head]: checks } });
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
}
+1 -1
View File
@@ -49,7 +49,7 @@ function stamp(repo: FixtureRepo): string {
async function pregateAt(repo: FixtureRepo): Promise<PregateFile> {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
const file = await runPregate(loaded.ctx, { stage: 'all' });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
return file;
}
+3 -3
View File
@@ -11,7 +11,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, type PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, clearObligation, runPregate, verifyPregate, type ClearDecision, type RunCheck } from '../lib/pregate/index';
import { CHECK_IDS, clearDecision, clearObligation, runPregate, verifyPregate, type ClearDecision, type RunCheck } from '../lib/pregate/index';
import { exceptionBinding, type FailureDeps, type GrantRow } from '../lib/pregate/flaky-exception';
import type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
@@ -44,7 +44,7 @@ function fixture(): FixtureRepo {
async function pregateAt(repo: FixtureRepo): Promise<PregateFile> {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
const file = await runPregate(loaded.ctx, { stage: 'all' });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
return file;
}
@@ -296,7 +296,7 @@ describe.skipIf(process.platform === 'win32')('ready with a flaky exception', ()
const exception = { lane: LANE, repo: 'o/r', pr: 1, head, tree, runs: ['400/1'], cases: [FLAKE] };
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({
schema_version: 1, tree, head, verdict: 'pass-with-exception', checks: [{ id: 'lanes', status: 'requires-remote' }],
schema_version: 1, tree, head, verdict: 'pass-with-exception', checks: CHECK_IDS.map(id => ({ id, status: id === 'lanes' ? 'requires-remote' : 'pass' })),
requires_remote: [{ lane: LANE, platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: `remote-gate tree=${tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` }],
exceptions: [{ ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) }],
}));
+1
View File
@@ -98,6 +98,7 @@ describe('gstack-pregate --policy-from head: additive only (GST-419)', () => {
expect(dropped.status).toBe(EXIT.refused);
expect(dropped.err).toContain('guards');
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...user, dependencies: NEW_LANE }) });
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'preflight', '--policy-from', 'head'], { GSTACK_PROJECT_SLUG: SLUG }).status).toBe(EXIT.ok);
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
const verify = () => runBin(repo, 'gstack-pregate', ['verify'], { GSTACK_PROJECT_SLUG: SLUG });
expect(verify().status).toBe(EXIT.ok);
+119
View File
@@ -0,0 +1,119 @@
/**
* A pregate record is publication evidence only when it holds every registered check (GST-2194). A `--only <check>` or
* `--stage` run writes a record with some checks missing, so `gstack-pregate verify` (and through it
* `gstack-ship-queue ready` and `gstack-ship-receipt write`) refuses it PREGATE_INCOMPLETE. A staged run at the same
* head, tree, base and policy as the record it replaces keeps the checks it did not run, so rerunning one stage
* neither drops an earlier failure nor loses an earlier pass. Pinned on result codes, exit codes and the PREGATE line.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { CHECK_IDS } from '../lib/pregate/index';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo(); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const run = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', ...args]);
const verify = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['verify']);
const record = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')) as { tree: string; verdict: string; checks: { id: string; status: string }[] };
const ALL = CHECK_IDS.length;
const fakePat = () => ['ghp', Array.from(randomBytes(36), b => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[b % 62]).join('')].join('_');
// Like test/pregate-remote.test.ts, this expects the Linux-host lanes verdict: on windows-latest the fixture's Linux lane is a
// requires-remote obligation too, so the single-obligation assertions and `verify` ok do not hold there. The curation list in
// scripts/lib/windows-curation.ts is the usual home for that; this file skips itself so the shared list is not edited here.
describe.skipIf(process.platform === 'win32')('pregate verify refuses a partial record', () => {
// Value: protects=a secrets failure from the full run cannot be erased by the tests-stage rerun pregate's own fix line
// suggests, after a commit moved the tree; fails_when=verify accepts a tests-only record (the GSTA-461 audit repro:
// PREGATE_VERIFY ok with the token still in HEAD); why_new=no test ran verify on a staged record; seam=none;
// no_claim=gstack-ship-queue ready's own wording (it calls verifyPregate)
test('a tests-stage rerun after a commit cannot hide the full run\'s secrets failure', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': `exports.add = (a, b) => a + b;\nexports.token = '${fakePat()}';\n` });
expect(run(repo, []).status).toBe(EXIT.fail);
expect(record(repo).checks.find(c => c.id === 'secrets')?.status).toBe('fail');
expect(verify(repo).out).toContain('PREGATE_VERIFY: refused PREGATE_LANE_FAILED');
commit(repo, { 'README.md': '# fixture, lane fixed\n' });
const staged = run(repo, ['--stage', 'tests']);
expect(staged.status, staged.out + staged.err).toBe(EXIT.ok);
expect(staged.out).toMatch(new RegExp(`^PREGATE: \\S+ tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=1 of ${ALL}$`, 'm'));
const v = verify(repo);
expect(v.status).toBe(EXIT.fail);
expect(v.out).toContain('PREGATE_VERIFY: refused PREGATE_INCOMPLETE');
expect(v.err).toContain(`checks=1 of ${ALL}`);
expect(v.err).toMatch(/missing: [^;]*\bsecrets\b/);
}, 120_000);
// Value: protects=a one-check or one-stage run on a fresh tree is never a full pass; fails_when=verify accepts
// `--only strays` (checks=1) or `--stage preflight` (no lanes); why_new=the audit's second repro had no test;
// seam=none; no_claim=which checks a full run selects
test('--only and --stage preflight records refuse PREGATE_INCOMPLETE and name the missing checks', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const only = run(repo, ['--only', 'strays']);
expect(only.status).toBe(EXIT.ok);
expect(only.out).toMatch(new RegExp(`^PREGATE: pass tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=1 of ${ALL}$`, 'm'));
const v1 = verify(repo);
expect(v1.status).toBe(EXIT.fail);
expect(v1.out).toContain('PREGATE_VERIFY: refused PREGATE_INCOMPLETE');
expect(v1.err).toContain(`checks=1 of ${ALL}`);
const pre = run(repo, ['--stage', 'preflight']);
expect(pre.status).toBe(EXIT.ok);
const v2 = verify(repo);
expect(v2.status).toBe(EXIT.fail);
expect(v2.out).toContain('PREGATE_VERIFY: refused PREGATE_INCOMPLETE');
expect(v2.err).toContain(`checks=${ALL - 1} of ${ALL} (missing: lanes)`);
}, 120_000);
// Value: protects=a staged rerun at the same tree keeps the failure it did not rerun; fails_when=the tests-stage
// record replaces the failed full run and verify passes with the token in HEAD; why_new=no test reran one stage at
// an unchanged tree; seam=none; no_claim=a record for another head or tree
test('a tests-stage rerun at the same tree keeps the earlier secrets failure', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': `exports.add = (a, b) => a + b;\nexports.token = '${fakePat()}';\n` });
expect(run(repo, []).status).toBe(EXIT.fail);
const staged = run(repo, ['--stage', 'tests']);
expect(staged.status).toBe(EXIT.fail);
expect(staged.out).toMatch(/^PREGATE_MERGED: kept \S*secrets\S* /m);
expect(record(repo).checks.map(c => c.id)).toEqual([...CHECK_IDS]);
expect(record(repo).checks.find(c => c.id === 'secrets')?.status).toBe('fail');
expect(verify(repo).out).toContain('PREGATE_VERIFY: refused PREGATE_LANE_FAILED');
}, 120_000);
// Value: protects=today's correct paths keep verifying: a full run, and a staged or --only rerun at the same head and
// tree after a full green run; fails_when=the completeness rule refuses a full record or a same-tree rerun drops the
// checks it did not run; why_new=the merge rule is new; seam=none; no_claim=remote obligations
test('a full run verifies, and so does a staged or --only rerun at the same tree after it', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const full = run(repo, []);
expect(full.status).toBe(EXIT.ok);
expect(full.out).toMatch(new RegExp(`^PREGATE: pass tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=${ALL} of ${ALL}$`, 'm'));
expect(full.out).not.toContain('PREGATE_MERGED');
expect(verify(repo).out).toMatch(/^PREGATE_VERIFY: ok pregate pass is current for tree [0-9a-f]{12}$/m);
for (const args of [['--stage', 'tests'], ['--stage', 'preflight'], ['--only', 'strays']]) {
const again = run(repo, args);
expect(again.status, args.join(' ')).toBe(EXIT.ok);
expect(again.out).toMatch(/^PREGATE_MERGED: kept \S+ from the record at this tree [0-9a-f]{12}/m);
expect(again.out).toMatch(new RegExp(`checks=${ALL} of ${ALL}$`, 'm'));
expect(record(repo).checks.map(c => c.id)).toEqual([...CHECK_IDS]);
expect(verify(repo).out).toMatch(/^PREGATE_VERIFY: ok pregate pass is current for tree [0-9a-f]{12}$/m);
}
}, 180_000);
// Value: protects=checks carry only into a rerun at the same head, not a reworded commit with the same tree;
// fails_when=a staged run merges a record from another head; why_new=the merge rule is new; seam=none; no_claim=base moves
test('a staged run at a new head with the same tree keeps nothing', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
expect(run(repo, []).status).toBe(EXIT.ok);
repo.git('commit', '-q', '--amend', '-m', 'reworded');
const staged = run(repo, ['--stage', 'tests']);
expect(staged.status).toBe(EXIT.ok);
expect(staged.out).not.toContain('PREGATE_MERGED');
expect(verify(repo).out).toContain('PREGATE_VERIFY: refused PREGATE_INCOMPLETE');
}, 120_000);
});
+1
View File
@@ -67,6 +67,7 @@ describe('requires-remote on a gbrain-shaped repo (ci:ubicloud, UBI_OWNER)', ()
test('--remote runs the package script with the caller\'s UBI_OWNER and key, and its report clears the obligation', () => {
const repo = gbrainShaped();
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--allow-repo-commands']).status).toBe(EXIT.ok);
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'tests', '--remote', '--allow-repo-commands', '--spend-cap', '5'], { UBI_OWNER: 'gbra49q2', UBICLOUD_API_KEY: 'fixture-not-a-key' });
expect(r.status).toBe(EXIT.ok);
const file = pregateFile(repo);
+4 -4
View File
@@ -64,10 +64,10 @@ function stamp(repo: FixtureRepo): string {
}
/** pregate.json for the current head on a 4-core machine: the test tier is the remote_gate obligation, uncleared. */
async function pregateAt(repo: FixtureRepo) {
async function pregateAt(repo: FixtureRepo, stage: 'tests' | 'all' = 'tests') {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
const file = await runPregate(loaded.ctx, { stage });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true });
fs.writeFileSync(path.join(repo.work, PREGATE_FILE), JSON.stringify(file));
@@ -87,7 +87,7 @@ describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-o
test('a receipt for the gated tree clears the stamped head; the substitution names the trees, the policy and every file with its rule, and verify passes', async () => {
const { repo, gated, gatedTree } = gatedRepo();
const head = stamp(repo);
const file = await pregateAt(repo);
const file = await pregateAt(repo, 'all');
expect(file.tree).toBe(repo.git('rev-parse', `${head}^{tree}`));
expect(file.tree).not.toBe(gatedTree);
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
@@ -207,7 +207,7 @@ describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-o
test('a same-tree receipt after a substituted clear drops the recorded substitution', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
const file = await pregateAt(repo, 'all');
expect(clear(repo, ['--receipt', receiptFile(repo, gatedTree)]).status).toBe(EXIT.ok);
expect(pregateJson(repo).requires_remote[0].substitution).toBeDefined();
expect(clear(repo, ['--receipt', receiptFile(repo, file.tree)]).status).toBe(EXIT.ok);
+1
View File
@@ -175,6 +175,7 @@ describe('gstack-pregate --remote (C-c)', () => {
test('a gate reporting the tested tree, tests > 0 and failed = 0 clears the obligation and reports its spend', () => {
const repo = gated();
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--allow-repo-commands']).status).toBe(EXIT.ok);
const r = remote(repo, ['--remote', '--allow-repo-commands'], { GSTACK_SPEND_CAP_USD: '4', GATE_SPENT: '0.37' });
expect(r.status).toBe(EXIT.ok);
const file = pregateFile(repo);
+3 -2
View File
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { loadConfigFromUser, validateConfig } from '../lib/pregate/config';
import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context';
import { runPregate, verifyPregate } from '../lib/pregate/index';
import { CHECK_IDS, runPregate, verifyPregate } from '../lib/pregate/index';
import { BASE_PREGATE, BIN, cleanup, cloneFixture, commit, fixtureTemplate, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { runScriptSync } from '../lib/script-invocation';
@@ -362,7 +362,7 @@ describe('gstack-pregate with a user-owned policy', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-dangle-'));
try {
const userPolicy = path.join(dir, 'projects', 's', 'pregate.json');
const record = { schema_version: 1, tree: 'T', checks: [], policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any;
const record = { schema_version: 1, tree: 'T', checks: CHECK_IDS.map(id => ({ id, status: 'pass' })), policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any;
expect(verifyPregate(dir, record, { tree: 'T' }).ok).toBe(true);
fs.symlinkSync(path.join(dir, 'not-yet-cloned'), path.join(dir, 'projects'));
expect((loadConfigFromUser(userPolicy) as { errors: { code: string }[] } | null)?.errors.map(e => e.code)).toEqual(['PREGATE_CONFIG_INVALID']);
@@ -430,6 +430,7 @@ describe('gstack-pregate with a user-owned policy', () => {
const { repo, userPolicy, run } = setup();
const verify = () => JSON.parse(runBin(repo, 'gstack-pregate', ['verify', '--json'], { GSTACK_PROJECT_SLUG: SLUG }).out);
writePolicy(userPolicy, { local: LOCAL_ALL });
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'preflight'], { GSTACK_PROJECT_SLUG: SLUG }).status).toBe(EXIT.ok);
expect(run().status).toBe(EXIT.ok);
expect(verify().ok).toBe(true);
writePolicy(userPolicy, { local: ['free-tests.yml/unit'] });
+3 -1
View File
@@ -51,8 +51,10 @@ function world(f: ShipFx, files: string[] = TESTS): void {
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
}
/** The remote gate for the checked-out tree, as gbrain ran it: gstack-pregate --remote, then verify. */
/** The remote gate for the checked-out tree, as gbrain ran it: the preflight stage, gstack-pregate --remote, then verify. */
function gate(f: ShipFx): string {
const pre = f.run(path.join(BIN, 'gstack-pregate'), ['--base', 'main', '--stage', 'preflight', '--allow-repo-commands']);
expect(pre.status, pre.stdout + pre.stderr).toBe(0);
const r = f.run(path.join(BIN, 'gstack-pregate'), ['--base', 'main', '--stage', 'tests', '--remote', '--no-ci-carry', '--allow-repo-commands', '--spend-cap', '5']);
expect(r.status, r.stdout + r.stderr).toBe(0);
const v = f.run(path.join(BIN, 'gstack-pregate'), ['verify']);