mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
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:
1 parent
00cb80cce8
commit
56c4e873b2
18 files changed
+250
-50
No files matched your search
+10
-5
@@ -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).
|
||||
@@ -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 }] }));
|
||||
|
||||
@@ -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\\)$`));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) }],
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in new issue
Block a user