mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +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
@@ -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