forgedock 1.41.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine-cli.mjs +82 -17
- package/bin/forgedock.mjs +12 -1
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/review-staging.mjs +28 -6
- package/commands/deploy-pr.md +3 -3
- package/commands/orchestrate.md +2 -2
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +30 -2
- package/docs/RELEASE-NOTES.md +13 -0
- package/docs/SELFTEST.md +34 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Opt-in nightly `selftest --live` (#4894), started from schedd's due callback on the host (never GitHub Actions). Off unless
|
|
4
|
+
// forge.yaml `selftest.nightly_interval_hours` is a positive number. The daemon only decides it is due and launches a
|
|
5
|
+
// detached `forgedock selftest --live --nightly-report <repo>`: the harness itself talks to schedd (orchestrate --add), so
|
|
6
|
+
// running it inside the daemon's event loop would deadlock it, and a 45-minute run would hold the due pass. A failing run
|
|
7
|
+
// (or one that cannot start, e.g. no sandbox repo) files ONE issue naming the failed step; the next failure edits it.
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { schedDir } from "./sched.mjs";
|
|
12
|
+
|
|
13
|
+
export const NIGHTLY_MARKER = "<!-- FORGE:SELFTEST_NIGHTLY -->";
|
|
14
|
+
const neutralize = (s) => String(s ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
15
|
+
/** One last-run file per repo: two repos on one host never share (and so never skip) each other's schedule. */
|
|
16
|
+
const stateFile = (repo) => `selftest-nightly-${String(repo).replace(/[^\w.-]+/g, "_")}.json`;
|
|
17
|
+
|
|
18
|
+
/** Interval in hours from the forge.yaml text, or null when the key is unset, zero or not a number (= nothing scheduled). */
|
|
19
|
+
export function nightlyConfig(raw) {
|
|
20
|
+
const sec = String(raw ?? "").split(/^selftest:/m)[1]?.split(/^\S/m)[0] ?? "";
|
|
21
|
+
const h = Number(/^\s+nightly_interval_hours:\s*["']?([\d.]+)/m.exec(sec)?.[1]);
|
|
22
|
+
return h > 0 ? h : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readLast(dir, repo) {
|
|
26
|
+
try { return Number(JSON.parse(readFileSync(join(dir, stateFile(repo)), "utf8")).lastRun) || 0; } catch { return 0; }
|
|
27
|
+
}
|
|
28
|
+
function writeLast(dir, repo, ts) {
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, stateFile(repo)), JSON.stringify({ lastRun: ts }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start `node <cli> selftest --live --nightly-report <repo>` detached, from the daemon. A spawn failure (EAGAIN, EMFILE,
|
|
35
|
+
* a vanished cwd) arrives as an async 'error' event: without a listener it is an uncaught exception, which stops schedd.
|
|
36
|
+
* It is swallowed here; that night's run is lost, never the daemon.
|
|
37
|
+
*/
|
|
38
|
+
export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
|
|
39
|
+
try {
|
|
40
|
+
const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
|
|
41
|
+
c.on("error", () => {});
|
|
42
|
+
c.unref();
|
|
43
|
+
return c;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** File (or edit) the one nightly failure issue in `repo`. `result` is {step, message}. @returns {Promise<{issue:?number, action:"create"|"edit"}>} */
|
|
48
|
+
export async function reportNightly({ repo, io, now = () => Date.now(), result }) {
|
|
49
|
+
const step = result?.step ?? "unknown";
|
|
50
|
+
const title = `selftest --live failed at step ${step} (nightly)`;
|
|
51
|
+
const body = `The nightly \`forgedock selftest --live\` failed at step **${neutralize(step)}**.\n\n${neutralize(String(result?.message ?? "").slice(0, 2000))}\n\nLast failure: ${new Date(now()).toISOString()}\n\n${NIGHTLY_MARKER}\n`;
|
|
52
|
+
const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body"])))
|
|
53
|
+
.find((i) => String(i.body ?? "").includes(NIGHTLY_MARKER));
|
|
54
|
+
if (open) {
|
|
55
|
+
await io.gh(["issue", "edit", String(open.number), "-R", repo, "--title", title, "--body", body]);
|
|
56
|
+
return { issue: open.number, action: "edit" };
|
|
57
|
+
}
|
|
58
|
+
const out = await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]);
|
|
59
|
+
return { issue: Number(/(\d+)\s*$/.exec(String(out).trim())?.[1]) || null, action: "create" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One schedd pass. With `launch` (the daemon) it only starts the detached run and returns {ran, launched}; with `runLive`
|
|
64
|
+
* (tests, or an in-process caller) it awaits {ok, step, message} and files the failure. A harness that throws is a
|
|
65
|
+
* failure at step "harness", never silent. Never throws.
|
|
66
|
+
* @returns {Promise<{ran:boolean, launched?:boolean, ok?:boolean, issue?:?number, action?:"create"|"edit"}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function nightlyDue({ repo, cwd = process.cwd(), io, now = () => Date.now(), runLive, launch, stateDir = schedDir() } = {}) {
|
|
69
|
+
try {
|
|
70
|
+
const file = join(cwd, "forge.yaml");
|
|
71
|
+
const hours = nightlyConfig(existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
72
|
+
if (!hours || !repo) return { ran: false };
|
|
73
|
+
if (now() - readLast(stateDir, repo) < hours * 3600_000) return { ran: false };
|
|
74
|
+
writeLast(stateDir, repo, now()); // before the run: a crashing harness must not re-run every 15 min
|
|
75
|
+
if (launch) { launch(); return { ran: true, launched: true }; }
|
|
76
|
+
let r;
|
|
77
|
+
try { r = await runLive(); } catch (e) { r = { ok: false, step: "harness", message: String(e?.message ?? e) }; }
|
|
78
|
+
if (r.ok) return { ran: true, ok: true };
|
|
79
|
+
return { ran: true, ok: false, ...(await reportNightly({ repo, io, now, result: r })) };
|
|
80
|
+
} catch { return { ran: false }; }
|
|
81
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { nightlyConfig, nightlyDue, launchNightly, NIGHTLY_MARKER } from "./selftest-nightly.mjs";
|
|
8
|
+
|
|
9
|
+
const setup = (yaml) => { const cwd = tmpDir("nt-"), stateDir = tmpDir("ns-"); if (yaml) writeFileSync(join(cwd, "forge.yaml"), yaml); return { cwd, stateDir }; };
|
|
10
|
+
const YAML = "selftest:\n sandbox_repo: a/b\n nightly_interval_hours: 24\n";
|
|
11
|
+
const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
|
|
12
|
+
|
|
13
|
+
test("nightlyConfig: unset, zero and other sections are off", () => {
|
|
14
|
+
assert.equal(nightlyConfig(""), null);
|
|
15
|
+
assert.equal(nightlyConfig("selftest:\n sandbox_repo: a/b\n"), null);
|
|
16
|
+
assert.equal(nightlyConfig("selftest:\n nightly_interval_hours: 0\n"), null);
|
|
17
|
+
assert.equal(nightlyConfig("other:\n nightly_interval_hours: 5\n"), null);
|
|
18
|
+
assert.equal(nightlyConfig(YAML), 24);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("key unset: nothing is scheduled", async () => {
|
|
22
|
+
const s = setup("selftest:\n sandbox_repo: a/b\n"); let n = 0;
|
|
23
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io: fakeIo(), runLive: async () => { n++; return { ok: true }; } });
|
|
24
|
+
assert.equal(r.ran, false); assert.equal(n, 0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("key set: runs when due, not again within the interval", async () => {
|
|
28
|
+
const s = setup(YAML); let n = 0, t = 1e13;
|
|
29
|
+
const run = () => nightlyDue({ ...s, repo: "o/r", io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
|
|
30
|
+
assert.equal((await run()).ran, true);
|
|
31
|
+
t += 3600_000; assert.equal((await run()).ran, false);
|
|
32
|
+
t += 24 * 3600_000; assert.equal((await run()).ran, true);
|
|
33
|
+
assert.equal(n, 2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("failure files one issue naming the step; the second failure edits it", async () => {
|
|
37
|
+
const s = setup(YAML); let t = 1e13;
|
|
38
|
+
const io1 = fakeIo();
|
|
39
|
+
const r1 = await nightlyDue({ ...s, repo: "o/r", io: io1, now: () => t, runLive: async () => ({ ok: false, step: "body", message: "lost <!-- x --> body" }) });
|
|
40
|
+
assert.deepEqual([r1.action, r1.issue], ["create", 77]);
|
|
41
|
+
const create = io1.calls.find((a) => a[1] === "create");
|
|
42
|
+
assert.match(create.join(" "), /step body/); assert.ok(create.join(" ").includes(NIGHTLY_MARKER));
|
|
43
|
+
assert.ok(!create[create.indexOf("--body") + 1].includes("<!-- x"));
|
|
44
|
+
t += 25 * 3600_000;
|
|
45
|
+
const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}` }]);
|
|
46
|
+
const r2 = await nightlyDue({ ...s, repo: "o/r", io: io2, now: () => t, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
47
|
+
assert.deepEqual([r2.action, r2.issue], ["edit", 77]);
|
|
48
|
+
assert.ok(!io2.calls.some((a) => a[1] === "create"));
|
|
49
|
+
assert.match(io2.calls.find((a) => a[1] === "edit").join(" "), /step add/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a passing run writes no issue; a throwing harness is reported, never silent", async () => {
|
|
53
|
+
const s = setup(YAML), io = fakeIo();
|
|
54
|
+
await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: true }) });
|
|
55
|
+
assert.equal(io.calls.length, 0);
|
|
56
|
+
const s2 = setup(YAML), io2 = fakeIo();
|
|
57
|
+
const r = await nightlyDue({ ...s2, repo: "o/r", io: io2, runLive: async () => { throw new Error("boom"); } });
|
|
58
|
+
assert.deepEqual([r.ran, r.ok, r.action], [true, false, "create"]);
|
|
59
|
+
assert.match(io2.calls.find((a) => a[1] === "create").join(" "), /step harness/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("with launch (the daemon) the run is only started, detached: nothing is awaited or filed in-process", async () => {
|
|
63
|
+
const s = setup(YAML), io = fakeIo(); let launched = 0;
|
|
64
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; }, runLive: async () => { throw new Error("must not run"); } });
|
|
65
|
+
assert.deepEqual(r, { ran: true, launched: true }); assert.equal(launched, 1); assert.equal(io.calls.length, 0);
|
|
66
|
+
assert.equal((await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; } })).ran, false, "not again within the interval");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the schedule is per repo: one repo's run never skips another's", async () => {
|
|
70
|
+
const s = setup(YAML); let n = 0; const t = 1e13;
|
|
71
|
+
const run = (repo) => nightlyDue({ ...s, repo, io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
|
|
72
|
+
assert.equal((await run("o/a")).ran, true); assert.equal((await run("o/b")).ran, true); assert.equal(n, 2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("launchNightly: detached, unref'd, and an async spawn error never escapes (it would stop schedd)", async () => {
|
|
76
|
+
let args = null, unrefd = false;
|
|
77
|
+
const child = new EventEmitter(); child.unref = () => { unrefd = true; };
|
|
78
|
+
const c = launchNightly({ cli: "/x/forgedock.mjs", repo: "o/r", cwd: "/tmp", spawnFn: (...a) => { args = a; return child; } });
|
|
79
|
+
assert.equal(c, child); assert.ok(unrefd);
|
|
80
|
+
assert.deepEqual(args[1], ["/x/forgedock.mjs", "selftest", "--live", "--nightly-report", "o/r"]);
|
|
81
|
+
assert.equal(args[2].detached, true); assert.equal(args[2].stdio, "ignore");
|
|
82
|
+
assert.doesNotThrow(() => child.emit("error", Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" })));
|
|
83
|
+
assert.equal(launchNightly({ cli: "c", repo: "o/r", spawnFn: () => { throw new Error("EMFILE"); } }), null);
|
|
84
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Static half of `forgedock selftest` (#4865): every `gh issue|pr view|list --json a,b` field list in bin/ must be a
|
|
4
|
+
// subset of the fields `gh <cmd> --help` advertises on this host (#4859: `authorAssociation` is not a gh field).
|
|
5
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/** Non-test .mjs files under `dir`, recursively (node_modules skipped). */
|
|
10
|
+
export function listSources(dir) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
13
|
+
if (e.name === "node_modules") continue;
|
|
14
|
+
const p = join(dir, e.name);
|
|
15
|
+
if (e.isDirectory()) out.push(...listSources(p));
|
|
16
|
+
else if (e.name.endsWith(".mjs") && !/\.test\.mjs$/.test(e.name)) out.push(p);
|
|
17
|
+
}
|
|
18
|
+
return out.sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {{cmd:string, fields:string[], line:number}[]} the literal gh `--json` field lists of `text` (cmd e.g. "issue view"). */
|
|
22
|
+
export function extractJsonFields(text) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const re = /\[([^[\]]*?)["'`]--json["'`]\s*,\s*["'`]([A-Za-z0-9_,]+)["'`]/g;
|
|
25
|
+
for (let m; (m = re.exec(text));) {
|
|
26
|
+
const words = [...m[1].matchAll(/["'`]([a-z-]+)["'`]/g)].map((x) => x[1]);
|
|
27
|
+
const i = words.findIndex((w, k) => (w === "issue" || w === "pr") && (words[k + 1] === "view" || words[k + 1] === "list"));
|
|
28
|
+
if (i < 0) continue;
|
|
29
|
+
out.push({ cmd: `${words[i]} ${words[i + 1]}`, fields: m[2].split(",").filter(Boolean), line: text.slice(0, m.index).split("\n").length });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The field names of the `JSON FIELDS` section of `gh <cmd> --help`. */
|
|
35
|
+
export function parseHelpFields(help) {
|
|
36
|
+
const lines = String(help).split("\n");
|
|
37
|
+
const at = lines.findIndex((l) => /^\s*JSON FIELDS\s*$/i.test(l));
|
|
38
|
+
if (at < 0) return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const l of lines.slice(at + 1)) {
|
|
41
|
+
if (!l.trim()) { if (out.length) break; continue; }
|
|
42
|
+
if (!/^\s/.test(l)) break;
|
|
43
|
+
out.push(...l.split(",").map((s) => s.trim()).filter(Boolean));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{root:string, io:{gh:(args:string[])=>string, read?:(p:string)=>string, list?:(d:string)=>string[]}}} a
|
|
50
|
+
* @returns {{ok:boolean, checked:number, missing:{file:string,line:number,cmd:string,field:string}[], errors:string[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function staticCheck({ root, io }) {
|
|
53
|
+
const read = io.read ?? ((p) => readFileSync(p, "utf8")), list = io.list ?? listSources;
|
|
54
|
+
const known = new Map(), errors = [], missing = [];
|
|
55
|
+
let checked = 0;
|
|
56
|
+
for (const file of list(root)) {
|
|
57
|
+
for (const u of extractJsonFields(read(file))) {
|
|
58
|
+
if (!known.has(u.cmd)) {
|
|
59
|
+
let fields = [];
|
|
60
|
+
try { fields = parseHelpFields(io.gh([...u.cmd.split(" "), "--help"])); } catch (e) { errors.push(`gh ${u.cmd} --help failed: ${e.message}`); }
|
|
61
|
+
if (!fields.length && !errors.length) errors.push(`gh ${u.cmd} --help lists no JSON fields`);
|
|
62
|
+
known.set(u.cmd, new Set(fields));
|
|
63
|
+
}
|
|
64
|
+
const set = known.get(u.cmd);
|
|
65
|
+
if (!set.size) continue;
|
|
66
|
+
checked++;
|
|
67
|
+
for (const field of u.fields) if (!set.has(field)) missing.push({ file, line: u.line, cmd: u.cmd, field });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ok: !missing.length && !errors.length, checked, missing, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const realGh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
74
|
+
|
|
75
|
+
export function formatStatic(r) {
|
|
76
|
+
return [
|
|
77
|
+
...r.missing.map((m) => `${m.file}:${m.line}: gh ${m.cmd} --json field "${m.field}" is not offered by this gh`),
|
|
78
|
+
...r.errors,
|
|
79
|
+
`static check: ${r.ok ? "ok" : "FAILED"} (${r.checked} field lists checked)`,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { extractJsonFields, parseHelpFields, staticCheck, formatStatic } from "./selftest-static.mjs";
|
|
6
|
+
|
|
7
|
+
const HELP = "Usage\n\nJSON FIELDS\n author, body, number,\n title\n\nARGUMENTS\n none\n";
|
|
8
|
+
const gh = (args) => { if (args[0] === "issue" || args[0] === "pr") return HELP; throw new Error("no"); };
|
|
9
|
+
|
|
10
|
+
describe("selftest static check (#4865)", () => {
|
|
11
|
+
it("extracts only gh issue|pr view|list field lists", () => {
|
|
12
|
+
const t = `x\nawait io.gh(["issue", "view", String(n), "--json", "body,title"]);\nrun(["repo", "view", "--json", "nameWithOwner"]);\ngh(["pr", "list", "--json", "number"])`;
|
|
13
|
+
assert.deepEqual(extractJsonFields(t), [{ cmd: "issue view", fields: ["body", "title"], line: 2 }, { cmd: "pr list", fields: ["number"], line: 4 }]);
|
|
14
|
+
});
|
|
15
|
+
it("parses the JSON FIELDS section of the help", () => {
|
|
16
|
+
assert.deepEqual(parseHelpFields(HELP), ["author", "body", "number", "title"]);
|
|
17
|
+
});
|
|
18
|
+
it("fails on a bogus field (#4859 authorAssociation) and names file:line", () => {
|
|
19
|
+
const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["issue", "list", "--json", "number,authorAssociation"])' } });
|
|
20
|
+
assert.equal(r.ok, false);
|
|
21
|
+
assert.deepEqual(r.missing, [{ file: "/bin/a.mjs", line: 1, cmd: "issue list", field: "authorAssociation" }]);
|
|
22
|
+
assert.match(formatStatic(r), /a\.mjs:1: gh issue list --json field "authorAssociation"/);
|
|
23
|
+
});
|
|
24
|
+
it("passes when every field is offered", () => {
|
|
25
|
+
const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["pr", "view", "1", "--json", "title,body"])' } });
|
|
26
|
+
assert.equal(r.ok, true); assert.equal(r.checked, 1);
|
|
27
|
+
});
|
|
28
|
+
it("fails closed when gh help cannot be read", () => {
|
|
29
|
+
const r = staticCheck({ root: "/bin", io: { gh: () => { throw new Error("no gh"); }, list: () => ["/a.mjs"], read: () => 'x(["pr", "view", "--json", "title"])' } });
|
|
30
|
+
assert.equal(r.ok, false);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/bin/lean/toolstate.mjs
CHANGED
|
@@ -51,14 +51,30 @@ export async function unstageToolState(git) {
|
|
|
51
51
|
return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const REQUIREMENTS_EXPORT = /^requirements([-_.][\w.-]+)?\.txt$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* #4959: `requirements*.txt` files the branch added to a Poetry/uv project (pyproject [tool.poetry], or poetry.lock / uv.lock at
|
|
58
|
+
* the base) that the base does not track and the contract does not list (`keep`): an export generated to run local tests.
|
|
59
|
+
*/
|
|
60
|
+
export async function strayGenerated(git, ref, added, keep = []) {
|
|
61
|
+
const cands = added.filter((p) => REQUIREMENTS_EXPORT.test(base(p)) && !keep.includes(p));
|
|
62
|
+
if (!cands.length) return [];
|
|
63
|
+
const tracked = zlist(await git(["ls-tree", "-r", "--name-only", "-z", ref]));
|
|
64
|
+
const managed = tracked.some((f) => /(^|\/)(poetry|uv)\.lock$/.test(f))
|
|
65
|
+
|| (await Promise.all(tracked.filter((f) => /(^|\/)pyproject\.toml$/.test(f)).map((f) => git(["show", `${ref}:${f}`]).catch(() => "")))).some((t) => /^\[tool\.poetry[\].]/m.test(String(t)));
|
|
66
|
+
return managed ? cands : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
/**
|
|
55
70
|
* Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
|
|
56
|
-
* when the branch is clean).
|
|
71
|
+
* when the branch is clean). `keep`: contract deliverable paths, never removed as generated files. Never throws: a failure
|
|
72
|
+
* leaves the branch as it was and the push proceeds.
|
|
57
73
|
*/
|
|
58
|
-
export async function untrackToolState({ git, base: ref, ev = () => {} }) {
|
|
74
|
+
export async function untrackToolState({ git, base: ref, ev = () => {}, keep = [] }) {
|
|
59
75
|
try {
|
|
60
76
|
const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
|
|
61
|
-
const stray = await strayPaths(git, added);
|
|
77
|
+
const stray = [...await strayPaths(git, added), ...await strayGenerated(git, ref, added, keep)];
|
|
62
78
|
if (!stray.length) return [];
|
|
63
79
|
await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
|
|
64
80
|
await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
|
|
@@ -112,3 +112,25 @@ describe("lane scratch is untracked and cleared (#4880)", () => {
|
|
|
112
112
|
assert.equal(execFileSync("git", ["ls-files", ".forge-lean"], { cwd: root, encoding: "utf8" }).trim(), "");
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("generated requirements.txt is untracked in a Poetry project (#4959)", () => {
|
|
117
|
+
const lane = (poetry, files) => {
|
|
118
|
+
const d = tmpDir("toolstate-poetry-");
|
|
119
|
+
sh(d, "init", "-q", "-b", "main"); sh(d, "config", "user.email", "t@t"); sh(d, "config", "user.name", "t");
|
|
120
|
+
put(d, "pyproject.toml", poetry ? "[tool.poetry]\nname = \"x\"\n" : "[project]\nname = \"x\"\n");
|
|
121
|
+
sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base"); sh(d, "checkout", "-q", "-b", "lane");
|
|
122
|
+
for (const f of files) put(d, f);
|
|
123
|
+
sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "lane");
|
|
124
|
+
return d;
|
|
125
|
+
};
|
|
126
|
+
it("drops an added requirements.txt, keeps product files and contract-listed ones", async () => {
|
|
127
|
+
const d = lane(true, ["services/worker/requirements.txt", "services/worker/app.py", "services/api/requirements.txt"]);
|
|
128
|
+
const removed = await untrackToolState({ git: gitIn(d), base: "main", keep: ["services/api/requirements.txt"] });
|
|
129
|
+
assert.deepEqual(removed, ["services/worker/requirements.txt"]);
|
|
130
|
+
assert.deepEqual(sh(d, "diff", "--name-only", "main...HEAD").trim().split("\n"), ["services/api/requirements.txt", "services/worker/app.py"]);
|
|
131
|
+
});
|
|
132
|
+
it("a non-Poetry project is untouched", async () => {
|
|
133
|
+
const d = lane(false, ["requirements.txt"]);
|
|
134
|
+
assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -60,7 +60,15 @@ export function parseVerify(sec = {}) {
|
|
|
60
60
|
const flaky = [...new Set(rawFlaky.map((x) => String(x ?? "").trim().replace(/^(["'])(.*)\1$/s, "$2").trim()).filter(Boolean))];
|
|
61
61
|
const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
|
|
62
62
|
const invariants = [...new Set((Array.isArray(s.invariants) ? s.invariants : []).map((x) => String(x ?? "").trim()).filter(Boolean))];
|
|
63
|
-
|
|
63
|
+
// #4792: guard_command maps an extension (`.py` or `py`) to a test-file command template; read by engine-cli readYamlMap; the key is omitted when empty so unconfigured repos parse as before
|
|
64
|
+
const guardCommand = {};
|
|
65
|
+
if (s.guard_command && typeof s.guard_command === "object" && !Array.isArray(s.guard_command)) {
|
|
66
|
+
for (const [k, v] of Object.entries(s.guard_command)) {
|
|
67
|
+
const ext = String(k).trim().toLowerCase().replace(/^\.?/, "."), cmd = str(v);
|
|
68
|
+
if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -22,6 +22,10 @@ function fake(o = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
describe("verify-by-CI config (#4167)", () => {
|
|
25
|
+
it("parseVerify normalises guard_command extensions and drops empty values (#4792)", () => {
|
|
26
|
+
assert.deepEqual(parseVerify({ guard_command: { PY: " scripts/pytest {path} ", ".js": "", "": "x" } }).guardCommand, { ".py": "scripts/pytest {path}" });
|
|
27
|
+
assert.equal("guardCommand" in parseVerify({ guard_command: "nope" }), false);
|
|
28
|
+
});
|
|
25
29
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
26
30
|
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
|
|
27
31
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
package/bin/review-staging.mjs
CHANGED
|
@@ -26,12 +26,15 @@ import * as nodeFs from "node:fs";
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
29
|
+
import { selftestGate, readMarkerComments } from "./lean/selftest-gate.mjs";
|
|
29
30
|
|
|
30
31
|
const INSTALL_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
31
32
|
const USAGE = "usage: forgedock review-staging [<PR number|PR URL>] [--thorough | --delta <from-sha>] [--json]\n" +
|
|
32
33
|
" forgedock review-staging [<PR>] --finalize --test-gate-output <file> [--notes-dropped <n>] [--json]\n" +
|
|
33
34
|
" forgedock review-staging [<PR>] --summary [--filed <issue>]... [--json]";
|
|
34
35
|
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
36
|
+
/** selftest.sandbox_repo turns the release gate on only when it names a repo (`null`, `~`, `false` or prose leave it off). */
|
|
37
|
+
const SANDBOX_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
35
38
|
/** Persona file / catalog keys per canonical marker domain (review-pr-agents.md routing table). */
|
|
36
39
|
const INDEX_KEY = { auth: "auth", billing: "billing", database: "database", api: "api", frontend: "frontend", infra: "infrastructure" };
|
|
37
40
|
const CONFIG_DOMAIN = { scraper: "scraping" };
|
|
@@ -137,7 +140,7 @@ function readConfig(io) {
|
|
|
137
140
|
try { cfg = parseForgeYaml(io.readFile(path)) ?? {}; } catch { /* absent config: owner/repo check below fails */ }
|
|
138
141
|
const owner = cfg.project?.owner, repo = cfg.project?.repo;
|
|
139
142
|
if (!owner || !repo) throw new Stop(1, [`review-staging: project.owner/project.repo not set in ${path}`]);
|
|
140
|
-
return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path };
|
|
143
|
+
return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path, sandboxRepo: SANDBOX_RE.test(String(cfg.selftest?.sandbox_repo ?? "").trim()) ? String(cfg.selftest.sandbox_repo).trim() : "", sandboxRaw: String(cfg.selftest?.sandbox_repo ?? "").trim() };
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
/** Same derivation as commands/review-pr-staging.md Phase -1 (and scripts/lib/fence-bootstrap.sh). */
|
|
@@ -223,6 +226,14 @@ async function main(argv, io) {
|
|
|
223
226
|
const head = await headSha(io, c, pr);
|
|
224
227
|
if (a.delta && a.delta === head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: --delta ${head} is the current head of PR #${pr}: nothing changed since that pass, nothing to review`]);
|
|
225
228
|
const sha7 = head.slice(0, 7);
|
|
229
|
+
// #4865: with selftest.sandbox_repo set, the release needs a passing live selftest for exactly this head (fail closed).
|
|
230
|
+
// Without a sandbox repo there is nothing the harness can run against, so the gate is skipped and says so.
|
|
231
|
+
if (c.sandboxRepo) {
|
|
232
|
+
let marks;
|
|
233
|
+
try { marks = await readMarkerComments(io.gh, c.ghRepo, pr); } catch (e) { throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: could not read the FORGE:SELFTEST markers of PR #${pr} (${String(e?.message ?? e).split("\n")[0].slice(0, 200)}); nothing reviewed (fail closed)`]); }
|
|
234
|
+
const selftest = selftestGate(marks, head);
|
|
235
|
+
if (!selftest.ok) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: ${selftest.reason}; nothing reviewed (fail closed)`]);
|
|
236
|
+
} else io.err(`SELFTEST: skipped (selftest.sandbox_repo ${c.sandboxRaw ? `"${c.sandboxRaw.slice(0, 80)}" is not an owner/repo` : "not set"} in forge.yaml; the release does not require a FORGE:SELFTEST marker)`);
|
|
226
237
|
let priorRoutes = 0;
|
|
227
238
|
try {
|
|
228
239
|
const out = await io.gh(["api", "--paginate", `repos/${c.ghRepo}/issues/${pr}/comments`, "--jq", '[.[] | select(.body | test("FORGE:REVIEW_ROUTE"))] | length']);
|
|
@@ -454,9 +465,19 @@ const TEST_GATE_NOTE = {
|
|
|
454
465
|
BLOCK: (r) => `> **Test Gate: BLOCK (override/advisory)**: Runtime failures were detected but deploy is proceeding. Reason: ${r}. Filed \`test-failure\` issues track the failures.`,
|
|
455
466
|
};
|
|
456
467
|
|
|
468
|
+
/** Checks that never count as CI failures: each only reports this review's own gate marker or the lean gate summary. */
|
|
469
|
+
export const CI_EXCLUDED_CHECKS = ["forgedock/verify", "forge/deploy-gate", "Check for FORGE gate markers"];
|
|
470
|
+
|
|
471
|
+
const parseChecks = (out) => { try { const j = JSON.parse(out); return Array.isArray(j) ? j : null; } catch { return null; } };
|
|
472
|
+
|
|
473
|
+
/** Failing (fail/cancel) checks outside CI_EXCLUDED_CHECKS. */
|
|
474
|
+
export function countFailingChecks(checks) {
|
|
475
|
+
return checks.filter((k) => !CI_EXCLUDED_CHECKS.includes(k?.name) && (k?.bucket === "fail" || k?.bucket === "cancel")).length;
|
|
476
|
+
}
|
|
477
|
+
|
|
457
478
|
/** Phase 8 verdict from the deterministic inputs (CI, test gate, CONFIRMED CRITICAL/HIGH). */
|
|
458
479
|
export function stagingVerdict({ ciFailing, testGateInvalid, confirmedCritical, confirmedHigh }) {
|
|
459
|
-
if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify
|
|
480
|
+
if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify, forge/deploy-gate and the gate-marker check excluded)` };
|
|
460
481
|
if (testGateInvalid) return { verdict: "BLOCK DEPLOY", why: "test-gate result missing/invalid (fail closed)" };
|
|
461
482
|
if (confirmedCritical > 0) return { verdict: "BLOCK DEPLOY", why: `${confirmedCritical} CONFIRMED CRITICAL finding(s)` };
|
|
462
483
|
if (confirmedHigh > 0) return { verdict: "NEEDS FIXES FIRST", why: `${confirmedHigh} CONFIRMED HIGH finding(s)` };
|
|
@@ -472,10 +493,11 @@ async function summary(a, io) {
|
|
|
472
493
|
const tg = testGateResult(io, sd, pr);
|
|
473
494
|
|
|
474
495
|
// Phase 1E read: forgedock/verify is excluded; an unreadable result counts as failing (fail closed). forge/deploy-gate
|
|
475
|
-
//
|
|
496
|
+
// and the gate-marker check are excluded too: they report this review's own marker, so an earlier GATE_FAILURE must not block the re-run that clears it.
|
|
476
497
|
let ciFailing = 1;
|
|
477
|
-
const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket"
|
|
478
|
-
|
|
498
|
+
const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket"], { env, cwd: io.cwd });
|
|
499
|
+
const checks = ci.code === 0 ? parseChecks(ci.stdout) : null;
|
|
500
|
+
if (checks) ciFailing = countFailingChecks(checks);
|
|
479
501
|
|
|
480
502
|
const v = stagingVerdict({ ciFailing, testGateInvalid: tg.invalid, confirmedCritical: st.confirmedCritical, confirmedHigh: st.confirmedHigh });
|
|
481
503
|
const pass = v.verdict === "APPROVE FOR DEPLOY";
|
|
@@ -501,7 +523,7 @@ async function summary(a, io) {
|
|
|
501
523
|
"### Risk Matrix",
|
|
502
524
|
"| Domain | Result | Notes |",
|
|
503
525
|
"|--------|--------|-------|",
|
|
504
|
-
`| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify
|
|
526
|
+
`| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify, forge/deploy-gate and the gate-marker check excluded |`,
|
|
505
527
|
`| Findings | ${st.confirmedCritical} CRITICAL / ${st.confirmedHigh} HIGH (CONFIRMED) | per-domain verdicts are in the FORGE:REVIEW-AGENT comments |`,
|
|
506
528
|
`| **Test Gate** | \`${tg.verdict}\` | \`${tg.reason || "Phase 6.5 not run"}\` |`,
|
|
507
529
|
"",
|
package/commands/deploy-pr.md
CHANGED
|
@@ -370,9 +370,9 @@ else
|
|
|
370
370
|
# Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
|
|
371
371
|
# old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
|
|
372
372
|
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
373
|
-
# forge/deploy-gate (the staging review's marker status)
|
|
373
|
+
# forge/deploy-gate and "Check for FORGE gate markers" (the staging review's marker status and its workflow job) are not fixable by /fix-ci: ignored here, still waited on in Phase 5.
|
|
374
374
|
CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
375
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
375
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
376
376
|
|
|
377
377
|
if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
|
|
378
378
|
echo "CI gate: PASSED (no failing checks)"
|
|
@@ -394,7 +394,7 @@ else
|
|
|
394
394
|
|
|
395
395
|
# Re-check CI status after fix attempt (same fail-closed query as above)
|
|
396
396
|
FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
397
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
397
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
398
398
|
|
|
399
399
|
if [ "$FAILING_AFTER" -eq 0 ]; then
|
|
400
400
|
echo "CI gate: PASSED after $CI_ITER iteration(s)"
|
package/commands/orchestrate.md
CHANGED
|
@@ -103,10 +103,10 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
|
|
|
103
103
|
`forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
|
|
104
104
|
`forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
|
|
105
105
|
for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
|
|
106
|
-
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
|
|
106
|
+
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`, `deferred`) returns to it with
|
|
107
107
|
`forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
|
|
108
108
|
return to pending with it; a finished batch is reopened); `--add` on a `skipped` issue re-queues it (other ended issues still report `already in batch`). A dependency-skipped issue shows as `waiting on #X` while the batch runs (the blocker is re-checked each pass; merged or closed re-admits it, closed as not planned keeps it skipped) and ends `skipped` only if the blocker never resolved. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
|
|
109
|
-
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
|
|
109
|
+
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, never capped, across every batch on
|
|
110
110
|
the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
|
|
111
111
|
deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
|
|
112
112
|
`orchestration.breaker` engine-errors (`errors` within `window_min`; held issues continue on `--resume`). Lanes are
|
|
@@ -212,10 +212,10 @@ gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
|
|
|
212
212
|
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
213
213
|
# Fail closed: an unreadable result counts as failing.
|
|
214
214
|
CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
|
|
215
|
-
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
215
|
+
--jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
216
216
|
echo "CI_FAILING: $CI_FAILING"
|
|
217
217
|
```
|
|
218
|
-
`CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither
|
|
218
|
+
`CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither do `forge/deploy-gate` or `Check for FORGE gate markers`: they report this review's own gate marker, so an earlier `GATE_FAILURE` never blocks the re-run that clears it.
|
|
219
219
|
|
|
220
220
|
### Phase 1F: CI Autofix
|
|
221
221
|
|