mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
fix(ship): record-ci verifies the CI run itself; finish's deferred preflight keeps pregate.json
/ship pass 1 findings red-team#1 and specialist-batched#1 (GSTA-258 piece A). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9125936247
commit
8fa4aca3ce
6 files changed
+106
-82
No files matched your search
+40
-25
@@ -4,7 +4,7 @@
|
||||
* IRON LAW ("no completion claims without fresh verification evidence").
|
||||
*
|
||||
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
|
||||
* gstack-evidence record-ci --label <L> --runs <id/attempt,...> --profiles <p,...> --tree <tree> --tests <n> --finished <iso> -- <cmd>
|
||||
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
|
||||
* gstack-evidence carried [--json]
|
||||
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
|
||||
* [--max-age <hours>] [--allow-paths <csv>]
|
||||
@@ -62,10 +62,11 @@
|
||||
* reuse appends a copy that keeps the original timestamp, so check's max age
|
||||
* still measures the real run.
|
||||
*
|
||||
* `record-ci` writes the explicit CI-sourced row for a lane CI carried
|
||||
* (lib/ci-carry.ts): source "ci", the runs and profiles, the run's finish time
|
||||
* as ts, bound to the clean working tree it names; `carried` lists those rows
|
||||
* at the current tree for the ship receipt.
|
||||
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
|
||||
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
|
||||
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
|
||||
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
|
||||
* rows at the current tree for the ship receipt.
|
||||
*
|
||||
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
|
||||
* A synced record citing an unsynced log would grade FRESH on a machine where
|
||||
@@ -89,6 +90,10 @@ import { resultLine } from "../lib/result-codes";
|
||||
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
|
||||
import { versionAtRef } from "../lib/restamp";
|
||||
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
|
||||
import { findCarry } from "../lib/ci-carry";
|
||||
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
|
||||
import { buildRunCheck } from "../lib/pregate/run-check";
|
||||
import { runInventory } from "../lib/run-evidence";
|
||||
|
||||
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
@@ -620,32 +625,41 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `record-ci`: the explicit CI-sourced row for a lane CI carried at this clean tree (lib/ci-carry.ts). Never runs anything. */
|
||||
/**
|
||||
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
|
||||
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
|
||||
*/
|
||||
function cmdRecordCi(argv: string[]): number {
|
||||
const sep = argv.indexOf("--");
|
||||
const opts = sep < 0 ? argv : argv.slice(0, sep);
|
||||
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
|
||||
const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const runs = (get("--runs") ?? "").split(",").filter(Boolean);
|
||||
const profiles = (get("--profiles") ?? "").split(",").filter(Boolean);
|
||||
const tree = get("--tree") ?? "";
|
||||
const tests = Number(get("--tests"));
|
||||
const finished = get("--finished") ?? "";
|
||||
const base = get("--base");
|
||||
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
|
||||
if (!label || !command || !runs.length || runs.some((r) => !/^\d{1,15}\/\d{1,4}$/.test(r)) || !profiles.length || !/^[0-9a-f]{40,64}$/.test(tree) || !Number.isInteger(tests) || tests <= 0 || !Number.isFinite(Date.parse(finished))) {
|
||||
console.error("usage: gstack-evidence record-ci --label <L> --runs <id/attempt,...> --profiles <p,...> --tree <tree> --tests <n > 0> --finished <iso> -- <command>");
|
||||
if (!label || !base || !command || opts.length !== 4) {
|
||||
console.error("usage: gstack-evidence record-ci --label <L> --base <base> -- <command>");
|
||||
return 2;
|
||||
}
|
||||
const wtree = currentWtree();
|
||||
const headTree = git(["rev-parse", "HEAD^{tree}"]);
|
||||
if (wtree !== tree) {
|
||||
console.error(`gstack-evidence record-ci: ${tree.slice(0, 12)} is not the working tree (${wtree ? wtree.slice(0, 12) : "fingerprint unavailable"}); nothing recorded`);
|
||||
const none = (reason: string, detail: string) => {
|
||||
console.error(`CARRY_NONE: ${reason}: ${detail}`);
|
||||
console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`);
|
||||
return 1;
|
||||
}
|
||||
if (headTree !== tree) {
|
||||
console.error(`gstack-evidence record-ci: the working tree is not HEAD's committed tree (${headTree ? headTree.slice(0, 12) : "no HEAD"}); a carry needs a clean, committed tree; nothing recorded`);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
const cwd = process.cwd();
|
||||
const loaded = loadConfigFromBase(cwd, base);
|
||||
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
|
||||
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
|
||||
const from = loaded.config.ship_lanes_from;
|
||||
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
|
||||
const m = from[command]!;
|
||||
const check = buildRunCheck(cwd, null, true, base);
|
||||
if ("error" in check) return none("profiles_unavailable", check.error);
|
||||
const head = git(["rev-parse", "HEAD"]);
|
||||
const tree = git(["rev-parse", "HEAD^{tree}"]);
|
||||
if (!head || !tree) return none("no_head", "HEAD is unreadable");
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
|
||||
if (!v.ok) return none(v.reason, v.detail);
|
||||
let paths: ReturnType<typeof ledgerPath>;
|
||||
try {
|
||||
paths = ledgerPath();
|
||||
@@ -654,12 +668,13 @@ function cmdRecordCi(argv: string[]): number {
|
||||
console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`);
|
||||
return 1;
|
||||
}
|
||||
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
|
||||
const rec: EvidenceRecord = {
|
||||
ts: new Date(finished).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
|
||||
commit: git(["rev-parse", "HEAD"]), tree: headTree, dirty: false, wtree, tests_ran: tests, source: "ci", runs, profiles,
|
||||
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles,
|
||||
};
|
||||
appendJsonl(paths.file, rec, { mode: 0o600 });
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${tests} log=- source=ci runs=${runs.join(",")}`);
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -956,9 +956,11 @@ it and the command that lists its test files:
|
||||
```
|
||||
|
||||
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
|
||||
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci`:
|
||||
`source: "ci"`, its runs and profiles, the run's finish time as `ts`, bound to
|
||||
the clean tree), never a fictitious local run. `gstack-evidence check` grades it
|
||||
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
|
||||
--label <label> --base <base> -- <command>`, which finds and verifies the run
|
||||
itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
|
||||
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
|
||||
fictitious local run. `gstack-evidence check` grades it
|
||||
like any row and names `source=ci runs=…`, so `finish`'s FRESH check and the
|
||||
24-hour age limit apply to the CI run's own time. A mapped lane that cannot
|
||||
carry prints `CI_CARRY: none label=<label> (<reason>)` and runs here. Every
|
||||
@@ -972,7 +974,8 @@ only; across machines, CI is the trust boundary.
|
||||
**Finish, push and accept.** `gstack-ship-release finish` commits the release
|
||||
metadata, so the stamped tree has no CI run yet. A mapped lane that is not
|
||||
already FRESH is deferred (`EVIDENCE: DEFERRED`), and the pregate runs only its
|
||||
preflight stage on the stamped tree. `gstack-ship-release push --early` (the
|
||||
preflight stage on the stamped tree, written to its own file so the full
|
||||
`.gstack/tmp/pregate.json` stays in place until `accept` replaces it. `gstack-ship-release push --early` (the
|
||||
Phase 2 push that lets CI run beside the review) runs the secrets scan and the
|
||||
sealed guard before it pushes. After the final push,
|
||||
`gstack-ship-release accept --base <base> --lane …` waits (bounded by `--wait`)
|
||||
|
||||
+9
-21
@@ -9,16 +9,13 @@ import { spawn, spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findCarry } from './ci-carry';
|
||||
import { fragmentSlug } from './changelog-fragments';
|
||||
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { runInventory } from './run-evidence';
|
||||
import { parseUnifiedDiff, versionAtRef } from './restamp';
|
||||
import { branchSides, stampOnlyLine } from './stamp-only';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { extractVersion } from './version-source';
|
||||
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation, wtreeFingerprint } from './script-invocation';
|
||||
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
|
||||
|
||||
export interface Run { status: number | null; stdout: string; stderr: string }
|
||||
const done = (r: { status: number | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; error?: Error }): Run =>
|
||||
@@ -186,23 +183,14 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
|
||||
}
|
||||
|
||||
/**
|
||||
* A mapped lane carried from CI at HEAD's clean tree (lib/ci-carry.ts), recorded as a CI-sourced ledger row; or why it was
|
||||
* not (`carryNone`), so the caller runs it.
|
||||
* A mapped lane carried from CI at HEAD's clean tree: gstack-evidence record-ci finds and verifies the run (lib/ci-carry.ts)
|
||||
* and records the CI-sourced ledger row; or why it was not (`carryNone`), so the caller runs it.
|
||||
*/
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }, m: { profiles: string[]; inventory: string }): LaneResult {
|
||||
const none = (why: string): LaneResult => ({ label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: why });
|
||||
const check = buildRunCheck(cwd, null, true, base);
|
||||
if ('error' in check) return none(`profiles_unavailable: ${check.error}`);
|
||||
const head = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd }); return 'tree' in fp ? fp.tree : null; };
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
|
||||
if (!v.ok) return none(`${v.reason}: ${v.detail}`);
|
||||
const runs = v.runs.map(e => `${e.run_id}/${e.run_attempt}`).join(',');
|
||||
const finished = v.runs.map(e => e.finished_at).sort()[0]!;
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--runs', runs, '--profiles', m.profiles.join(','), '--tree', tree, '--tests', String(v.tests), '--finished', finished, '--', l.command], 60_000);
|
||||
if (rec.status !== 0) return none(`record_failed: ${firstLine(rec.stderr)}`);
|
||||
return { label: l.label, exit: 0, testsRan: String(v.tests), log: '-', carried: runs };
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
|
||||
const got = / tests_ran=(\d+) log=- source=ci runs=(\S+)/.exec(rec.stderr);
|
||||
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[1]!, log: '-', carried: got[2]! };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +207,7 @@ export async function runLanes(cwd: string, list: { label: string; command: stri
|
||||
const i = next++;
|
||||
const l = list[i]!;
|
||||
const m = mapped.get(l.label);
|
||||
const carry = m ? carryLane(cwd, opts.base!, l, m) : null;
|
||||
const carry = m ? carryLane(cwd, opts.base!, l) : null;
|
||||
if (carry?.carried) { out[i] = carry; continue; }
|
||||
const r = await binAsync(cwd, 'gstack-evidence', ['run', ...(opts.reuse === false ? [] : ['--reuse']), '--label', l.label, '--', l.command]);
|
||||
const rec = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
|
||||
|
||||
+4
-3
@@ -367,7 +367,7 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
let e = check(l);
|
||||
if (!/EVIDENCE: FRESH/.test(e)) {
|
||||
const m = mapped.get(l.label);
|
||||
const res = m ? carryLane(cwd, base, l, m) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
if (m && !res.carried) {
|
||||
r.add(`EVIDENCE: DEFERRED label=${l.label} (${short([res.carryNone ?? 'not carried'], 1).slice(0, 160)}): CI at the pushed head carries it in accept`);
|
||||
deferred.push(l.label);
|
||||
@@ -384,7 +384,8 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
}
|
||||
const laneArgs = list.map(l => `--lane '${l.label}=${l.command.replace(/'/g, `'\\''`)}'`).join(' ');
|
||||
if (deferred.length) {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--stage', 'preflight'], 3_600_000);
|
||||
const out = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'gstack/pregate-finish-preflight.json']).stdout.trim() || '.git/gstack/pregate-finish-preflight.json');
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--stage', 'preflight', '--out', out], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
r.add(`PREGATE: preflight ${verdict} on the stamped tree (the tests stage runs in accept, carried from CI)`);
|
||||
if (!/^(pass|warn)/.test(verdict)) return r.stop(`pregate preflight ${verdict} on the stamped tree: fix it, then rerun finish`);
|
||||
@@ -433,7 +434,7 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
}
|
||||
for (const l of list) {
|
||||
const m = mapped.get(l.label);
|
||||
const res = m ? carryLane(cwd, base, l, m) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
if (m && !res.carried) { r.add(carryNoneLine(res)!); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
|
||||
const ran = laneLine(res);
|
||||
r.add(ran.line);
|
||||
|
||||
+24
-29
@@ -3,9 +3,10 @@
|
||||
*
|
||||
* run --reuse: a lane that passed at this exact working tree, command and execution identity never runs again;
|
||||
* the newest matching row decides (a newer failure vetoes), the reused row keeps its original timestamp and the
|
||||
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row, never a fictitious local run.
|
||||
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row for a run it verified itself, never a fictitious local run.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -106,41 +107,35 @@ describe('gstack-evidence run --reuse (A5)', () => {
|
||||
});
|
||||
|
||||
describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
const ci = (extra: string[] = []) => run(['record-ci', '--label', 'free', '--runs', '41/2', '--profiles', 'ci-reuse/free-tests', '--tree', wtree(), '--tests', '900', '--finished', new Date(Date.now() - 600_000).toISOString(), ...extra, '--', 'bun run test']);
|
||||
const ciRow = () => {
|
||||
const tree = wtree();
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'] };
|
||||
};
|
||||
|
||||
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source; fails_when=carry fakes a local run or a check cannot tell them apart
|
||||
test('a CI-sourced row names its runs, is FRESH for the exact command, and never runs anything', () => {
|
||||
const r = ci();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('gstack-evidence: recorded label=free exit=0 tests_ran=900 log=- source=ci runs=41/2');
|
||||
const row = rows().at(-1);
|
||||
expect(row).toMatchObject({ label: 'free', source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'], exit: 0, tests_ran: 900, wtree: wtree(), duration_s: 0 });
|
||||
expect(row.log_path).toBeUndefined();
|
||||
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
|
||||
// fails_when=a check cannot tell a CI row from a local run, or a stale carry from an earlier head reaches ci_carried
|
||||
test('a CI-sourced row is FRESH for its exact command, and carried lists it only at its tree', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(2)]).status).toBe(0);
|
||||
fs.appendFileSync(ledger(), JSON.stringify(ciRow()) + '\n');
|
||||
const chk = run(['check', '--label', 'free', '--expect-cmd', 'bun run test', '--max-age', '24']);
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
|
||||
expect(chk.stdout).toContain('source=ci runs=41/2');
|
||||
const carried = run(['carried', '--json']);
|
||||
expect(JSON.parse(carried.stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
});
|
||||
|
||||
// Value: protects=a carry binds to the working-tree fingerprint (A2); fails_when=a dirty tree or another tree records a carried row
|
||||
test('record-ci refuses a tree that is not the working tree, or a dirty tree, and writes nothing', () => {
|
||||
const bad = run(['record-ci', '--label', 'free', '--runs', '41/2', '--profiles', 'p', '--tree', 'a'.repeat(40), '--tests', '9', '--finished', new Date().toISOString(), '--', 'bun run test']);
|
||||
expect(bad.status).toBe(1);
|
||||
expect(bad.stderr).toContain('is not the working tree');
|
||||
fs.writeFileSync(path.join(repo, 'new.txt'), 'untracked\n');
|
||||
const dirty = ci();
|
||||
expect(dirty.status).toBe(1);
|
||||
expect(dirty.stderr).toContain('not HEAD\'s committed tree');
|
||||
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=the receipt names only carries at the current tree; fails_when=a stale carry from an earlier head reaches ci_carried
|
||||
test('carried lists nothing once the tree moves on', () => {
|
||||
expect(ci().status).toBe(0);
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
|
||||
gitIn(repo, 'commit -qam two');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=record-ci finds and verifies the CI run itself, so no caller supplies run ids, a test count or a tree (/ship pass 1 red-team#1);
|
||||
// fails_when=the forged call from that finding writes a FRESH carried row; seam=no origin, so the base's ship_lanes_from is unreadable
|
||||
test('record-ci takes no run facts from its caller and records nothing without a verified run', () => {
|
||||
const forged = run(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', wtree(), '--tests', '999', '--finished', new Date().toISOString(), '--', 'bun run test']);
|
||||
expect(forged.status).toBe(2);
|
||||
expect(forged.stderr).toContain('usage: gstack-evidence record-ci --label <L> --base <base> -- <command>');
|
||||
const unverified = run(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
|
||||
expect(unverified.status).toBe(1);
|
||||
expect(unverified.stderr).toMatch(/^CARRY_NONE: config_unavailable: /m);
|
||||
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,25 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
expect(other).not.toContain('CI_CARRY');
|
||||
expect(lane(other, 'free')).not.toContain('carried');
|
||||
});
|
||||
|
||||
// Value: protects=a CI-sourced ledger row exists only for a run gstack-evidence record-ci found and verified itself (/ship pass 1 red-team#1);
|
||||
// fails_when=caller-supplied run ids, test counts or a tree make a FRESH carried row, or an unmapped command or a missing run records one; seam=fake gh
|
||||
test('record-ci verifies the run itself: forged facts, an unmapped command or no run at HEAD record nothing', () => {
|
||||
const f = fx();
|
||||
const ev = (args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
|
||||
const forged = ev(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), '--tests', '999', '--finished', at(1), '--', FREE]);
|
||||
expect(forged.status).toBe(2);
|
||||
world(f, { run: { head_sha: '0'.repeat(40) } });
|
||||
const noRun = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([noRun.status, noRun.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: no_run: /m)]);
|
||||
world(f);
|
||||
const unmapped = ev(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
|
||||
expect([unmapped.status, unmapped.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
|
||||
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
|
||||
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
|
||||
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finish, push --early and accept (A1)', () => {
|
||||
@@ -105,9 +124,12 @@ describe('finish, push --early and accept (A1)', () => {
|
||||
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
|
||||
const post = snapshot(f);
|
||||
const pregateFile = path.join(f.work, '.gstack/tmp/pregate.json');
|
||||
const before = fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null;
|
||||
const fin = f.run(RELEASE, finishArgs(f, reviewed, post));
|
||||
expect(fin.stdout).toMatch(/^EVIDENCE: DEFERRED label=free \(/m);
|
||||
expect(lineOf(fin.stdout, 'PREGATE')).toMatch(/^PREGATE: preflight (pass|warn) on the stamped tree/);
|
||||
expect(fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null).toBe(before);
|
||||
expect(fin.stdout).toContain('gstack-ship-release accept --base main --lane');
|
||||
expect(fin.status).toBe(0);
|
||||
expect(runs(f, 'free-runs')).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user