mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
ship accept: a lanes-only head policy gates the final tree; owner-granted test-lane exception (GST-1595) (#4061)
* ship accept: a lanes-only head policy gates the final tree; name bun-hidden JUnit suites; owner-granted test-lane exception (GST-1595) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Each excepted file is preflighted on its own and must run a counted test; the preflighted files count toward the lane's tests_ran; grants are kept per head and file set; lanesOnlyHeadPolicy compares .gstack/generated.json with the fork copy. Review-Finding: v1/outside-voice#1 reader=outside-voice Review-Finding: v1/outside-voice#2 reader=outside-voice Review-Finding: v1/outside-voice#3 reader=outside-voice Review-Finding: v1/adversarial-native#2 reader=adversarial-native Review-Finding: v1/adversarial-native#4 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: lanesOnlyHeadPolicy compares generated.json with the base tip again A --policy-from head run loads the regen registry from the branch (lib/pregate/context.ts), so a branch whose .gstack/generated.json differs from main's tip keeps the base policy; comparing with the fork copy would gate under a stale registry. Review-Finding: p2/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting entries for the accept head policy and test-lane exceptions (GST-1595) 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
3c7ff030b2
commit
05114c8049
20 files changed
+624
-48
No files matched your search
+24
-3
@@ -9,6 +9,7 @@
|
||||
* gstack-approvals render [--no-heading] [--full] [--branch <name>]
|
||||
* gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled> [--json]
|
||||
* gstack-approvals rulings [--branch <name>]
|
||||
* gstack-approvals lane-exception --head <pushed head> --files <f1,f2> --reason <why> --words <exact words> --by <who granted> [--json]
|
||||
* gstack-approvals list [--json] [--branch <name>]
|
||||
* gstack-approvals sync [--branch <name>]
|
||||
* gstack-approvals path [--branch <name>]
|
||||
@@ -26,6 +27,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
||||
import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception';
|
||||
|
||||
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
|
||||
|
||||
@@ -39,6 +41,11 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
|
||||
counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body
|
||||
list it; refused inside this checkout's open /ship run
|
||||
rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry
|
||||
lane-exception --head <the pushed head the owner granted at> --files <the uncovered inventory files, comma-separated>
|
||||
--reason <why, one line> --words <the owner's exact words> --by <who granted>
|
||||
the owner's grant that a CI-carried lane counts although exactly those files are in no run's tree.json
|
||||
(docs/ship-policy.md#test-lane-exceptions); gstack-evidence record-ci binds it only at that head, for that exact
|
||||
set, after a local preflight of those files passes; refused inside this checkout's open /ship run
|
||||
render [--no-heading] [--full] the ## Approvals section for a PR body or report; a public repository, or one whose
|
||||
visibility gh cannot read, gets only "Approved: yes|no (record <id>)", because a PR description keeps
|
||||
its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private
|
||||
@@ -53,7 +60,7 @@ Example:
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head']);
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files']);
|
||||
|
||||
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
|
||||
const flags: Record<string, string> = {};
|
||||
@@ -121,9 +128,22 @@ function main(argv: string[]): number {
|
||||
const json = bools.has('--json');
|
||||
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
|
||||
|
||||
if (cmd === 'add' || cmd === 'import' || cmd === 'rule') {
|
||||
if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception') {
|
||||
let rows;
|
||||
if (cmd === 'rule') {
|
||||
if (cmd === 'lane-exception') {
|
||||
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
|
||||
const files = [...new Set((flags['--files'] ?? '').split(',').map(f => f.trim().replace(/^(\.\/)+/, '')).filter(Boolean))].sort();
|
||||
const reason = (flags['--reason'] ?? '').trim();
|
||||
if (!files.length || files.some(f => /\s/.test(f)) || !reason || /[\x00-\x1f\x7f]/.test(reason)) return invalid('lane-exception needs --files <the uncovered inventory files, comma-separated, no spaces> and --reason <why, one line>');
|
||||
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
|
||||
if (!flags['--head'] || head.status !== 0) return invalid('lane-exception needs --head <the pushed head the owner granted at, a commit in this repository>');
|
||||
if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a test-lane exception is the owner\'s, so --by names who granted it');
|
||||
const open = openShipRun(process.cwd());
|
||||
if (open) return invalid(`/ship run ${open} is open on this checkout: a test-lane exception is the owner's, never the lane's own runner's; record it from the owner's session`);
|
||||
const built = buildRow({ ...testLaneExceptionRow(head.stdout.trim(), files, reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
|
||||
if ('code' in built) return fail(cmd, built);
|
||||
rows = [built];
|
||||
} else if (cmd === 'rule') {
|
||||
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
|
||||
const finding = (flags['--finding'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim();
|
||||
if (!finding || /\s/.test(finding) || !reason) return invalid('rule needs --finding <the finding\'s fingerprint, no spaces> and --reason <why it ships as a note>');
|
||||
@@ -138,6 +158,7 @@ function main(argv: string[]): number {
|
||||
rows = [built];
|
||||
} else if (cmd === 'add') {
|
||||
if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' });
|
||||
if (TEST_LANE_EXCEPTION_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a test_lane_exception scope is recorded with gstack-approvals lane-exception' });
|
||||
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
|
||||
if ('code' in built) return fail(cmd, built);
|
||||
rows = [built];
|
||||
|
||||
+35
-7
@@ -100,6 +100,7 @@ import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/
|
||||
import { DEFAULT_MAX_AGE_HOURS, runChanged } from "../lib/run-evidence";
|
||||
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
|
||||
import { buildRunCheck } from "../lib/pregate/run-check";
|
||||
import { readTestLaneExceptions, testLaneExceptionText, type BoundLaneException } from "../lib/test-lane-exception";
|
||||
|
||||
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
@@ -140,6 +141,8 @@ interface EvidenceRecord {
|
||||
not_owed?: string;
|
||||
/** The per_file run of the inventory files CI skipped entirely; not_owed: the ones that skipped every test here too (GST-1314). */
|
||||
skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown"; not_owed?: string[] };
|
||||
/** The owner's test-lane exception that let a lane carry with uncovered inventory files, and the preflight of exactly them here (GST-1595). */
|
||||
exception?: BoundLaneException;
|
||||
}
|
||||
|
||||
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
|
||||
@@ -721,8 +724,9 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
|
||||
console.error("usage: gstack-evidence record-ci --label <L> --base <base> -- <command>");
|
||||
return 2;
|
||||
}
|
||||
const none = (reason: string, detail: string) => {
|
||||
const none = (reason: string, detail: string, diagnosis: string[] = []) => {
|
||||
console.error(`CARRY_NONE: ${reason}: ${detail}`);
|
||||
for (const d of diagnosis) console.error(`CARRY_DIAGNOSIS: ${d}`);
|
||||
console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`);
|
||||
return 1;
|
||||
};
|
||||
@@ -738,8 +742,29 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
|
||||
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, ...mappingCoverage(m, cwd) });
|
||||
if (!v.ok) return none(v.reason, v.detail);
|
||||
const grants = readTestLaneExceptions(cwd);
|
||||
const atHead = grants.filter((g) => g.head === head);
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd), ...(atHead.length ? { excepted: atHead } : {}) });
|
||||
const elsewhere = grants.filter((g) => g.head !== head).map((g) => `${g.approval} at ${g.head.slice(0, 12)}`);
|
||||
if (!v.ok) return none(v.reason, `${v.detail}${v.reason === "inventory_uncovered" && !atHead.length && elsewhere.length ? `; test-lane exception ${elsewhere.join(", ")} binds another head, not HEAD ${head.slice(0, 12)}` : ""}`, v.diagnosis);
|
||||
let exception: BoundLaneException | undefined;
|
||||
const grant = atHead.find((g) => g.approval === v.exceptedBy);
|
||||
if (v.excepted?.length && grant) {
|
||||
const preLabel = `${label}.excepted`;
|
||||
console.error(`gstack-evidence record-ci: test-lane exception ${grant.approval}: preflighting ${v.excepted.length} uncovered file(s) here at ${head.slice(0, 12)}, one at a time (label=${preLabel})`);
|
||||
let ran = 0;
|
||||
for (const file of v.excepted) {
|
||||
const preCommand = perFileCommand(m.per_file!, [file]);
|
||||
const exit = await cmdRun(["--label", preLabel, "--", preCommand]);
|
||||
let rows: EvidenceRecord[] = [];
|
||||
try { rows = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { rows = []; }
|
||||
const n = rows.findLast((r) => r.label === preLabel && r.source === undefined && r.cmd_sha256 === sha256(preCommand) && r.wtree === tree)?.tests_ran ?? "unknown";
|
||||
if (exit !== 0 || typeof n !== "number" || n <= 0) return none("exception_preflight_failed", `test-lane exception ${grant.approval}'s preflight of ${file} ${exit !== 0 ? `exited ${exit}` : `ran ${n} tests`} here (label=${preLabel}); the exception binds only when each of ${v.excepted.join(", ")} runs and passes at HEAD`);
|
||||
ran += n;
|
||||
}
|
||||
exception = { ...grant, preflight: { label: preLabel, tests_ran: ran } };
|
||||
}
|
||||
const testsRan = exception && typeof v.tests === "number" ? v.tests + v.excepted!.length : v.tests;
|
||||
let here: EvidenceRecord["skipped_here"];
|
||||
if (v.remainder?.length) {
|
||||
const hereLabel = `${label}.skipped`;
|
||||
@@ -760,11 +785,13 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
|
||||
}
|
||||
here = { label: hereLabel, files: v.remainder, tests_ran: ran, ...(notOwed.length ? { not_owed: notOwed } : {}) };
|
||||
if (notOwed.length) console.error(`gstack-evidence record-ci: not owed here: ${notOwed.length} remainder file(s) skipped every test on CI and here too (env- or tool-gated by design): ${notOwed.join(", ")}`);
|
||||
}
|
||||
if (here || exception) {
|
||||
for (const e of v.runs) {
|
||||
const changed = runChanged(check.deps, e.run_id, e.run_attempt);
|
||||
if (changed) return none("run_changed", changed);
|
||||
}
|
||||
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`);
|
||||
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${here?.label ?? exception!.preflight.label} ran`);
|
||||
}
|
||||
let paths: ReturnType<typeof ledgerPath>;
|
||||
try {
|
||||
@@ -777,11 +804,12 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
|
||||
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
|
||||
const rec: EvidenceRecord = {
|
||||
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, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}),
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: testsRan, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}), ...(exception ? { exception } : {}),
|
||||
};
|
||||
appendJsonl(paths.file, rec, { mode: 0o600 });
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`);
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${testsRan}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`);
|
||||
if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`);
|
||||
if (exception) console.error(`TEST_LANE_EXCEPTION: ${testLaneExceptionText(exception)}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -804,7 +832,7 @@ function cmdCarried(argv: string[]): number {
|
||||
for (const r of records) latest.set(r.label, r);
|
||||
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)
|
||||
&& gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: wtree }).verdict === "FRESH")
|
||||
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
|
||||
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree!, ...(r.exception ? { exception: r.exception } : {}) }; }));
|
||||
if (argv.includes("--json")) console.log(JSON.stringify(carried));
|
||||
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
|
||||
return 0;
|
||||
|
||||
@@ -57,6 +57,7 @@ import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import { readPause, receiptBaseRed } from '../lib/base-red';
|
||||
import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes';
|
||||
import { laneExceptionBodyLines } from '../lib/test-lane-exception';
|
||||
import { readMechanicalLines } from '../lib/ship-mechanical';
|
||||
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
|
||||
|
||||
@@ -265,6 +266,9 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
const ruledNotes = readRuledNotes(repoRoot);
|
||||
const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
||||
if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
|
||||
const carried = ciCarried(base);
|
||||
const unlistedException = bodyFile ? laneExceptionBodyLines(carried).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
||||
if (unlistedException) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedException)} (accept's TEST_LANE_EXCEPTION: line; the body carries it as one line); nothing written`) + '\n'); return EXIT.fail; }
|
||||
const mechanical = readMechanicalLines(repoRoot, git(['rev-parse', `${head}^{tree}`], repoRoot) ?? '');
|
||||
const unlistedRetake = bodyFile ? mechanical.find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
||||
if (unlistedRetake) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedRetake)} (finish's MECHANICAL: lines; the body carries each as one line); nothing written`) + '\n'); return EXIT.fail; }
|
||||
@@ -317,7 +321,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
|
||||
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
|
||||
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base),
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: carried,
|
||||
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
|
||||
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
|
||||
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **`/ship` accept gates under a branch's own pregate policy when it only adds test lanes, says why a carried lane's test file is missing from CI's JUnit tree, and takes an owner-granted test-lane exception.** On gstack-evals #240 a PR that added a runtime file and its own lane line passed preflight under `--policy-from head`, then `gstack-ship-release accept` reran the pregate under the base policy and refused with `PREGATE_NO_LANE`. `accept`, and `finish` when it reruns the whole pregate, now pass `--policy-from head` when the branch's `.gstack/pregate.json` differs from the copy it forked from in nothing but added test lanes and `.gstack/generated.json` is unchanged, printing `POLICY: head …`; a removed or shrunk lane, or any other changed key, keeps the base policy with `POLICY: base … (the branch's copy <what differs> …)`. The same PR then stopped on `inventory_uncovered`: bun registers an imported test file's tests under the importer, so its JUnit suite never appears. The refusal now adds `CARRY_DIAGNOSIS: bun registers <imported> under <importer>; its JUnit suite won't appear`, and the owner can record `gstack-approvals lane-exception --head <pushed head> --files <the uncovered files> --reason … --words … --by …` (refused inside the lane's open /ship run, by gstack, or through `add`). `gstack-evidence record-ci` binds it only at that head for exactly those files, after a local preflight of them with the lane's `per_file` runner passes; `accept` prints `TEST_LANE_EXCEPTION:`, the receipt's `ci_carried` entry records the grant and the preflight, and `gstack-ship-receipt write --body` refuses a body without the `Test-lane exception: …` line. See docs/pregate.md#accept-head-policy and docs/ship-policy.md#test-lane-exceptions. (GST-1595)
|
||||
+40
-1
@@ -39,7 +39,24 @@ read from the branch under `--policy-from head`. So an allowlist entry or a
|
||||
`[]` declaration a branch adds counts only once it is on the base, and a lane it
|
||||
adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line
|
||||
says so when the branch edits the file
|
||||
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with
|
||||
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)).
|
||||
<a id="accept-head-policy"></a>/ship's reruns on the final tree (`gstack-ship-release accept`, and `finish`
|
||||
when it reruns the whole pregate on the stamped tree) choose the policy themselves
|
||||
(GST-1595). When the branch's `pregate.json` differs from the copy it forked from
|
||||
(the base tip's when the fork has none) in nothing but added test lanes, and
|
||||
`.gstack/generated.json` matches the base tip's copy (a head-policy run loads the
|
||||
regen registry from the branch, so a copy that main changed after the fork keeps
|
||||
the base policy), they pass `--policy-from head` and print
|
||||
`POLICY: head (unreviewed) .gstack/pregate.json: adds <n> test lane(s) over
|
||||
origin/<base> and changes nothing else, so the gate is only stricter`. So a PR
|
||||
that adds a runtime file and its own lane line passes `accept` without a
|
||||
lanes-only PR first (gstack-evals #240). Any other difference (a removed or
|
||||
shrunk lane, a `[]` declaration, `warn`, `timeout_s`, `ship_lanes_from`, any
|
||||
other key, a changed `generated.json`, a file that does not parse) keeps the
|
||||
base policy as before, with `POLICY: base origin/<base>:.gstack/pregate.json (the
|
||||
branch's copy <what differs>, so it does not count here; land it on <base>
|
||||
first)`. A branch that leaves the file alone prints no `POLICY:` line.
|
||||
An unresolvable base exits 3 with
|
||||
`POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute
|
||||
paths, at most 64 per list); commands are one line under 512 bytes.
|
||||
|
||||
@@ -1202,6 +1219,28 @@ the same run. `gstack-ship-receipt write` records the carried runs as
|
||||
`ci_carried: [{ lane, run, attempt, tree }]`, and the `SHIP_RECEIPT:` line names
|
||||
them (`ci_carried=<lane>:<run>/<attempt>`).
|
||||
|
||||
<a id="carry-imported-tests"></a>**An inventory file CI's JUnit tree never lists (GST-1595).** bun registers
|
||||
the tests of a test file another test file imports under the importer, so the
|
||||
imported file's own JUnit suite never appears in `tree.json` and the carry
|
||||
refuses with `inventory_uncovered`. For each such file another inventory file
|
||||
imports by a relative path (`import`, `import()`, `require`), the refusal adds
|
||||
`CARRY_DIAGNOSIS: bun registers <imported> under <importer>; its JUnit suite
|
||||
won't appear` (`lib/ci-carry.ts` `importedTestLines`), printed by `gstack-evidence
|
||||
record-ci`, `gstack-ship-preflight` and `accept`; the remote gate's
|
||||
`inventory_uncovered` reason carries the same words. The fix is usually to move
|
||||
the shared code out of the `.test.` file. When that can't happen in this PR,
|
||||
the owner can grant a [test-lane exception](ship-policy.md#test-lane-exceptions)
|
||||
for exactly those files at the pushed head: `record-ci` then runs each of them
|
||||
here, one file at a time, with the entry's `per_file` runner (label
|
||||
`<lane>.excepted`) and carries the lane only when every file's run passes with a
|
||||
counted test; the lane's `tests_ran` counts those files beside the ones CI
|
||||
covered. A failed or zero-test run of any file refuses with
|
||||
`exception_preflight_failed`, and a grant at another head or for another file
|
||||
set binds nothing. The CI row and the receipt's `ci_carried` entry
|
||||
keep the grant and the preflight (`exception`), and `accept` prints
|
||||
`TEST_LANE_EXCEPTION: <files> at <head12>: preflight <label> ran <n> test(s)
|
||||
here; granted by <who> at <time>: <reason> (approval <id>)`.
|
||||
|
||||
**`gstack-ci-carry`.** `find [--profile <name>]… [--command <exact lane command>
|
||||
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
|
||||
polls the newest run at HEAD until it completes (run it in the background) and
|
||||
|
||||
@@ -1645,6 +1645,38 @@ Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
|
||||
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
|
||||
line word for word, and the READY block prints each as `RULED_NOTE:`.
|
||||
|
||||
<a id="test-lane-exceptions"></a>
|
||||
**Test-lane exceptions (GST-1595).** A lane `accept` carries from CI refuses with
|
||||
`inventory_uncovered` when an inventory file is in no run's `tree.json`; when bun
|
||||
hid it under an importing test file, the refusal says so
|
||||
([pregate.md](pregate.md#carry-imported-tests)). The owner, never the lane, can
|
||||
grant that the lane counts anyway at one pushed head, for exactly those files,
|
||||
through the same approval record as a ruling:
|
||||
|
||||
```bash
|
||||
gstack-approvals lane-exception --head <pushed head> --files <f1,f2> \
|
||||
--reason '<why, one line>' --words '<the owner's exact words>' --by '<who granted>'
|
||||
```
|
||||
|
||||
The row is an approved `gstack-approvals` row with scope `test_lane_exception <head>
|
||||
<files>` and ask `test_lane_exception at <head>: <files>: <reason>`
|
||||
(`lib/test-lane-exception.ts`). `lane-exception` refuses a head that is not a
|
||||
commit, a file with whitespace, a multi-line reason, a `--by` naming gstack and
|
||||
any grant while this checkout holds an open /ship run's marker, and `add` refuses
|
||||
the scope, so the lane's own runner cannot write one; a row by gstack is ignored
|
||||
wherever it came from. `gstack-evidence record-ci` binds a grant only when its
|
||||
head is HEAD and its files are exactly the uncovered set, and only after a local
|
||||
preflight of each of those files, one at a time, with the lane's `per_file` runner
|
||||
passes with a counted test at that head (a lane with no `per_file` runner cannot
|
||||
use one). Grants are kept per head and file set (the newest wins), so two lanes at
|
||||
one head each bind their own. Nothing about it is
|
||||
hidden: `accept` prints `TEST_LANE_EXCEPTION: …` and names the body line in its
|
||||
`NEXT:`, the receipt's `ci_carried` entry records `exception` (head, files, by,
|
||||
reason, time, approval id and the preflight's label and test count),
|
||||
`gstack-ship-receipt write --body` refuses a body that does not carry
|
||||
`Test-lane exception: …` word for word, and the READY block prints each as
|
||||
`TEST_LANE_EXCEPTION:`. A new push needs a new grant at the new head.
|
||||
|
||||
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
|
||||
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
|
||||
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
|
||||
|
||||
+31
-6
@@ -1940,8 +1940,17 @@ The guide is `capy/skill/gstack/captain.md`.
|
||||
`--by`), holds a value outside its enum (`--outcome approved|rejected|pending|standing`,
|
||||
a `--date` that is not a real `YYYY-MM-DD` day) or exceeds a length cap (ask, scope and
|
||||
pending 300 characters, words 2,000). Exit 2. Nothing was written.
|
||||
`rule` and `lane-exception` refuse with this code too: a `--head` that is not
|
||||
a commit in the repository, a reason that is missing or holds a newline or
|
||||
control character, a `--by` naming gstack, any record while this checkout
|
||||
holds an open /ship run, and for `lane-exception` a `--files` list that is
|
||||
empty or holds whitespace. `add` refuses a `ruled_note` or
|
||||
`test_lane_exception` scope ([ruled notes](ship-policy.md#ruled-notes),
|
||||
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
|
||||
|
||||
**Fix.** Rerun with every required flag inside the caps the line names.
|
||||
**Fix.** Rerun with every required flag inside the caps the line names. A
|
||||
ruling or a test-lane exception is the owner's: record it from the owner's
|
||||
session, outside the lane's /ship run.
|
||||
|
||||
<a id="approval-record-secret"></a>
|
||||
### `gstack-approvals: ... (APPROVAL_RECORD_SECRET)`
|
||||
@@ -3496,10 +3505,17 @@ scan tags included), or whose product patch is not the receipt's and the change
|
||||
since it is more than plain docs. A docs-only change after the read carries the
|
||||
record only when it changes no binary file, adds no numeric claim, touches no
|
||||
results, prereg or eval path, and names no money, security, secret or auth topic; any other docs change
|
||||
needs the reader again (GST-1195).
|
||||
needs the reader again (GST-1195). With `--body`, write also refuses, writing
|
||||
nothing, a body that does not carry each line the receipt requires as one line,
|
||||
word for word: every `Ruled note: …` line `gstack-approvals rulings` prints,
|
||||
finish's `MECHANICAL:` lines as `Mechanical retake: …`, and accept's
|
||||
`TEST_LANE_EXCEPTION:` lines as `Test-lane exception: …`
|
||||
([ruled notes](ship-policy.md#ruled-notes),
|
||||
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
|
||||
|
||||
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
|
||||
block. For the `--head` refusal, check out that commit and write there. For a
|
||||
block. For a missing body line, add the line the refusal quotes to the body and
|
||||
write again. For the `--head` refusal, check out that commit and write there. For a
|
||||
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
|
||||
--targeted-out .gstack/tmp/targeted.json`, and write again with
|
||||
`--review-targeted @.gstack/tmp/targeted.json`.
|
||||
@@ -4146,6 +4162,14 @@ two routes:
|
||||
An allowlist entry or a `[]` declaration the branch adds to its own
|
||||
`pregate.json` counts only once it is on the base.
|
||||
|
||||
/ship's own reruns on the final tree (`gstack-ship-release accept`, and
|
||||
`finish` when it reruns the whole pregate) pass `--policy-from head`
|
||||
themselves when the branch's copy differs from the one it forked from in
|
||||
nothing but added test lanes and `.gstack/generated.json` matches the base
|
||||
tip's, and print `POLICY: head (unreviewed) …`; any other difference keeps the
|
||||
base policy and prints `POLICY: base … (the branch's copy <what differs> …)`
|
||||
([accept's policy choice](pregate.md#accept-head-policy), GST-1595).
|
||||
|
||||
<a id="pregate-head-policy-loosens"></a>
|
||||
### `PREGATE_HEAD_POLICY_LOOSENS`
|
||||
|
||||
@@ -6536,9 +6560,10 @@ body that names a counter the receipt records (`passes=3` against
|
||||
against `gate.passed: 41`) with a different value. The receipt is written from
|
||||
the run; the prose was typed, so a reader of the body would learn a number the
|
||||
run did not produce. The `PROSE:` line names each field with both values. The
|
||||
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
|
||||
and `Documentation exception: …` lines the receipt's exceptions require, and
|
||||
the inside of a closed code fence are never read as claims.
|
||||
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`,
|
||||
`Documentation exception: …`, `Ruled note: …`, `Mechanical retake: …` and
|
||||
`Test-lane exception: …` lines the receipt requires, and the inside of a
|
||||
closed code fence are never read as claims.
|
||||
|
||||
**Fix.** Correct the body to the receipt's number. When the receipt is the
|
||||
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
|
||||
|
||||
+59
-13
@@ -27,18 +27,21 @@
|
||||
* bounded by a timeout, and returns green, red with the failing jobs, still
|
||||
* running, or none.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { normalPath, runChanged, runInventory, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
|
||||
|
||||
export type CarryRefusal = 'dirty_tree' | 'no_profile' | 'no_run' | 'no_verified_run' | 'tree_mismatch' | 'no_tests'
|
||||
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'inventory_skipped' | 'run_changed' | 'tree_changed' | 'api_error';
|
||||
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
|
||||
/** `diagnosis`: one line per uncovered inventory file another inventory file imports (importedTestLines). */
|
||||
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string; diagnosis?: string[] };
|
||||
/**
|
||||
* `tests` never claims a whole run's count for a narrow lane: with an inventory it is the number of the lane's inventory files
|
||||
* the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only
|
||||
* without either (the whole run carried) the runs' test count. `remainder`: inventory files every run skipped entirely
|
||||
* (tree.json `skipped_files`), which the caller must run here with the lane's per_file runner before the lane counts.
|
||||
*/
|
||||
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; receipt: string } | CarryRefused;
|
||||
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; excepted?: string[]; exceptedBy?: string; receipt: string } | CarryRefused;
|
||||
|
||||
/** How many of HEAD's runs per workflow are tried, newest first. */
|
||||
const CANDIDATES = 10;
|
||||
@@ -63,11 +66,17 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
|
||||
* The CI evidence for HEAD's tree under `profiles` (one verified run each), or why there is none. `wtree` reads the
|
||||
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover, or `jobs`
|
||||
* names the jobs that must have succeeded in each run's newest attempt (a lane with no test files; no test count needed).
|
||||
* `excepted` holds the owner's test-lane exceptions at HEAD (lib/test-lane-exception.ts): when the inventory files no run lists
|
||||
* are exactly one grant's files, they come back as `excepted` (with that grant's `exceptedBy`) for the caller to preflight here
|
||||
* with the per_file runner instead of refusing the lane (GST-1595).
|
||||
*/
|
||||
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean }): CarryVerdict {
|
||||
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean;
|
||||
importers?: (missing: string[], listed: string[]) => string[]; excepted?: { files: string[]; approval: string }[] }): CarryVerdict {
|
||||
const jobs = o.jobs ?? [];
|
||||
let covered: number | undefined;
|
||||
let remainder: string[] = [];
|
||||
let excused: string[] = [];
|
||||
let exceptedBy: string | undefined;
|
||||
const before = o.wtree();
|
||||
if (before !== o.tree) return no('dirty_tree', `the working tree (${before ? before.slice(0, 12) : 'fingerprint unavailable'}) is not HEAD's committed tree ${o.tree.slice(0, 12)}; only a clean, committed tree carries`);
|
||||
if (!o.profiles.length) return no('no_profile', 'no run profile is named');
|
||||
@@ -90,10 +99,19 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
|
||||
const absent = uncoveredFiles(listed, runs, 'covered');
|
||||
const some = (fs: string[]) => `${fs.slice(0, 5).join(', ')}${fs.length > 5 ? ', …' : ''}`;
|
||||
if (absent.length) return no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}`);
|
||||
remainder = uncoveredFiles(listed, runs, 'uncovered');
|
||||
const grants = o.excepted ?? [];
|
||||
const granted = absent.length ? grants.find(g => sameFiles(absent, g.files)) : undefined;
|
||||
if (absent.length && (!granted || !o.perFile)) {
|
||||
const grant = !grants.length ? '' : granted ? `; test-lane exception ${granted.approval} names them, but the lane declares no per_file runner to preflight them here`
|
||||
: `; test-lane exception ${grants.map(g => `${g.approval} names ${some(g.files)}`).join('; ')}, not exactly these`;
|
||||
const diagnosis = o.importers?.(absent, listed) ?? [];
|
||||
return { ...no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}${grant}`), ...(diagnosis.length ? { diagnosis } : {}) };
|
||||
}
|
||||
excused = absent;
|
||||
exceptedBy = granted?.approval;
|
||||
remainder = uncoveredFiles(listed, runs, 'uncovered').filter(f => !excused.includes(f));
|
||||
if (remainder.length && !o.perFile) return no('inventory_skipped', `${remainder.length} of ${listed.length} inventory file(s) skipped every test in CI and the lane declares no per_file runner: ${some(remainder)}`);
|
||||
covered = new Set(listed.map(normalPath)).size - remainder.length;
|
||||
covered = new Set(listed.map(normalPath)).size - remainder.length - excused.length;
|
||||
}
|
||||
for (const e of runs) {
|
||||
const changed = runChanged(src.deps, e.run_id, e.run_attempt);
|
||||
@@ -110,7 +128,7 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined;
|
||||
return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` };
|
||||
}
|
||||
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''} ${runReceiptFields(runs, src.now)}` };
|
||||
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), ...(excused.length ? { excepted: excused, exceptedBy } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''}${excused.length ? ` excepted=${excused.length} approval=${exceptedBy}` : ''} ${runReceiptFields(runs, src.now)}` };
|
||||
const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
|
||||
return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` };
|
||||
}
|
||||
@@ -150,23 +168,51 @@ export function laneMapping(from: Record<string, ShipLaneMapping>, command: stri
|
||||
}
|
||||
|
||||
/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`, with a per_file runner or not) or its named jobs. */
|
||||
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean } =>
|
||||
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file };
|
||||
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean; importers?: (missing: string[], listed: string[]) => string[] } =>
|
||||
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file, importers: (missing, listed) => importedTestLines(cwd, missing, listed) };
|
||||
|
||||
const sameFiles = (a: string[], b: string[]) => { const x = new Set(a.map(normalPath)), y = new Set(b.map(normalPath)); return x.size === y.size && [...x].every(f => y.has(f)); };
|
||||
const IMPORT_SPEC = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*['"](\.{1,2}\/[^'"\n]+)['"]/g;
|
||||
const RESOLVE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.js'];
|
||||
|
||||
/**
|
||||
* One line per file in `missing` (test files no run's tree.json lists) that another file in `candidates` imports by a
|
||||
* relative path: bun registers an imported test file's tests under the importing file, so the imported file's own JUnit
|
||||
* suite never appears (GST-1595, gstack-evals #240). Read from the working tree in `cwd`; an unreadable file is skipped.
|
||||
*/
|
||||
export function importedTestLines(cwd: string, missing: string[], candidates: string[]): string[] {
|
||||
const want = new Map(missing.map(f => [normalPath(f), f]));
|
||||
const lines = new Map<string, string>();
|
||||
for (const importer of candidates) {
|
||||
const from = normalPath(importer);
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(path.join(cwd, from), 'utf8'); } catch { continue; }
|
||||
for (const m of text.matchAll(IMPORT_SPEC)) {
|
||||
const target = path.posix.normalize(path.posix.join(path.posix.dirname(from), m[1]!));
|
||||
const hit = RESOLVE_SUFFIXES.map(s => want.get(target + s)).find(Boolean);
|
||||
if (hit && normalPath(hit) !== from && !lines.has(hit)) lines.set(hit, `bun registers ${hit} under ${importer}; its JUnit suite won't appear`);
|
||||
}
|
||||
}
|
||||
return missing.flatMap(f => lines.get(f) ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes
|
||||
* first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any
|
||||
* not-owed reason, or the last CARRY_NONE reason.
|
||||
*/
|
||||
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; none?: string } {
|
||||
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; none?: string; diagnosis?: string[] } {
|
||||
const lines = stderr.split('\n');
|
||||
const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs='));
|
||||
const none = lines.findLast(x => x.startsWith('CARRY_NONE: '))?.slice('CARRY_NONE: '.length);
|
||||
if (at < 0) return none ? { none } : {};
|
||||
const noneAt = lines.findLastIndex(x => x.startsWith('CARRY_NONE: '));
|
||||
const none = noneAt < 0 ? undefined : lines[noneAt]!.slice('CARRY_NONE: '.length);
|
||||
const diagnosis = noneAt < 0 ? [] : lines.slice(noneAt + 1).filter(x => x.startsWith('CARRY_DIAGNOSIS: ')).map(x => x.slice('CARRY_DIAGNOSIS: '.length));
|
||||
if (at < 0) return none ? { none, ...(diagnosis.length ? { diagnosis } : {}) } : {};
|
||||
const exception = lines.slice(at + 1).find(x => x.startsWith('TEST_LANE_EXCEPTION: '))?.slice('TEST_LANE_EXCEPTION: '.length);
|
||||
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(lines[at]!);
|
||||
const here = / skipped_here=(\d+) tests_ran_here=(\S+)( not_owed_here=\d+)?$/.exec(lines[at]!);
|
||||
const notOwed = lines.slice(at + 1).find(x => x.startsWith('gstack-evidence: not owed: '))?.slice('gstack-evidence: not owed: '.length);
|
||||
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}${here[3] ?? ''}` } : {}) };
|
||||
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}${here[3] ?? ''}` } : {}), ...(exception ? { exception } : {}) };
|
||||
}
|
||||
|
||||
/** The per_file runner command for `files`: `{files}` replaced by the paths, each anchored with ./ (Bun reads a bare relative path as a substring filter) and single-quoted for bash. */
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import type { BoundLaneException } from './test-lane-exception';
|
||||
import { withFileLock } from './spend-ledger';
|
||||
|
||||
export type Severity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational';
|
||||
@@ -102,7 +103,7 @@ export interface ShipReceipt {
|
||||
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
|
||||
gate?: Record<string, unknown>; ci?: Record<string, unknown>; spend_usd?: number | string; predecessor?: string;
|
||||
/** GSTA-258: the lanes CI carried at this head's tree, one entry per run (gstack-evidence carried). */
|
||||
ci_carried?: Array<{ lane: string; run: number; attempt: number; tree: string }>;
|
||||
ci_carried?: Array<{ lane: string; run: number; attempt: number; tree: string; exception?: BoundLaneException }>;
|
||||
session_kind?: string; artifacts_consumed?: string; run?: string;
|
||||
// C-stamp (C3/C5/C7): the tree receipt's verdicts and the policy provenance, all optional.
|
||||
thread?: string; tree?: string; gate_reuse?: string; policy?: string; queue_mode?: string; preregistration?: string; history?: string;
|
||||
@@ -257,7 +258,9 @@ export const ARTIFACT_SCHEMAS = {
|
||||
properties: {
|
||||
schema_version: { const: 1 }, pr: { type: 'integer' }, head: str, version: str, base: str, gated_tree: str,
|
||||
gate: { type: 'object' }, ci: { type: 'object' }, spend_usd: { type: ['number', 'string'] }, predecessor: str,
|
||||
ci_carried: { type: 'array', items: { type: 'object', required: ['lane', 'run', 'attempt', 'tree'], properties: { lane: str, run: { type: 'integer', minimum: 1 }, attempt: { type: 'integer', minimum: 1 }, tree: str } } },
|
||||
ci_carried: { type: 'array', items: { type: 'object', required: ['lane', 'run', 'attempt', 'tree'], properties: { lane: str, run: { type: 'integer', minimum: 1 }, attempt: { type: 'integer', minimum: 1 }, tree: str,
|
||||
exception: { type: 'object', required: ['head', 'files', 'by', 'reason', 'at', 'approval', 'preflight'], properties: { head: str, files: strList, by: str, reason: str, at: str, approval: str,
|
||||
preflight: { type: 'object', required: ['label', 'tests_ran'], properties: { label: str, tests_ran: { type: 'integer', minimum: 1 } } } } } } } },
|
||||
session_kind: str, artifacts_consumed: { ...str, description: '<n>/<m>' }, run: str,
|
||||
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
|
||||
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
* compared: its guard reads the base branch's copy itself.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GENERATED_PATH } from '../regen';
|
||||
import { PREGATE_CONFIG_PATH, loadConfigFromHead, parseConfig, type ConfigLoad, type PregateConfig } from './config';
|
||||
|
||||
type Loaded = Exclude<ConfigLoad, { unavailable: true }>;
|
||||
@@ -70,3 +73,34 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string,
|
||||
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a /ship run on the final tree (gstack-ship-release accept, and finish's full pregate rerun) gates under the
|
||||
* branch's own policy (GST-1595): only when the base branch carries a policy and the branch's file differs from the copy
|
||||
* it forked from (else the base tip's) in nothing but added test lanes, with GENERATED_PATH equal to the base tip's (a
|
||||
* head-policy run loads the regen registry from the branch, context.ts), so the gate only gets stricter. Any other difference keeps the base policy, as before. `line` names the policy and why; it is absent
|
||||
* when the branch leaves the policy alone.
|
||||
*/
|
||||
export function lanesOnlyHeadPolicy(repoRoot: string, base: string): { head: boolean; line?: string } {
|
||||
const ref = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const show = (rev: string, file: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 && !r.error ? r.stdout : null; };
|
||||
const read = (file: string) => { try { return fs.readFileSync(path.join(repoRoot, file), 'utf8'); } catch { return null; } };
|
||||
const json = (text: string | null): Record<string, unknown> | null => { try { const v = text === null ? null : JSON.parse(text); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } };
|
||||
const tip = show(ref, PREGATE_CONFIG_PATH), headText = read(PREGATE_CONFIG_PATH);
|
||||
const fork = spawnSync('git', ['merge-base', ref, 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).stdout?.trim();
|
||||
const trusted = json((fork && show(fork, PREGATE_CONFIG_PATH)) || tip), head = json(headText);
|
||||
if (headText === tip || (trusted && head && same(trusted, head))) return { head: false };
|
||||
const keep = (why: string) => ({ head: false, line: `POLICY: base ${ref}:${PREGATE_CONFIG_PATH} (the branch's copy ${why}, so it does not count here; land it on ${ref.slice(7)} first)` });
|
||||
if (!tip || !trusted) return keep(`has no reviewed copy on ${ref} to add lanes to`);
|
||||
if (!head) return keep(headText === null ? 'is deleted' : 'does not parse as a JSON object');
|
||||
const { dependencies: refDeps = {}, ...refRest } = trusted, { dependencies: headDeps = {}, ...headRest } = head;
|
||||
const other = [...new Set([...Object.keys(refRest), ...Object.keys(headRest)])].filter(k => !same(refRest[k], headRest[k])).sort();
|
||||
if (other.length) return keep(`also changes ${other.join(', ')}`);
|
||||
const lists = (v: unknown): v is Record<string, string[]> => !!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every(t => Array.isArray(t) && t.every(x => typeof x === 'string'));
|
||||
if (!lists(refDeps) || !lists(headDeps)) return keep('has a dependencies entry that is not a list of test files');
|
||||
const { loosens, adds } = headPolicyDiff({ dependencies: refDeps } as PregateConfig, { dependencies: headDeps } as PregateConfig, ref.slice(7));
|
||||
if (loosens.length) return keep(loosens.join('; '));
|
||||
if (show(ref, GENERATED_PATH) !== read(GENERATED_PATH)) return keep(`comes with a changed ${GENERATED_PATH}`);
|
||||
const n = Object.keys(adds).length;
|
||||
return { head: true, line: `POLICY: head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} test lane${n === 1 ? '' : 's'} over ${ref} and changes nothing else, so the gate is only stricter` };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { ARTIFACT_SCHEMAS, checkSchema, type PregateException, type PregateFile,
|
||||
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
|
||||
import { resolveStateRoot } from '../state-root';
|
||||
import { guardsCheck } from './checks/guards';
|
||||
import { findCarry } from '../ci-carry';
|
||||
import { findCarry, importedTestLines } from '../ci-carry';
|
||||
import { clearHint, fromRunProfiles, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
|
||||
import { literalsCheck } from './checks/literals';
|
||||
import { regenCheck } from './checks/regen';
|
||||
@@ -456,7 +456,8 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
|
||||
for (const f of listed) required.add(f);
|
||||
}
|
||||
const missing = uncoveredFiles([...required], evidence);
|
||||
if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}); ${lane} stays uncleared` };
|
||||
const imported = missing.length ? importedTestLines(repoRoot, missing, [...required]) : [];
|
||||
if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''})${imported.length ? `; ${imported.join('; ')}` : ''}; ${lane} stays uncleared` };
|
||||
const granted = red ? flakyDecision(file, lane, check, red, flaky!, repoRoot) : null;
|
||||
if (granted && 'refused' in granted) return granted;
|
||||
for (const e of evidence) {
|
||||
|
||||
@@ -182,7 +182,7 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
|
||||
return cpus <= 4 ? 1 : Infinity;
|
||||
}
|
||||
|
||||
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string; here?: string }
|
||||
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; diagnosis?: string[]; notOwed?: string; here?: string; exception?: string }
|
||||
|
||||
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */
|
||||
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, ShipLaneMapping> {
|
||||
@@ -201,8 +201,8 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
|
||||
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 = recordCiResult(rec.stderr);
|
||||
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}) };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}` };
|
||||
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}), ...(got.exception ? { exception: got.exception } : {}) };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}`, ...(got.diagnosis ? { diagnosis: got.diagnosis } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,5 +240,5 @@ export function laneLine(l: LaneResult): { line: string; ok: boolean } {
|
||||
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero };
|
||||
}
|
||||
|
||||
/** The CI_CARRY line for a mapped lane that ran here instead. */
|
||||
export const carryNoneLine = (l: LaneResult) => (l.carryNone ? `CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})` : null);
|
||||
/** The CI_CARRY line for a mapped lane that ran here instead, then a CARRY_DIAGNOSIS line per uncovered file another test file imports. */
|
||||
export const carryNoneLines = (l: LaneResult): string[] => (l.carryNone ? [`CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})`, ...(l.diagnosis ?? []).map(d => `CARRY_DIAGNOSIS: ${d}`)] : []);
|
||||
@@ -15,7 +15,7 @@ import { buildRunCheck } from './pregate/run-check';
|
||||
import { classifyDiff } from './review-depth';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { audit } from './ship-audit';
|
||||
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
|
||||
import { bin, carryNoneLines, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
|
||||
|
||||
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
|
||||
|
||||
@@ -203,7 +203,7 @@ async function tests(cwd: string, base: string, list: { label: string; command:
|
||||
r.add(run.line);
|
||||
const ran = await runLanes(cwd, list, run.concurrency, { base });
|
||||
const results = ran.map(laneLine);
|
||||
for (const [i, l] of results.entries()) { const none = carryNoneLine(ran[i]!); if (none) r.add(none); r.add(l.line); }
|
||||
for (const [i, l] of results.entries()) { for (const none of carryNoneLines(ran[i]!)) r.add(none); r.add(l.line); }
|
||||
const bad = results.filter(l => !l.ok);
|
||||
if (bad.length) { r.stop(`${bad.length} lane(s) failed or ran zero tests: Step 5 triage (read each log; a free-suite shard failure reruns its file list per measure.md); in-branch failures stop the ship`); return; }
|
||||
r.next('Step 6: select eval suites for prompt-related changes with the project\'s selector, then Step 7');
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RECEIPT_FENCE } from './ship-receipt';
|
||||
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
|
||||
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
|
||||
import { ruledNoteText, type RuledNote } from './ruled-notes';
|
||||
import { laneExceptionBodyLines } from './test-lane-exception';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
|
||||
@@ -96,7 +97,7 @@ function withoutApprovalsBlocks(body: string): string {
|
||||
|
||||
/**
|
||||
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
|
||||
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …` and `Mechanical retake: …` line (GST-1537, GST-1560). write and the receipt check want each word for word,
|
||||
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …`, `Mechanical retake: …` and `Test-lane exception: …` line (GST-1537, GST-1560, GST-1595). write and the receipt check want each word for word,
|
||||
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
|
||||
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
|
||||
* prose. A marker line takes its place so the text on either side never joins into a claim.
|
||||
@@ -110,6 +111,7 @@ function withoutExceptionLines(body: string, receipt: Record<string, unknown>):
|
||||
if (docsLine) lines.add(docsLine);
|
||||
if (Array.isArray(receipt.ruled_notes)) for (const n of receipt.ruled_notes) lines.add(`Ruled note: ${ruledNoteText(n as RuledNote)}`);
|
||||
if (Array.isArray(receipt.mechanical)) for (const l of receipt.mechanical) if (typeof l === 'string') lines.add(l);
|
||||
for (const l of laneExceptionBodyLines(receipt.ci_carried)) lines.add(l);
|
||||
if (!lines.size) return body;
|
||||
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FRAGMENT_README } from './changelog-fragments';
|
||||
import { renderIssueLines } from './issue-links';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { ruledNoteText } from './ruled-notes';
|
||||
import { laneExceptionBodyLines } from './test-lane-exception';
|
||||
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
|
||||
import { stampOnlyLine, type BranchSide } from './stamp-only';
|
||||
|
||||
@@ -175,6 +176,7 @@ export function readyTailLines(r: ShipReceipt | undefined): string[] {
|
||||
return [
|
||||
loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt',
|
||||
...(r?.ruled_notes ?? []).map(n => `RULED_NOTE: ${oneLine(ruledNoteText(n))}`),
|
||||
...laneExceptionBodyLines(r?.ci_carried).map(l => `TEST_LANE_EXCEPTION: ${oneLine(l.slice('Test-lane exception: '.length))}`),
|
||||
'TRIAL_MERGES: <one line per open sibling pin that shares a file: its git merge-tree result and the resolution, or "none share a file">',
|
||||
'Attention:',
|
||||
legacy ? 'reviewed by <model on another family> (risk class unknown: the receipt records no review_depth; rule 27 cross-family trail read)'
|
||||
|
||||
+12
-5
@@ -27,9 +27,10 @@ import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||||
import { waitForRun } from './ci-carry';
|
||||
import { lanesOnlyHeadPolicy } from './pregate/head-policy';
|
||||
import { PREPUSH_CHECK_IDS } from './pregate/index';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
|
||||
import { bin, carryLane, carryNoneLines, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
|
||||
|
||||
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
|
||||
|
||||
@@ -532,7 +533,9 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
|
||||
const v = bin(cwd, 'gstack-pregate', ['verify'], 60_000);
|
||||
if (v.status === 0) r.add('PREGATE: verify ok on the stamped tree');
|
||||
else {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||||
const policy = lanesOnlyHeadPolicy(cwd, base);
|
||||
if (policy.line) r.add(policy.line);
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, ...(policy.head ? ['--policy-from', 'head'] : [])], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
|
||||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
|
||||
@@ -570,25 +573,29 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
if (w.state === 'red') return r.stop(`${p.workflow} run ${w.run.id} failed (${short(w.failing, 4) || 'no failing job named'}): Step 5 triage on its logs; fix, commit, push, then accept again`);
|
||||
if (w.state === 'none') return r.stop(`${w.reason}: push the branch (a pull_request or workflow_dispatch run must exist at HEAD), then accept again`);
|
||||
}
|
||||
const exceptions: string[] = [];
|
||||
for (const l of list) {
|
||||
const m = mapped.get(l.label);
|
||||
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`); }
|
||||
if (m && !res.carried) { for (const line of carryNoneLines(res)) r.add(line); 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);
|
||||
if (res.exception) { r.add(`TEST_LANE_EXCEPTION: ${res.exception}`); exceptions.push(res.exception); }
|
||||
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: Step 5 triage, then push and accept again`);
|
||||
const e = bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24'], 60_000).stdout;
|
||||
const line = /^EVIDENCE: .*$/m.exec(e)?.[0] ?? `EVIDENCE: unreadable label=${l.label}`;
|
||||
r.add(line.replace(/ log=\S+/, ''));
|
||||
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH at the pushed head: Step 16 stage 4 recovery`);
|
||||
}
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||||
const policy = lanesOnlyHeadPolicy(cwd, base);
|
||||
if (policy.line) r.add(policy.line);
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, ...(policy.head ? ['--policy-from', 'head'] : [])], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
const carried = /^CI_CARRY: .*$/m.exec(pg.stdout + pg.stderr)?.[0];
|
||||
if (carried) r.add(carried.length > 240 ? `${carried.slice(0, 240)}…` : carried);
|
||||
r.add(`PREGATE: ${verdict} (final tree)`);
|
||||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the final tree: fix it, then rerun accept`);
|
||||
return r.next('Step 18: the receipt records ci_carried from the ledger');
|
||||
return r.next(`Step 18: the receipt records ci_carried from the ledger${exceptions.length ? `; the PR body carries ${exceptions.map(e => JSON.stringify(`Test-lane exception: ${e}`)).join(' and ')} verbatim, one line each, or the receipt write refuses` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Owner-granted test-lane exceptions (GST-1595, docs/ship-policy.md#test-lane-exceptions): the owner's grant that a lane
|
||||
* carries from CI although some of its inventory files are in no run's tree.json (bun registers an imported test file's
|
||||
* tests under the importer, so the imported file's JUnit suite never appears). A grant is one approved gstack-approvals
|
||||
* row (`gstack-approvals lane-exception`), scope `test_lane_exception <head> <files>` and ask
|
||||
* `test_lane_exception at <head>: <files>: <reason>`, where head is the full sha of the pushed head the owner granted at and
|
||||
* files are exactly the uncovered files, comma-separated. It binds only at that head and only for that exact set, and
|
||||
* `gstack-evidence record-ci` counts it only after a local preflight of exactly those files passes there. Like a ruled note
|
||||
* (lib/ruled-notes.ts), a row by gstack itself is no grant, and the CLI refuses inside the checkout's open /ship run, so the
|
||||
* lane that needs the exception cannot write it for itself.
|
||||
*/
|
||||
import { locate, readRecord, type ApprovalRow, type Env } from './approval-record';
|
||||
import { GSTACK_ACTOR } from './ruled-notes';
|
||||
|
||||
export interface TestLaneException { head: string; files: string[]; by: string; reason: string; at: string; approval: string }
|
||||
/** What the ledger's CI row and the receipt's ci_carried entry keep: the grant plus the preflight that backed it. */
|
||||
export interface BoundLaneException extends TestLaneException { preflight: { label: string; tests_ran: number } }
|
||||
|
||||
const SCOPE = /^test_lane_exception ([0-9a-f]{40}) (\S+)$/;
|
||||
const ASK = /^test_lane_exception at ([0-9a-f]{40}): (\S+): (\S(?:.*\S)?)$/;
|
||||
export const TEST_LANE_EXCEPTION_SCOPE = /^\s*test_lane_exception\b/;
|
||||
|
||||
export const testLaneExceptionRow = (head: string, files: string[], reason: string) =>
|
||||
({ scope: `test_lane_exception ${head} ${files.join(',')}`, ask: `test_lane_exception at ${head}: ${files.join(',')}: ${reason}` });
|
||||
|
||||
/** The approved grants, one per head and file set (the newest wins), so each lane at a head keeps its own grant. */
|
||||
export function testLaneExceptions(rows: readonly ApprovalRow[]): TestLaneException[] {
|
||||
const byGrant = new Map<string, TestLaneException>();
|
||||
for (const r of rows) {
|
||||
const scope = SCOPE.exec(r.scope), ask = ASK.exec(r.ask);
|
||||
if (r.outcome !== 'approved' || !scope || !ask || ask[1] !== scope[1] || ask[2] !== scope[2] || GSTACK_ACTOR.test(r.by)) continue;
|
||||
const files = scope[2]!.split(',').filter(Boolean).sort(), key = `${scope[1]} ${files.join(',')}`;
|
||||
byGrant.delete(key);
|
||||
byGrant.set(key, { head: scope[1]!, files, by: r.by, reason: ask[3]!, at: r.recorded_at, approval: r.id });
|
||||
}
|
||||
return [...byGrant.values()];
|
||||
}
|
||||
|
||||
/** The grants in the checked-out branch's approval record (and its mirror). */
|
||||
export function readTestLaneExceptions(cwd: string, env: Env = process.env): TestLaneException[] {
|
||||
const loc = locate({ cwd, env });
|
||||
return 'code' in loc ? [] : testLaneExceptions(readRecord(loc).rows);
|
||||
}
|
||||
|
||||
/** The `Test-lane exception: …` lines a receipt's ci_carried entries require in the PR body, one per grant. */
|
||||
export function laneExceptionBodyLines(carried: unknown): string[] {
|
||||
const list = Array.isArray(carried) ? carried : [];
|
||||
return [...new Set(list.flatMap(c => {
|
||||
const e = (c as { exception?: BoundLaneException } | null)?.exception;
|
||||
return e && Array.isArray(e.files) && typeof e.head === 'string' && e.preflight ? [`Test-lane exception: ${testLaneExceptionText(e)}`] : [];
|
||||
}))];
|
||||
}
|
||||
|
||||
/** The text after `TEST_LANE_EXCEPTION: ` in accept's output and after `Test-lane exception: ` in the PR body. */
|
||||
export const testLaneExceptionText = (e: BoundLaneException) =>
|
||||
`${e.files.join(', ')} at ${e.head.slice(0, 12)}: preflight ${e.preflight.label} ran ${e.preflight.tests_ran} test(s) here; granted by ${e.by} at ${e.at}: ${e.reason} (approval ${e.approval})`;
|
||||
@@ -407,6 +407,14 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-finish-ruled-notes.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-approvals, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42139467391)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-accept-head-policy.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-accept-lane-exception.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence, gstack-approvals and gstack-ship-receipt (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-mechanical-retake.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-review-plan, gstack-ship-receipt, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-finish-stack-parent.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42156939158)',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* GST-1595 (1): gstack-ship-release accept reruns gstack-pregate on the final tree under the branch's own
|
||||
* .gstack/pregate.json only when that file adds test lanes and changes nothing else, so the gate gets stricter, never
|
||||
* looser; any other difference keeps the base policy. gstack-evals #240's shape: a PR that adds a runtime file plus its
|
||||
* own lane line passed preflight under --policy-from head, then refused at accept with PREGATE_NO_LANE.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
|
||||
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
|
||||
const TESTS = ['test/a.test.ts', 'test/new.test.ts'];
|
||||
const DEPS = { 'src/a.ts': ['test/a.test.ts'], 'src/b.ts': ['test/a.test.ts'], 'test/**': [] };
|
||||
const policy = (deps: Record<string, string[]>, extra: Record<string, unknown> = {}) => JSON.stringify({
|
||||
run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: `printf '${TESTS.join('\\n')}\\n'` } }, dependencies: deps, ...extra,
|
||||
}, null, 2) + '\n';
|
||||
const passing = "import { expect, test } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n";
|
||||
const BASE = {
|
||||
'.gstack/pregate.json': policy(DEPS),
|
||||
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
|
||||
'test/a.test.ts': passing,
|
||||
};
|
||||
/** The #240 shape: a new runtime file, its own test, and the lane line that maps one to the other. */
|
||||
const PR = { 'src/b.ts': 'export const b = 2;\n', 'src/new.ts': 'export const fresh = 1;\n', 'test/new.test.ts': passing };
|
||||
const ADDS = { ...DEPS, 'src/new.ts': ['test/new.test.ts'] };
|
||||
|
||||
function fx(headPolicy: string): ShipFx {
|
||||
const f = shipFixture(BASE, { ...PR, '.gstack/pregate.json': headPolicy });
|
||||
fixtures.push(f);
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] }, trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: TESTS, tests: 2, os: 'Linux' } } };
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
|
||||
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
|
||||
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
|
||||
return f;
|
||||
}
|
||||
const lanesCheck = (f: ShipFx) => JSON.parse(fs.readFileSync(path.join(f.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
|
||||
describe('accept: a lanes-only head policy gates the final tree (GST-1595)', () => {
|
||||
// Value: protects=a PR that adds a runtime file and its own lane line passes accept, gated by that lane; fails_when=accept reruns pregate under the base
|
||||
// policy and refuses with PREGATE_NO_LANE (gstack-evals #240); why_new=GEVA-2 had to land a lanes-only PR first; seam=fake gh, gstack-pregate
|
||||
test('a head policy that only adds the new file\'s lane: accept passes under it', () => {
|
||||
const f = fx(policy(ADDS));
|
||||
const ok = accept(f);
|
||||
expect([ok.status, lineOf(ok.stdout, 'PREGATE'), lanesCheck(f).status], ok.stdout).toEqual([0, expect.stringMatching(/^PREGATE: (pass|warn) \(final tree\)$/), 'pass']);
|
||||
expect(lineOf(ok.stdout, 'POLICY')).toBe('POLICY: head (unreviewed) .gstack/pregate.json: adds 1 test lane over origin/main and changes nothing else, so the gate is only stricter');
|
||||
expect(ok.stdout).toContain('NEXT: Step 18');
|
||||
});
|
||||
|
||||
// Value: protects=accept never gates under a stale branch registry: --policy-from head loads .gstack/generated.json from the branch
|
||||
// (lib/pregate/context.ts loadRegistryFromHead), so a branch whose copy differs from main's tip keeps the base policy;
|
||||
// fails_when=lanesOnlyHeadPolicy compares generated.json with the fork copy and picks the head policy; seam=lanesOnlyHeadPolicy
|
||||
test('main changes .gstack/generated.json after the fork: accept keeps the base policy', () => {
|
||||
const f = fx(policy(ADDS));
|
||||
for (const step of [['checkout', '-q', 'main'], ['pull', '-q', 'origin', 'main']]) expect(f.git(...step).status).toBe(0);
|
||||
fs.mkdirSync(path.join(f.work, '.gstack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(f.work, '.gstack/generated.json'), JSON.stringify({ $schema: 'https://github.com/garrytan/gstack/docs/pregate.md#generated-v1', entries: [] }, null, 2) + '\n');
|
||||
for (const step of [['add', '-A'], ['commit', '-qm', 'main: generated registry'], ['push', '-q', 'origin', 'main'], ['checkout', '-q', 'feat'], ['fetch', '-q', 'origin']]) expect(f.git(...step).status).toBe(0);
|
||||
const out = accept(f);
|
||||
expect(lineOf(out.stdout, 'POLICY'), out.stdout).toBe("POLICY: base origin/main:.gstack/pregate.json (the branch's copy comes with a changed .gstack/generated.json, so it does not count here; land it on main first)");
|
||||
});
|
||||
|
||||
// Value: protects=the trust boundary: a head policy that drops, shrinks or loosens anything stays unread, and accept refuses as before;
|
||||
// fails_when=accept runs a branch's looser policy; seam=lib/pregate/head-policy.ts lanesOnlyHeadPolicy
|
||||
test.each([
|
||||
['removes a lane', policy({ ...ADDS, 'src/a.ts': [] }), 'dependencies["src/a.ts"] drops test/a.test.ts'],
|
||||
['loosens a check', policy(ADDS, { warn: ['lanes'] }), 'also changes warn'],
|
||||
['adds a lane and changes the timeout', policy(ADDS, { timeout_s: 30 }), 'also changes timeout_s'],
|
||||
['adds a lane and changes the carry map', JSON.stringify({ ...JSON.parse(policy(ADDS)), ship_lanes_from: {} }), 'also changes ship_lanes_from'],
|
||||
])('a head policy that %s keeps the base policy, and accept refuses', (_what, head, why) => {
|
||||
const f = fx(head);
|
||||
const out = accept(f);
|
||||
expect(lineOf(out.stdout, 'POLICY')).toBe(`POLICY: base origin/main:.gstack/pregate.json (the branch's copy ${why}, so it does not count here; land it on main first)`);
|
||||
expect([out.status, lineOf(out.stdout, 'STOP'), lanesCheck(f).code]).toEqual([3, 'STOP: pregate fail on the final tree: fix it, then rerun accept', 'PREGATE_NO_LANE']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* GST-1595 (2): a CI-carried lane whose inventory has a file no run's tree.json lists. bun registers an imported test
|
||||
* file's tests under the importer, so the imported file's JUnit suite never appears (gstack-evals #240). accept names
|
||||
* that pattern, and an owner-granted test-lane exception (gstack-approvals lane-exception, the trust path of ruled notes)
|
||||
* lets the lane carry once a local preflight of exactly those files passes at the exact head; the receipt and PR body
|
||||
* carry it. The lane itself can never write the grant.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { appendRows, buildRow, locate } from '../lib/approval-record';
|
||||
import { importedTestLines } from '../lib/ci-carry';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt } from '../lib/ship-receipt';
|
||||
import { testLaneExceptionRow } from '../lib/test-lane-exception';
|
||||
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const BIN = path.dirname(RELEASE);
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
|
||||
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
|
||||
const HELPERS = 'test/helpers.test.ts';
|
||||
const PASSING = "import { expect, test } from 'bun:test';\ntest('helpers', () => expect(1).toBe(1));\n";
|
||||
const FAILING = "import { expect, test } from 'bun:test';\ntest('helpers', () => expect(1).toBe(2));\n";
|
||||
const BASE = {
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: 'ls test/*.test.ts', per_file: 'bun --no-env-file test {files}' } } }, null, 2) + '\n',
|
||||
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
|
||||
'test/a.test.ts': "import './helpers.test';\nimport { expect, test } from 'bun:test';\ntest('a', () => expect(1).toBe(1));\n",
|
||||
[HELPERS]: PASSING,
|
||||
};
|
||||
const NO_MIRROR = { GSTACK_APPROVALS_MIRROR: '0' };
|
||||
const BUN_PLACED = `CARRY_DIAGNOSIS: bun registers ${HELPERS} under test/a.test.ts; its JUnit suite won't appear`;
|
||||
|
||||
/** A pushed branch whose green CI run's tree.json lists test/a.test.ts only: bun put the helpers' suite under it. */
|
||||
function fx(feat: Record<string, string> = {}, ciFiles = ['test/a.test.ts']): { f: ShipFx; head: string } {
|
||||
const f = shipFixture(BASE, { 'src/b.ts': 'export const b = 2;\n', ...feat });
|
||||
fixtures.push(f);
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] },
|
||||
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree: f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), files: ciFiles, tests: 2, os: 'Linux' } } };
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
|
||||
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
|
||||
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
|
||||
return { f, head };
|
||||
}
|
||||
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0'], NO_MIRROR);
|
||||
const grant = (f: ShipFx, head: string, files = HELPERS, by = 'Garry (via GStack Minder)') => f.run(path.join(BIN, 'gstack-approvals'), ['lane-exception', '--head', head, '--files', files,
|
||||
'--reason', 'bun registers the helpers suite under test/a.test.ts', '--words', 'grant it for that head', '--by', by, '--json'], NO_MIRROR);
|
||||
|
||||
describe('inventory_uncovered names the bun import pattern (GST-1595)', () => {
|
||||
// Value: protects=the operator learns why a file is missing from tree.json instead of chasing CI; fails_when=accept stops on inventory_uncovered
|
||||
// without naming the importer; why_new=GEVA-2 spent most of a day on #240 finding it; seam=gstack-evidence record-ci CARRY_DIAGNOSIS
|
||||
test('accept stops and names the importing test file', () => {
|
||||
const { f } = fx();
|
||||
const out = accept(f);
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')]).toEqual([3, `CI_CARRY: none label=free (inventory_uncovered: 1 of 2 inventory file(s) are in no run's tree.json: ${HELPERS})`]);
|
||||
expect(out.stdout.split('\n')).toContain(BUN_PLACED);
|
||||
});
|
||||
|
||||
// Value: protects=the import scan resolves relative specifiers with and without extensions, require and dynamic import, and names nothing it
|
||||
// cannot tie to a missing file; fails_when=a missing file is blamed on the wrong importer or a self-import; seam=lib/ci-carry.ts importedTestLines
|
||||
test('importedTestLines resolves import, require and import() by relative path', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imported-tests-'));
|
||||
try {
|
||||
const files: Record<string, string> = {
|
||||
'test/a.test.ts': "import { x } from './shared/b.test';\n",
|
||||
'test/c.test.js': "const y = require('../test/shared/d.test.js');\n",
|
||||
'test/e.test.ts': "await import('./f.test.ts');\nimport '../lib/unrelated';\n",
|
||||
'test/shared/b.test.ts': '', 'test/shared/d.test.js': '', 'test/f.test.ts': "import './f.test';\n", 'test/g.test.ts': '',
|
||||
};
|
||||
for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); }
|
||||
expect(importedTestLines(dir, ['test/shared/b.test.ts', './test/shared/d.test.js', 'test/f.test.ts', 'test/g.test.ts'], Object.keys(files))).toEqual([
|
||||
'bun registers test/shared/b.test.ts under test/a.test.ts; its JUnit suite won\'t appear',
|
||||
'bun registers ./test/shared/d.test.js under test/c.test.js; its JUnit suite won\'t appear',
|
||||
'bun registers test/f.test.ts under test/e.test.ts; its JUnit suite won\'t appear',
|
||||
]);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('an owner-granted test-lane exception (GST-1595)', () => {
|
||||
// Value: protects=a granted exception at the pushed head lets the lane carry after a local preflight of exactly those files, and nothing about
|
||||
// it is hidden: accept, the ledger, the receipt and the PR body all carry it; fails_when=the grant is ignored, the preflight is skipped, or the
|
||||
// receipt writes without the body line; why_new=#240 had no exception path at all; seam=record-ci, accept, gstack-ship-receipt write
|
||||
test('a grant at the head with a passing preflight binds, and the receipt and PR body carry it', () => {
|
||||
const { f, head } = fx();
|
||||
expect(grant(f, head).status).toBe(0);
|
||||
const ok = accept(f);
|
||||
expect([ok.status, lineOf(ok.stdout, 'LANE')], ok.stdout).toEqual([0, 'LANE: free carried run=7/1 tests_ran=2 (files)']);
|
||||
const line = lineOf(ok.stdout, 'TEST_LANE_EXCEPTION');
|
||||
expect(line).toMatch(new RegExp(`^TEST_LANE_EXCEPTION: ${HELPERS} at ${head.slice(0, 12)}: preflight free\\.excepted ran 1 test\\(s\\) here; granted by Garry \\(via GStack Minder\\) at \\S+Z: bun registers the helpers suite under test/a\\.test\\.ts \\(approval a-[0-9a-f]{12}\\)$`));
|
||||
const bodyLine = `Test-lane exception: ${line.slice('TEST_LANE_EXCEPTION: '.length)}`;
|
||||
expect(lineOf(ok.stdout, 'NEXT')).toContain(JSON.stringify(bodyLine));
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free /);
|
||||
|
||||
const body = path.join(f.root, 'body.md');
|
||||
const write = () => f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], NO_MIRROR);
|
||||
fs.writeFileSync(body, '## Summary\n\nAdds b.\n');
|
||||
const short = write();
|
||||
expect([short.status, short.stderr]).toEqual([EXIT.fail, expect.stringContaining(`does not list ${JSON.stringify(bodyLine)}`)]);
|
||||
fs.writeFileSync(body, `## Summary\n\nAdds b.\n\n${bodyLine}\n`);
|
||||
const written = write();
|
||||
expect(written.status, written.stderr).toBe(EXIT.ok);
|
||||
const receipt = extractReceipt(written.stdout);
|
||||
expect(receipt.errors).toEqual([]);
|
||||
expect(receipt.receipt?.ci_carried).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1,
|
||||
exception: expect.objectContaining({ head, files: [HELPERS], by: 'Garry (via GStack Minder)', preflight: { label: 'free.excepted', tests_ran: 1 } }) })]);
|
||||
});
|
||||
|
||||
// Value: protects=each excepted file must run and pass on its own, a lane CI covered none of still counts its preflighted files, and two
|
||||
// grants at one head each keep their own file set; fails_when=a passing file masks a zero-test one in one aggregate run, a fully excepted
|
||||
// lane grades ZERO-RUN, or a later grant at the same head replaces the one that matches; seam=record-ci's preflight and testLaneExceptions
|
||||
test('a zero-test excepted file refuses even beside a passing one', () => {
|
||||
const { f, head } = fx({ 'test/empty.test.ts': 'export const empty = true;\n' });
|
||||
expect(grant(f, head, `${HELPERS},test/empty.test.ts`).status).toBe(0);
|
||||
const out = accept(f);
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([3, expect.stringMatching(/exception_preflight_failed: test-lane exception a-[0-9a-f]{12}'s preflight of test\/empty\.test\.ts (exited \d+|ran 0 tests) here/)]);
|
||||
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
|
||||
});
|
||||
|
||||
test('a lane whose every inventory file is excepted carries with its preflighted files counted', () => {
|
||||
const { f, head } = fx({}, ['test/other.test.ts']);
|
||||
expect(grant(f, head, `test/a.test.ts,${HELPERS}`).status).toBe(0);
|
||||
const ok = accept(f);
|
||||
expect([ok.status, lineOf(ok.stdout, 'LANE'), lineOf(ok.stdout, 'EVIDENCE')], ok.stdout).toEqual([0, 'LANE: free carried run=7/1 tests_ran=2 (files)', expect.stringMatching(/^EVIDENCE: FRESH label=free /)]);
|
||||
});
|
||||
|
||||
test('a later grant at the same head for another file set leaves the matching grant bound', () => {
|
||||
const { f, head } = fx();
|
||||
const first = grant(f, head);
|
||||
expect(first.status).toBe(0);
|
||||
expect(grant(f, head, `${HELPERS},test/other.test.ts`).status).toBe(0);
|
||||
const ok = accept(f);
|
||||
expect([ok.status, lineOf(ok.stdout, 'TEST_LANE_EXCEPTION')], ok.stdout).toEqual([0, expect.stringContaining(`(approval ${JSON.parse(first.stdout).added[0]})`)]);
|
||||
});
|
||||
|
||||
// Value: protects=the grant binds one head, one exact file set and a passing preflight only; fails_when=a grant made at an earlier head, a grant
|
||||
// for other files, or a failing local run lets the lane carry; seam=findCarry's excepted set and record-ci's preflight
|
||||
test.each([
|
||||
['granted at another head', {}, (f: ShipFx) => f.git('rev-parse', 'HEAD~1').stdout.trim(), HELPERS, /binds another head, not HEAD [0-9a-f]{12}/],
|
||||
['granted for another file set', {}, (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim(), `${HELPERS},test/other.test.ts`, /names test\/helpers\.test\.ts, test\/other\.test\.ts, not exactly these/],
|
||||
['whose preflight fails here', { [HELPERS]: FAILING }, (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim(), HELPERS, /^CI_CARRY: none label=free \(exception_preflight_failed: test-lane exception a-[0-9a-f]{12}'s preflight of test\/helpers\.test\.ts exited 1 here/],
|
||||
] as const)('a grant %s refuses', (_what, feat, at, files, why) => {
|
||||
const { f } = fx(feat);
|
||||
expect(grant(f, at(f), files).status).toBe(0);
|
||||
const out = accept(f);
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([3, expect.stringMatching(why)]);
|
||||
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
|
||||
});
|
||||
|
||||
// Value: protects=the lane cannot grant itself the exception; fails_when=gstack-approvals writes a grant inside the open /ship run, through add, or
|
||||
// by gstack, or record-ci binds a row gstack wrote; seam=gstack-approvals lane-exception and testLaneExceptions
|
||||
test('a lane-written grant refuses: inside the open /ship run, through add, by gstack, or a row gstack wrote', () => {
|
||||
const { f, head } = fx();
|
||||
const marker = path.resolve(f.work, f.git('rev-parse', '--git-path', 'gstack-ship-run').stdout.trim());
|
||||
fs.writeFileSync(marker, 'ship-1595-abc\n');
|
||||
const inRun = grant(f, head);
|
||||
expect([inRun.status, inRun.stderr]).toEqual([EXIT.usage, expect.stringContaining('/ship run ship-1595-abc is open on this checkout')]);
|
||||
fs.rmSync(marker);
|
||||
const add = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', 'exception', '--words', 'yes', '--by', 'Garry', '--scope', `test_lane_exception ${head} ${HELPERS}`], NO_MIRROR);
|
||||
expect([add.status, add.stderr]).toEqual([EXIT.usage, expect.stringContaining('gstack-approvals lane-exception')]);
|
||||
const self = grant(f, head, HELPERS, 'gstack ship lane');
|
||||
expect([self.status, self.stderr]).toEqual([EXIT.usage, expect.stringContaining('--by names gstack')]);
|
||||
const loc = locate({ cwd: f.work, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, ...NO_MIRROR } });
|
||||
const row = buildRow({ ...testLaneExceptionRow(head, [HELPERS], 'the lane says so'), words: 'granted', by: 'gstack ship lane' }, 'feat');
|
||||
if ('code' in loc || 'code' in row) throw new Error('fixture record unavailable');
|
||||
expect(appendRows(loc, [row])).not.toHaveProperty('code');
|
||||
expect(JSON.parse(f.run(path.join(BIN, 'gstack-approvals'), ['list', '--json'], NO_MIRROR).stdout).rows.map((r: { by: string }) => r.by)).toEqual(['gstack ship lane']);
|
||||
const out = accept(f);
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')]).toEqual([3, expect.stringContaining('inventory_uncovered')]);
|
||||
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user