forgedock 1.41.0 → 1.42.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 +6 -0
- package/bin/forgedock.mjs +12 -1
- 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/review-staging.mjs +28 -6
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +22 -0
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/package.json +1 -1
package/bin/engine-cli.mjs
CHANGED
|
@@ -2032,6 +2032,12 @@ async function scheddDue() {
|
|
|
2032
2032
|
await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
|
|
2033
2033
|
await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
|
|
2034
2034
|
try { const prem = await import("./lean/needs-human-premise.mjs"); await prem.sweepPremises({ repo, io: { gh: (a) => leanIo.gh(a), pkgVersion: prem.registryPkgVersion } }); } catch { /* best effort (#4725) */ } // clear needs-human whose recorded premise now holds
|
|
2035
|
+
try { // #4894: opt-in nightly live selftest (forge.yaml selftest.nightly_interval_hours); best effort
|
|
2036
|
+
const { nightlyDue, launchNightly } = await import("./lean/selftest-nightly.mjs");
|
|
2037
|
+
// Detached child: the harness calls back into this daemon (orchestrate --add), so it must never run on its event loop.
|
|
2038
|
+
const launch = () => launchNightly({ cli: fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), repo });
|
|
2039
|
+
await nightlyDue({ repo, cwd: process.cwd(), io: leanIo, launch });
|
|
2040
|
+
} catch { /* never stops the other sweeps */ }
|
|
2035
2041
|
}
|
|
2036
2042
|
|
|
2037
2043
|
/** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3362,6 +3362,7 @@ function help() {
|
|
|
3362
3362
|
["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
|
|
3363
3363
|
["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
|
|
3364
3364
|
["npx forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--dry-run] | status [--name <w>]", "Named, scoped, capped watches (forge.yaml issue_watch.watches) that claim matching open issues into lanes or one long-lived orchestrate batch, or (mode: release) pool them and cut themed releases; see docs/ISSUE-WATCH.md"],
|
|
3365
|
+
["npx forgedock selftest [--static] | --live --repo <owner/sandbox> [--lang node|python] [--pr <N>]", "Live contract harness: drives the real gh, git and orchestrate through a disposable sandbox repo (steps create, add, body, resume, merge, remote, cleanup) plus a static check of every gh --json field list; exits non-zero naming the failed step; --pr posts the FORGE:SELFTEST result the staging->main review requires; see docs/SELFTEST.md"],
|
|
3365
3366
|
["npx forgedock prod-watch run [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status", "Production-side watcher (forge.yaml prod_watch): read-only signal sources -> triage -> governor -> issues, kill-switch file, owner digest; see docs/PROD-WATCH.md"],
|
|
3366
3367
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3367
3368
|
["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
|
|
@@ -3953,7 +3954,7 @@ async function watch() {
|
|
|
3953
3954
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3954
3955
|
const KNOWN_COMMANDS = new Set([
|
|
3955
3956
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3956
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
|
|
3957
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "selftest", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
|
|
3957
3958
|
"version", "--version", "-v",
|
|
3958
3959
|
]);
|
|
3959
3960
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4224,6 +4225,16 @@ switch (command) {
|
|
|
4224
4225
|
}
|
|
4225
4226
|
break;
|
|
4226
4227
|
}
|
|
4228
|
+
case "selftest": {
|
|
4229
|
+
const { selftestFromCli } = await import("./lean/selftest-live.mjs");
|
|
4230
|
+
try {
|
|
4231
|
+
if (!(await selftestFromCli(restArgs)).ok) exitCode = 1;
|
|
4232
|
+
} catch (err) {
|
|
4233
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4234
|
+
exitCode = 1;
|
|
4235
|
+
}
|
|
4236
|
+
break;
|
|
4237
|
+
}
|
|
4227
4238
|
case "prod-watch": {
|
|
4228
4239
|
const { prodWatchFromCli } = await import("./lean/prodwatch.mjs");
|
|
4229
4240
|
try {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// FORGE:SELFTEST marker (#4865): the live selftest result on the staging->main PR, and the release gate over it.
|
|
4
|
+
import { isTrustedComment } from "../engine/phases.mjs";
|
|
5
|
+
|
|
6
|
+
// Anchored: the marker leads the comment (postMarker writes it first), so prose that quotes a marker never counts.
|
|
7
|
+
const MARKER_RE = /^\s*<!--\s*FORGE:SELFTEST\s+([^>]*?)\s*-->/;
|
|
8
|
+
|
|
9
|
+
/** @param {{result:"pass"|"fail", sha:string, step?:string}} m */
|
|
10
|
+
export function buildMarker({ result, sha, step }) {
|
|
11
|
+
return `<!-- FORGE:SELFTEST result=${result} sha=${sha} step=${step || "none"} -->`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @returns {{result:string, sha:string, step:string}|null} null for a body without a well-formed marker. */
|
|
15
|
+
export function parseMarker(body) {
|
|
16
|
+
const m = MARKER_RE.exec(String(body ?? ""));
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
const kv = Object.fromEntries(m[1].split(/\s+/).map((p) => p.split("=")).filter((p) => p.length === 2));
|
|
19
|
+
if (!/^(pass|fail)$/.test(kv.result ?? "") || !/^[0-9a-f]{40}$/.test(kv.sha ?? "")) return null;
|
|
20
|
+
return { result: kv.result, sha: kv.sha, step: kv.step ?? "none" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fail closed: the newest marker must be a pass for exactly `headSha`. @param {string[]} comments comment bodies, oldest first */
|
|
24
|
+
export function selftestGate(comments, headSha) {
|
|
25
|
+
const marks = comments.map(parseMarker).filter(Boolean);
|
|
26
|
+
const last = marks[marks.length - 1];
|
|
27
|
+
if (!last) return { ok: false, reason: "no FORGE:SELFTEST result on the PR; run `forgedock selftest --live --repo <sandbox> --pr <N>`" };
|
|
28
|
+
if (last.result !== "pass") return { ok: false, reason: `the latest FORGE:SELFTEST failed at step ${last.step}` };
|
|
29
|
+
if (last.sha !== headSha) return { ok: false, reason: `the passing FORGE:SELFTEST is for ${last.sha.slice(0, 7)}, not the PR head ${String(headSha).slice(0, 7)}` };
|
|
30
|
+
return { ok: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bodies of the trusted comments of PR `pr` that carry a marker, oldest first (one JSON object per gh output line).
|
|
35
|
+
* Only comments `isTrustedComment` accepts count (maintainer association, a Bot, FORGE_TRAIL_TRUSTED_LOGINS): anyone can
|
|
36
|
+
* comment on a public PR, and a forged pass must not open the release gate. A gh failure throws (the caller fails closed).
|
|
37
|
+
*/
|
|
38
|
+
export async function readMarkerComments(gh, repo, pr) {
|
|
39
|
+
const out = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`, "--jq", '.[] | select(.body | test("FORGE:SELFTEST")) | {body, author_association, user: {login: .user.login, type: .user.type}} | @json']);
|
|
40
|
+
return String(out).split("\n").filter(Boolean).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } })
|
|
41
|
+
.filter(isTrustedComment).map((c) => String(c.body ?? ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function postMarker(gh, repo, pr, m) {
|
|
45
|
+
await gh(["pr", "comment", String(pr), "-R", repo, "--body", `${buildMarker(m)}\nLive selftest: ${m.result}${m.step ? ` (step ${m.step})` : ""}`]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { buildMarker, parseMarker, selftestGate, readMarkerComments, postMarker } from "./selftest-gate.mjs";
|
|
6
|
+
|
|
7
|
+
const H = "a".repeat(40), O = "b".repeat(40);
|
|
8
|
+
describe("FORGE:SELFTEST gate (#4865)", () => {
|
|
9
|
+
it("round-trips a marker", () => {
|
|
10
|
+
assert.deepEqual(parseMarker(buildMarker({ result: "fail", sha: H, step: "add" })), { result: "fail", sha: H, step: "add" });
|
|
11
|
+
});
|
|
12
|
+
it("accepts a passing marker for the head", () => assert.equal(selftestGate([buildMarker({ result: "pass", sha: H })], H).ok, true));
|
|
13
|
+
it("refuses missing, failing, stale and malformed markers", () => {
|
|
14
|
+
assert.equal(selftestGate([], H).ok, false);
|
|
15
|
+
assert.equal(selftestGate(["lgtm"], H).ok, false);
|
|
16
|
+
assert.match(selftestGate([buildMarker({ result: "fail", sha: H, step: "add" })], H).reason, /step add/);
|
|
17
|
+
assert.match(selftestGate([buildMarker({ result: "pass", sha: O })], H).reason, /not the PR head/);
|
|
18
|
+
assert.equal(selftestGate(["<!-- FORGE:SELFTEST result=pass sha=abc -->"], H).ok, false);
|
|
19
|
+
});
|
|
20
|
+
it("a marker quoted inside prose does not count", () => {
|
|
21
|
+
assert.equal(parseMarker(`see: ${buildMarker({ result: "pass", sha: H })}`), null);
|
|
22
|
+
assert.equal(selftestGate([buildMarker({ result: "fail", sha: H, step: "add" }), `quoting ${buildMarker({ result: "pass", sha: H })}`], H).ok, false);
|
|
23
|
+
});
|
|
24
|
+
it("a gh failure is not read as no marker", async () => {
|
|
25
|
+
await assert.rejects(readMarkerComments(async () => { throw new Error("HTTP 502"); }, "o/r", 5), /502/);
|
|
26
|
+
});
|
|
27
|
+
it("the newest marker wins", () => {
|
|
28
|
+
assert.equal(selftestGate([buildMarker({ result: "pass", sha: H }), buildMarker({ result: "fail", sha: H, step: "merge" })], H).ok, false);
|
|
29
|
+
assert.equal(selftestGate([buildMarker({ result: "fail", sha: H, step: "merge" }), buildMarker({ result: "pass", sha: H })], H).ok, true);
|
|
30
|
+
});
|
|
31
|
+
it("reads and posts through gh", async () => {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const rows = [{ body: "x", author_association: "OWNER", user: { login: "m", type: "User" } }, { body: "forged", author_association: "NONE", user: { login: "drive-by", type: "User" } }, { body: "bot", author_association: "NONE", user: { login: "app[bot]", type: "Bot" } }];
|
|
34
|
+
const gh = async (a) => { calls.push(a); return a[0] === "api" ? rows.map((r) => JSON.stringify(r)).join("\n") + "\nnot json\n" : ""; };
|
|
35
|
+
assert.deepEqual(await readMarkerComments(gh, "o/r", 5), ["x", "bot"], "an untrusted commenter's marker is dropped");
|
|
36
|
+
await postMarker(gh, "o/r", 5, { result: "pass", sha: H });
|
|
37
|
+
assert.match(calls[1].at(-1), /FORGE:SELFTEST result=pass/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// `forgedock selftest --live --repo <owner/sandbox> [--lang node|python] [--pr N]` (#4865): drives the real binaries
|
|
4
|
+
// (gh, git, forgedock orchestrate) against a disposable sandbox repo. The mocked unit tests missed #4859 (--add skipped
|
|
5
|
+
// every issue), #4860 (issue body wiped to FORGE:STATE), #4817 and #4818; each step below fails on one of those classes.
|
|
6
|
+
// All process access goes through `io` so the step logic is unit-tested with fakes; `realIo` is the live binding.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
12
|
+
import { staticCheck, formatStatic, realGh } from "./selftest-static.mjs";
|
|
13
|
+
import { postMarker } from "./selftest-gate.mjs";
|
|
14
|
+
import { reportNightly } from "./selftest-nightly.mjs";
|
|
15
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
16
|
+
|
|
17
|
+
export class StepError extends Error {
|
|
18
|
+
constructor(step, message) { super(message); this.step = step; }
|
|
19
|
+
}
|
|
20
|
+
const fail = (step, msg) => { throw new StepError(step, msg); };
|
|
21
|
+
|
|
22
|
+
/** Fixture per toolchain: base files plus the two trivial issues (a test that fails on the old code). */
|
|
23
|
+
export const FIXTURES = {
|
|
24
|
+
node: {
|
|
25
|
+
files: { "package.json": '{"name":"selftest-sandbox","private":true,"type":"module","scripts":{"test":"node --test"}}\n', "README.md": "ForgeDock selftest sandbox\n" },
|
|
26
|
+
a: { title: "selftest A: add a()", body: "Create `src/a.js` exporting `a()` that returns `\"a\"`, and `src/a.test.js` (node:test) asserting it. The test must fail on the current code." },
|
|
27
|
+
b: { title: "selftest B: add b()", body: "Create `src/b.js` exporting `b()` that returns `\"b\"`, and `src/b.test.js` (node:test) asserting it. The test must fail on the current code." },
|
|
28
|
+
},
|
|
29
|
+
python: {
|
|
30
|
+
files: { "pyproject.toml": '[project]\nname = "selftest-sandbox"\nversion = "0.0.0"\n', "README.md": "ForgeDock selftest sandbox\n" },
|
|
31
|
+
a: { title: "selftest A: add a()", body: "Create `a.py` with `def a(): return \"a\"` and `test_a.py` (pytest) asserting it. The test must fail on the current code." },
|
|
32
|
+
b: { title: "selftest B: add b()", body: "Create `b.py` with `def b(): return \"b\"` and `test_b.py` (pytest) asserting it. The test must fail on the current code." },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The issue body with its FORGE:STATE annotation removed. */
|
|
37
|
+
export const stripState = (body) => String(body ?? "").replace(/<!--\s*FORGE:STATE[\s\S]*?-->/g, "").trim();
|
|
38
|
+
|
|
39
|
+
const num = (out) => { const m = /\/issues\/(\d+)/.exec(String(out)) ?? /^(\d+)$/m.exec(String(out)); if (!m) throw new Error(`no issue number in: ${String(out).slice(0, 120)}`); return Number(m[1]); };
|
|
40
|
+
|
|
41
|
+
async function waitFor(ctx, step, what, cond, onTick) {
|
|
42
|
+
const { io, timeoutMs } = ctx, t0 = io.now();
|
|
43
|
+
for (;;) {
|
|
44
|
+
if (onTick) await onTick();
|
|
45
|
+
const v = await cond();
|
|
46
|
+
if (v) return v;
|
|
47
|
+
if (io.now() - t0 > timeoutMs) fail(step, `timed out waiting for ${what}`);
|
|
48
|
+
await io.sleep(ctx.pollMs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Step "body": every issue body still holds its creation text (#4860). */
|
|
53
|
+
async function checkBodies(ctx, step = "body") {
|
|
54
|
+
for (const [name, n] of Object.entries(ctx.issues)) {
|
|
55
|
+
const now = JSON.parse(await ctx.io.gh(["issue", "view", String(n), "-R", ctx.repo, "--json", "body"])).body;
|
|
56
|
+
if (!stripState(now).includes(ctx.bodies[n])) fail(step, `issue ${name} (#${n}) lost its body text; now: ${JSON.stringify(stripState(now).slice(0, 80))}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const STEPS = [
|
|
61
|
+
{ name: "create", run: async (ctx) => {
|
|
62
|
+
const { io, repo, fx } = ctx;
|
|
63
|
+
for (const [path, content] of Object.entries(fx.files)) {
|
|
64
|
+
await io.gh(["api", "-X", "PUT", `repos/${repo}/contents/${path}`, "-f", `message=selftest fixture ${path}`, "-f", `content=${Buffer.from(content).toString("base64")}`]).catch(() => { /* already present */ });
|
|
65
|
+
}
|
|
66
|
+
const mk = async (t, body) => num(await io.gh(["issue", "create", "-R", repo, "--title", t, "--body", body]));
|
|
67
|
+
const a = await mk(fx.a.title, fx.a.body);
|
|
68
|
+
const b = await mk(fx.b.title, `${fx.b.body}\n\n**Depends on**: #${a}`);
|
|
69
|
+
ctx.issues = { A: a, B: b };
|
|
70
|
+
ctx.bodies = { [a]: fx.a.body, [b]: fx.b.body };
|
|
71
|
+
ctx.created.push(a, b);
|
|
72
|
+
} },
|
|
73
|
+
{ name: "add", run: async (ctx) => {
|
|
74
|
+
const { io, repo, issues } = ctx;
|
|
75
|
+
ctx.run = await io.orchestrate.start(repo, [issues.A], 2);
|
|
76
|
+
const r = await io.cli(["orchestrate", "--add", ctx.run.id, String(issues.B)]);
|
|
77
|
+
if (r.code !== 0) fail("add", `orchestrate --add exited ${r.code}: ${String(r.stderr || r.stdout).slice(0, 200)}`);
|
|
78
|
+
if (/unreadable|skipped/i.test(`${r.stdout}\n${r.stderr}`)) fail("add", `--add skipped #${issues.B}: ${String(r.stdout || r.stderr).slice(0, 200)}`);
|
|
79
|
+
const st = await waitFor(ctx, "add", `#${issues.B} in the batch`, async () => { const s = await io.orchestrate.state(ctx.run.id); return s?.order?.includes(issues.B) ? s : null; });
|
|
80
|
+
if (!(st.deps?.[issues.B] ?? []).includes(issues.A)) fail("add", `#${issues.B} does not wait on #${issues.A} in the DAG (deps: ${JSON.stringify(st.deps?.[issues.B] ?? [])})`);
|
|
81
|
+
} },
|
|
82
|
+
{ name: "body", run: (ctx) => checkBodies(ctx) },
|
|
83
|
+
{ name: "resume", run: async (ctx) => {
|
|
84
|
+
const { io, issues } = ctx;
|
|
85
|
+
const pid = async () => (await io.orchestrate.state(ctx.run.id))?.running?.[issues.A]?.pid ?? null;
|
|
86
|
+
const before = await waitFor(ctx, "resume", `a live lane for #${issues.A}`, pid);
|
|
87
|
+
await ctx.run.kill();
|
|
88
|
+
ctx.run = await io.orchestrate.resume(ctx.run.id);
|
|
89
|
+
await io.sleep(ctx.pollMs);
|
|
90
|
+
const st = await io.orchestrate.state(ctx.run.id), after = st?.running?.[issues.A]?.pid ?? null;
|
|
91
|
+
const done = st?.results?.[issues.A];
|
|
92
|
+
if (!done && after !== before) fail("resume", `#${issues.A} was launched again after --resume (pid ${before} -> ${after}), not re-adopted`);
|
|
93
|
+
await checkBodies(ctx, "body");
|
|
94
|
+
} },
|
|
95
|
+
{ name: "merge", run: async (ctx) => {
|
|
96
|
+
const { io, repo, issues } = ctx;
|
|
97
|
+
const res = await waitFor(ctx, "merge", "both lanes to finish", async () => {
|
|
98
|
+
const s = await io.orchestrate.state(ctx.run.id);
|
|
99
|
+
return s?.results?.[issues.A] && s?.results?.[issues.B] ? s.results : null;
|
|
100
|
+
}, () => checkBodies(ctx, "body"));
|
|
101
|
+
const merged = {};
|
|
102
|
+
for (const [k, n] of Object.entries(issues)) {
|
|
103
|
+
if (res[n].outcome !== "merged" || !res[n].pr) fail("merge", `#${n} ended ${res[n].outcome}, not merged`);
|
|
104
|
+
const pr = JSON.parse(await io.gh(["pr", "view", String(res[n].pr), "-R", repo, "--json", "mergedAt,state"]));
|
|
105
|
+
if (pr.state !== "MERGED") fail("merge", `PR #${res[n].pr} for ${k} is ${pr.state}`);
|
|
106
|
+
const is = JSON.parse(await io.gh(["issue", "view", String(n), "-R", repo, "--json", "state,labels"]));
|
|
107
|
+
if (is.state !== "CLOSED") fail("merge", `#${n} is not closed`);
|
|
108
|
+
if (!is.labels.some((l) => l.name === "workflow:merged")) fail("merge", `#${n} lacks the workflow:merged label`);
|
|
109
|
+
merged[k] = Date.parse(pr.mergedAt);
|
|
110
|
+
}
|
|
111
|
+
if (!(merged.A <= merged.B)) fail("merge", `B merged before A (${merged.B} < ${merged.A})`);
|
|
112
|
+
} },
|
|
113
|
+
{ name: "remote", run: async (ctx) => {
|
|
114
|
+
const { io } = ctx;
|
|
115
|
+
if (!io.remote) { ctx.notes.push("remote: skipped (pool not configured)"); return; }
|
|
116
|
+
const c = num(await io.gh(["issue", "create", "-R", ctx.repo, "--title", `${ctx.fx.a.title.replace("A:", "C:")}`, "--body", ctx.fx.a.body]));
|
|
117
|
+
ctx.created.push(c);
|
|
118
|
+
const r = await io.remote.run({ repo: ctx.repo, issue: c, timeoutMs: ctx.timeoutMs, pollMs: ctx.pollMs });
|
|
119
|
+
if (r.where !== "remote") fail("remote", `#${c} ran ${r.where ?? "unknown"}, not in the pool`);
|
|
120
|
+
if (!r.authenticated) fail("remote", "the remote lane did not authenticate");
|
|
121
|
+
const cli = await io.cliVersion();
|
|
122
|
+
if (r.engineVersion !== cli) fail("remote", `sandbox engine ${r.engineVersion} != CLI ${cli}`);
|
|
123
|
+
} },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run the steps in order; the first failure stops them, cleanup always runs.
|
|
128
|
+
* @returns {{ok:boolean, step:string|null, message:string|null, notes:string[], cleanupErrors:string[]}}
|
|
129
|
+
*/
|
|
130
|
+
export async function runSelftestLive({ repo, lang = "node", io, timeoutMs = 45 * 60 * 1000, pollMs = 5000 }) {
|
|
131
|
+
const fx = FIXTURES[lang];
|
|
132
|
+
if (!fx) return { ok: false, step: "setup", message: `unknown fixture language ${lang} (node|python)`, notes: [], cleanupErrors: [] };
|
|
133
|
+
const ctx = { io, repo, fx, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
|
|
134
|
+
let out = { ok: true, step: null, message: null };
|
|
135
|
+
for (const s of STEPS) {
|
|
136
|
+
try { await s.run(ctx); } catch (e) { out = { ok: false, step: e.step ?? s.name, message: String(e.message ?? e) }; break; }
|
|
137
|
+
}
|
|
138
|
+
const cleanupErrors = [];
|
|
139
|
+
const tryC = async (what, f) => { try { await f(); } catch (e) { cleanupErrors.push(`${what}: ${String(e.message ?? e).slice(0, 120)}`); } };
|
|
140
|
+
if (ctx.run) await tryC("stop orchestrate", () => ctx.run.kill());
|
|
141
|
+
for (const n of ctx.created) {
|
|
142
|
+
await tryC(`close #${n}`, () => io.gh(["issue", "close", String(n), "-R", repo]));
|
|
143
|
+
await tryC(`delete branches of #${n}`, async () => {
|
|
144
|
+
const refs = String(await io.gh(["api", `repos/${repo}/git/matching-refs/heads/forge/issue-${n}`, "--jq", ".[].ref"])).split("\n").filter(Boolean);
|
|
145
|
+
for (const ref of refs) await io.gh(["api", "-X", "DELETE", `repos/${repo}/git/${ref}`]);
|
|
146
|
+
});
|
|
147
|
+
await tryC(`close PRs of #${n}`, async () => {
|
|
148
|
+
const prs = String(await io.gh(["pr", "list", "-R", repo, "--search", `#${n} in:title,body`, "--state", "open", "--json", "number", "--jq", ".[].number"])).split("\n").filter(Boolean);
|
|
149
|
+
for (const p of prs) await io.gh(["pr", "close", p, "-R", repo, "--delete-branch"]);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { ...out, notes: ctx.notes, cleanupErrors };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── real io + CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
157
|
+
|
|
158
|
+
export function realIo(env = process.env, { pool = false } = {}) {
|
|
159
|
+
let self;
|
|
160
|
+
const root = batchRoot(env);
|
|
161
|
+
const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
|
|
162
|
+
const waitBatch = async (before) => { for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("orchestrate did not create a batch within 60s"); };
|
|
163
|
+
return (self = {
|
|
164
|
+
now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
165
|
+
gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.status}`); return r.stdout; },
|
|
166
|
+
cli: async (args) => { const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env }); return { code: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; },
|
|
167
|
+
cliVersion: async () => JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version,
|
|
168
|
+
orchestrate: {
|
|
169
|
+
start: async (repo, issues, parallel) => {
|
|
170
|
+
const before = new Set(listBatches(root)), h = detached(["orchestrate", ...issues.map(String), "--parallel", String(parallel), "--repo", repo]);
|
|
171
|
+
return { id: await waitBatch(before), kill: h.kill };
|
|
172
|
+
},
|
|
173
|
+
resume: async (id) => ({ id, ...detached(["orchestrate", "--resume", id]) }),
|
|
174
|
+
state: async (id) => openBatch(root, id).store.load(),
|
|
175
|
+
},
|
|
176
|
+
// Step 6: one lane through the pool. The issue's FORGE:STATE records the placement and the lane's FORGE:TELEMETRY rows
|
|
177
|
+
// carry the engine's forge_version (the sandbox's, not the CLI's: the #4818 stale-image defect).
|
|
178
|
+
remote: pool ? { run: async ({ repo, issue, timeoutMs, pollMs }) => {
|
|
179
|
+
const run = await self.orchestrate.start(repo, [issue], 1), t0 = Date.now();
|
|
180
|
+
let row = null;
|
|
181
|
+
try {
|
|
182
|
+
while (!row && Date.now() - t0 < timeoutMs) { row = (await self.orchestrate.state(run.id))?.results?.[issue] ?? null; if (!row) await self.sleep(pollMs); }
|
|
183
|
+
} finally { await run.kill(); }
|
|
184
|
+
if (!row) throw new Error(`#${issue} produced no result within ${Math.round(timeoutMs / 1000)}s`);
|
|
185
|
+
const v = JSON.parse(await self.gh(["issue", "view", String(issue), "-R", repo, "--json", "body,comments"]));
|
|
186
|
+
const text = `${v.body}\n${v.comments.map((c) => c.body).join("\n")}`;
|
|
187
|
+
const versions = [...text.matchAll(/"forge_version":"([^"]+)"/g)].map((m) => m[1]);
|
|
188
|
+
return { where: /"placement":\{"where":"(\w+)"/.exec(text)?.[1] ?? null, authenticated: versions.length > 0 && !/^env\./.test(String(row.code ?? row.outcome ?? "")), engineVersion: versions.at(-1) ?? null };
|
|
189
|
+
} } : null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The value after flag `f`, or null when absent. A flag with no value (end of argv, or another `--flag` next) throws. */
|
|
194
|
+
const flag = (argv, f) => {
|
|
195
|
+
const i = argv.indexOf(f);
|
|
196
|
+
if (i < 0) return null;
|
|
197
|
+
const v = argv[i + 1];
|
|
198
|
+
if (v == null || String(v).startsWith("--") || !String(v).trim()) throw new Error(`${f} needs a value`);
|
|
199
|
+
return v;
|
|
200
|
+
};
|
|
201
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
202
|
+
const repoOrNull = (v) => (REPO_RE.test(String(v ?? "").trim()) ? String(v).trim() : null);
|
|
203
|
+
|
|
204
|
+
/** `forgedock selftest [--static] | --live --repo <o/sandbox> [--lang node|python] [--pr N]`; non-zero exit names the failed step. */
|
|
205
|
+
export async function selftestFromCli(argv, deps = {}) {
|
|
206
|
+
const cwd = deps.cwd ?? process.cwd(), env = deps.env ?? process.env, print = deps.print ?? ((l) => console.log(l));
|
|
207
|
+
const usage = "usage: forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--lang node|python] [--pr <staging->main PR>] (sandbox repo from --repo or forge.yaml selftest.sandbox_repo)";
|
|
208
|
+
if (!argv.includes("--live") && !argv.includes("--static")) throw new Error(usage);
|
|
209
|
+
const nightlyRepo = argv.includes("--nightly-report") ? flag(argv, "--nightly-report") : null;
|
|
210
|
+
if (nightlyRepo) {
|
|
211
|
+
// #4894: the detached nightly run. Any failure, including one before the harness starts (no sandbox repo), is filed.
|
|
212
|
+
const rest = argv.filter((a, i) => a !== "--nightly-report" && argv[i - 1] !== "--nightly-report");
|
|
213
|
+
const lines = [];
|
|
214
|
+
let r;
|
|
215
|
+
try { r = await selftestFromCli(rest, { ...deps, print: (l) => { lines.push(l); print(l); } }); } catch (e) { r = { ok: false, step: "config", message: String(e?.message ?? e) }; }
|
|
216
|
+
if (!r.ok) await reportNightly({ repo: nightlyRepo, io: { gh: async (a) => (deps.io?.gh ?? realGh)(a) }, result: { step: r.step, message: r.message ?? lines.filter((l) => /FAILED|FAIL/.test(l)).join("\n") } });
|
|
217
|
+
return r;
|
|
218
|
+
}
|
|
219
|
+
const gh = deps.io?.gh ?? realGh;
|
|
220
|
+
const st = staticCheck({ root: deps.binRoot ?? join(dirname(CLI)), io: { gh: (a) => gh(a) } });
|
|
221
|
+
print(formatStatic(st));
|
|
222
|
+
let exitCode = st.ok ? 0 : 1, step = st.ok ? null : "static";
|
|
223
|
+
if (argv.includes("--live")) {
|
|
224
|
+
let cfg = {}; try { cfg = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {}; } catch { /* none */ }
|
|
225
|
+
const repo = repoOrNull(flag(argv, "--repo") ?? cfg.selftest?.sandbox_repo);
|
|
226
|
+
if (!repo) throw new Error(`no sandbox repo: pass --repo owner/sandbox or set selftest.sandbox_repo in forge.yaml\n${usage}`);
|
|
227
|
+
const project = cfg.project?.owner && cfg.project?.repo ? `${cfg.project.owner}/${cfg.project.repo}` : null;
|
|
228
|
+
if (project && project.toLowerCase() === repo.toLowerCase()) throw new Error(`refusing to run in ${repo}: it is this project's repo (forge.yaml project), and the harness creates, merges, closes and deletes there; use a disposable sandbox repo`);
|
|
229
|
+
const lang = flag(argv, "--lang") ?? cfg.selftest?.fixture ?? "node";
|
|
230
|
+
const io = deps.io ?? realIo(env, { pool: ["remote", "hybrid"].includes(String(cfg.execution?.mode ?? "")) });
|
|
231
|
+
// The marker binds the head that was tested: read it (from the release repo) before the run, never after.
|
|
232
|
+
const pr = flag(argv, "--pr");
|
|
233
|
+
let releaseRepo = null, head = null;
|
|
234
|
+
if (pr) {
|
|
235
|
+
releaseRepo = flag(argv, "--release-repo") ?? JSON.parse(String(await io.gh(["repo", "view", "--json", "nameWithOwner"]))).nameWithOwner;
|
|
236
|
+
if (releaseRepo.toLowerCase() === repo.toLowerCase()) throw new Error(`refusing to run in ${repo}: it is the release repo`);
|
|
237
|
+
head = String(await io.gh(["pr", "view", String(pr), "-R", releaseRepo, "--json", "headRefOid", "--jq", ".headRefOid"])).trim();
|
|
238
|
+
}
|
|
239
|
+
const r = await runSelftestLive({ repo, lang, io, ...(deps.timing ?? {}) });
|
|
240
|
+
for (const n of r.notes) print(n);
|
|
241
|
+
for (const e of r.cleanupErrors) print(`cleanup: ${e}`);
|
|
242
|
+
if (r.ok) print(`selftest --live: all steps passed on ${repo}`);
|
|
243
|
+
else { print(`selftest --live FAILED at step ${r.step}: ${r.message}`); exitCode = 1; step = r.step; }
|
|
244
|
+
if (pr) await postMarker((a) => io.gh(a), releaseRepo, pr, { result: exitCode === 0 ? "pass" : "fail", sha: head, step });
|
|
245
|
+
if (!r.ok) return { ok: false, step, message: r.message };
|
|
246
|
+
}
|
|
247
|
+
return { ok: exitCode === 0, step };
|
|
248
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { runSelftestLive, stripState, selftestFromCli, STEPS } from "./selftest-live.mjs";
|
|
9
|
+
|
|
10
|
+
/** A fake sandbox world. `d` toggles a defect: add4859 (--add skips), wipe4860 (body wiped), double (resume relaunches), orderBA, noRemoteAuth. */
|
|
11
|
+
function world(d = {}) {
|
|
12
|
+
const w = { issues: {}, closed: [], calls: [], killed: 0, pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
|
|
13
|
+
const gh = async (a) => {
|
|
14
|
+
w.calls.push(a.join(" "));
|
|
15
|
+
if (a[0] === "api" && a[1] === "-X" && a[2] === "PUT") return "{}";
|
|
16
|
+
if (a[0] === "issue" && a[1] === "create") {
|
|
17
|
+
const n = 10 + Object.keys(w.issues).length; w.issues[n] = { body: a[a.indexOf("--body") + 1], state: "OPEN", labels: [] }; return `https://github.com/o/r/issues/${n}\n`;
|
|
18
|
+
}
|
|
19
|
+
if (a[0] === "issue" && a[1] === "view") {
|
|
20
|
+
const n = a[2], i = w.issues[n];
|
|
21
|
+
if (a.includes("body")) return JSON.stringify({ body: d.wipe4860 && w.wiped ? "<!-- FORGE:STATE {} -->" : `${i.body}\n<!-- FORGE:STATE {"v":1} -->` });
|
|
22
|
+
return JSON.stringify({ state: i.state, labels: i.labels.map((name) => ({ name })) });
|
|
23
|
+
}
|
|
24
|
+
if (a[0] === "issue" && a[1] === "close") { w.closed.push(a[2]); return ""; }
|
|
25
|
+
if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ state: "MERGED", mergedAt: `2026-01-01T00:0${a[2] === "101" ? (d.orderBA ? 2 : 1) : (d.orderBA ? 1 : 2)}:00Z` });
|
|
26
|
+
if (a[0] === "pr" && a[1] === "list") return "";
|
|
27
|
+
if (a[0] === "api") { throw new Error("fail cleanup api"); }
|
|
28
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
29
|
+
};
|
|
30
|
+
const io = {
|
|
31
|
+
now: () => (w.clock += 1000), sleep: async () => { w.tick = (w.tick ?? 0) + 1; if (d.wipe4860 && w.tick >= 1) w.wiped = true; finish(); },
|
|
32
|
+
gh, cliVersion: async () => "1.39.0",
|
|
33
|
+
cli: async (a) => {
|
|
34
|
+
if (a[1] === "--add") { const n = Number(a[3]); if (d.add4859) return { code: 0, stdout: `#${n} skipped: unreadable`, stderr: "" }; w.order.push(n); w.deps[n] = [Number(Object.keys(w.issues)[0])]; return { code: 0, stdout: "added", stderr: "" }; }
|
|
35
|
+
throw new Error("cli " + a);
|
|
36
|
+
},
|
|
37
|
+
orchestrate: {
|
|
38
|
+
start: async (repo, issues) => { w.order.push(...issues); w.running[issues[0]] = { pid: ++w.pid }; w.a = issues[0]; return { id: "b1", kill: async () => { w.killed++; } }; },
|
|
39
|
+
resume: async (id) => { w.resumedAt = w.tick ?? 0; if (d.double) w.running[w.a] = { pid: ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
|
|
40
|
+
state: async () => ({ order: w.order, deps: w.deps, running: w.running, results: w.results }),
|
|
41
|
+
},
|
|
42
|
+
remote: w.pool ? { run: async ({ issue }) => { w.remoteIssue = issue; return { where: d.local ? "local" : "remote", authenticated: !d.noRemoteAuth, engineVersion: d.oldEngine ? "1.37.0" : "1.39.0" }; } } : null,
|
|
43
|
+
};
|
|
44
|
+
function finish() {
|
|
45
|
+
if (w.resumedAt !== undefined && !w.finished && (w.tick ?? 0) >= w.resumedAt + 2) {
|
|
46
|
+
w.finished = true;
|
|
47
|
+
const [a, b] = Object.keys(w.issues).map(Number);
|
|
48
|
+
w.results = { [a]: { outcome: "merged", pr: 101 }, [b]: { outcome: "merged", pr: 102 } };
|
|
49
|
+
for (const n of [a, b]) w.issues[n].labels.push("workflow:merged"), w.issues[n].state = "CLOSED";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { w, io };
|
|
53
|
+
}
|
|
54
|
+
const run = (d, extra = {}) => { const { w, io } = world(d); return runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000, ...extra }).then((r) => ({ w, r })); };
|
|
55
|
+
|
|
56
|
+
describe("selftest --live steps (#4865)", () => {
|
|
57
|
+
it("all steps pass and cleanup runs", async () => {
|
|
58
|
+
const { w, r } = await run({});
|
|
59
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
60
|
+
assert.deepEqual(w.closed, ["10", "11"]);
|
|
61
|
+
assert.match(r.notes.join(), /remote: skipped/);
|
|
62
|
+
});
|
|
63
|
+
it("fixtures: B depends on A and the issue bodies are the fixture text", async () => {
|
|
64
|
+
const { w } = await run({});
|
|
65
|
+
assert.match(w.issues[11].body, /\*\*Depends on\*\*: #10/);
|
|
66
|
+
});
|
|
67
|
+
it("the #4859 defect (--add skips the issue) fails the add step", async () => {
|
|
68
|
+
const { r, w } = await run({ add4859: true });
|
|
69
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add"); assert.match(r.message, /skipped #11/);
|
|
70
|
+
assert.deepEqual(w.closed, ["10", "11"], "cleanup ran after the failure");
|
|
71
|
+
});
|
|
72
|
+
it("the #4860 defect (body wiped to FORGE:STATE) fails the body step", async () => {
|
|
73
|
+
const { r } = await run({ wipe4860: true });
|
|
74
|
+
assert.equal(r.ok, false); assert.equal(r.step, "body"); assert.match(r.message, /lost its body text/);
|
|
75
|
+
});
|
|
76
|
+
it("a lane launched twice after --resume fails the resume step", async () => {
|
|
77
|
+
const { r } = await run({ double: true });
|
|
78
|
+
assert.equal(r.step, "resume"); assert.match(r.message, /launched again/);
|
|
79
|
+
});
|
|
80
|
+
it("B merging before A fails the merge step", async () => {
|
|
81
|
+
const { r } = await run({ orderBA: true });
|
|
82
|
+
assert.equal(r.step, "merge"); assert.match(r.message, /B merged before A/);
|
|
83
|
+
});
|
|
84
|
+
it("remote step: stale sandbox engine or no auth fails it; matching passes", async () => {
|
|
85
|
+
assert.equal((await run({ pool: true })).r.ok, true);
|
|
86
|
+
assert.match((await run({ pool: true, oldEngine: true })).r.message, /1\.37\.0 != CLI 1\.39\.0/);
|
|
87
|
+
assert.equal((await run({ pool: true, noRemoteAuth: true })).r.step, "remote");
|
|
88
|
+
assert.match((await run({ pool: true, local: true })).r.message, /not in the pool/);
|
|
89
|
+
const ok = await run({ pool: true });
|
|
90
|
+
assert.equal(ok.w.remoteIssue, 12); assert.deepEqual(ok.w.closed, ["10", "11", "12"]);
|
|
91
|
+
});
|
|
92
|
+
it("unknown fixture language fails at setup", async () => {
|
|
93
|
+
assert.equal((await runSelftestLive({ repo: "o/r", lang: "ruby", io: {} })).step, "setup");
|
|
94
|
+
});
|
|
95
|
+
it("stripState drops only the annotation", () => assert.equal(stripState("hi\n<!-- FORGE:STATE\n{}\n-->"), "hi"));
|
|
96
|
+
it("has the six ordered phases (cleanup runs after them)", () => assert.deepEqual(STEPS.map((s) => s.name), ["create", "add", "body", "resume", "merge", "remote"]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("selftestFromCli", () => {
|
|
100
|
+
it("requires --live or --static and a sandbox repo", async () => {
|
|
101
|
+
await assert.rejects(selftestFromCli([]), /usage/);
|
|
102
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
103
|
+
});
|
|
104
|
+
it("a failing step is named in the output, returned not-ok, and the marker is posted as fail", async () => {
|
|
105
|
+
const { io } = world({ add4859: true }); const out = [], posted = [];
|
|
106
|
+
const gh = io.gh;
|
|
107
|
+
io.gh = async (a) => {
|
|
108
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) return "a".repeat(40);
|
|
109
|
+
if (a[0] === "repo") return JSON.stringify({ nameWithOwner: "o/r" });
|
|
110
|
+
if (a[0] === "pr" && a[1] === "comment") { posted.push(a.at(-1)); return ""; }
|
|
111
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
112
|
+
return gh(a);
|
|
113
|
+
};
|
|
114
|
+
const r = await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9"], { cwd: "/nonexistent", print: (l) => out.push(l), io, timing: { pollMs: 1 } });
|
|
115
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add");
|
|
116
|
+
assert.ok(out.some((l) => /FAILED at step add/.test(l)));
|
|
117
|
+
assert.match(posted[0], /FORGE:SELFTEST result=fail sha=a{40} step=add/);
|
|
118
|
+
});
|
|
119
|
+
it("the PR head is read from the release repo before the run", async () => {
|
|
120
|
+
const { io } = world({}); const order = [];
|
|
121
|
+
const gh = io.gh;
|
|
122
|
+
io.gh = async (a) => {
|
|
123
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) { order.push(`head ${a[a.indexOf("-R") + 1]}`); return "b".repeat(40); }
|
|
124
|
+
if (a[0] === "pr" && a[1] === "comment") { order.push("post"); return ""; }
|
|
125
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
126
|
+
if (a[0] === "issue" && a[1] === "create" && !order.includes("run")) order.push("run");
|
|
127
|
+
return gh(a);
|
|
128
|
+
};
|
|
129
|
+
await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9", "--release-repo", "o/rel"], { cwd: "/nonexistent", print: () => {}, io, timing: { pollMs: 1 } });
|
|
130
|
+
assert.deepEqual(order.slice(0, 2), ["head o/rel", "run"]);
|
|
131
|
+
});
|
|
132
|
+
it("refuses the project repo or the release repo as the sandbox", async () => {
|
|
133
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "project:\n owner: o\n repo: R\n");
|
|
134
|
+
const io = { gh: async (a) => (a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : "") };
|
|
135
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/r"], { cwd, print: () => {}, io }), /this project's repo/);
|
|
136
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/x", "--pr", "3", "--release-repo", "o/x"], { cwd: "/nonexistent", print: () => {}, io }), /release repo/);
|
|
137
|
+
});
|
|
138
|
+
it("a flag without a value throws instead of being skipped", async () => {
|
|
139
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/s", "--pr"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--pr needs a value/);
|
|
140
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "--pr", "4"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--repo needs a value/);
|
|
141
|
+
});
|
|
142
|
+
it("forge.yaml is read per section: another section's mode or sandbox_repo is not picked up", async () => {
|
|
143
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "selftest:\n fixture: node\nother:\n sandbox_repo: x/y\n");
|
|
144
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd, print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
145
|
+
});
|
|
146
|
+
it("--nightly-report files a failure that happens before the harness starts (no sandbox repo)", async () => {
|
|
147
|
+
const calls = [];
|
|
148
|
+
const gh = async (a) => { calls.push(a); if (a[1] === "list") return "[]"; if (a[1] === "create") return "https://github.com/o/r/issues/5\n"; return a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : ""; };
|
|
149
|
+
const r = await selftestFromCli(["--live", "--nightly-report", "o/r"], { cwd: "/nonexistent", print: () => {}, io: { gh } });
|
|
150
|
+
assert.equal(r.ok, false); assert.equal(r.step, "config");
|
|
151
|
+
const create = calls.find((a) => a[0] === "issue" && a[1] === "create");
|
|
152
|
+
assert.ok(create && create.includes("o/r")); assert.match(create.join(" "), /step config/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -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/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)"
|
|
@@ -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
|
|
package/docs/CONFIG.md
CHANGED
|
@@ -58,6 +58,7 @@ If you have both a local `claude` CLI and `ANTHROPIC_API_KEY` set and want to pi
|
|
|
58
58
|
| [`verification`](#verification-optional) | No | Health-check patterns |
|
|
59
59
|
| [`execution`](#execution-optional) | No | `forgedock orchestrate`: run lanes in sandboxes instead of host processes (`mode: local\|remote\|hybrid`, `max_remote`) |
|
|
60
60
|
| [`verify`](#verify-optional) | No | Core work-on: run lane verification on CI runners instead of the host (`mode: local\|ci\|hybrid`) |
|
|
61
|
+
| [`selftest`](#selftest-optional) | No | `forgedock selftest --live`: the disposable sandbox repo and fixture language of the live contract harness |
|
|
61
62
|
| [`billing`](#billing-optional) | No | Enable financial integrity audit phase |
|
|
62
63
|
| [`adaptive_scripts`](#adaptive_scripts-optional) | No | Per-repo script override configuration |
|
|
63
64
|
|
|
@@ -658,6 +659,27 @@ prod_watch:
|
|
|
658
659
|
|
|
659
660
|
---
|
|
660
661
|
|
|
662
|
+
## `selftest` (OPTIONAL)
|
|
663
|
+
|
|
664
|
+
Settings for `forgedock selftest --live` (see [SELFTEST.md](SELFTEST.md)): a harness that drives the real `gh`, git and `orchestrate` against a disposable sandbox repo, and the `FORGE:SELFTEST` result the staging->main review requires.
|
|
665
|
+
|
|
666
|
+
```yaml
|
|
667
|
+
selftest:
|
|
668
|
+
sandbox_repo: acme/forgedock-sandbox # disposable repo; the harness creates and closes issues, PRs and branches in it
|
|
669
|
+
fixture: node # node (default) | python
|
|
670
|
+
nightly_interval_hours: 24 # opt-in: schedd runs `selftest --live` this often (default off)
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
| Key | Type | Required | Description |
|
|
674
|
+
|-----|------|----------|-------------|
|
|
675
|
+
| `sandbox_repo` | string | No | `owner/repo` the live harness runs in. `--repo` overrides it; with neither the command stops. Never point it at a real project. Setting it also turns on the release gate: `forgedock review-staging` then requires a passing `FORGE:SELFTEST` marker for the release PR head; unset, the gate is skipped. |
|
|
676
|
+
| `fixture` | string | No | Toolchain of the two trivial fixture issues: `node` (default) or `python`. `--lang` overrides it. |
|
|
677
|
+
| `nightly_interval_hours` | number | No | Off when unset or 0. When positive, the schedd daemon (host, never GitHub Actions) starts a detached `selftest --live` against `sandbox_repo` once this many hours have passed since the last run of that repo. On failure, including a run that cannot start, it files or edits one issue naming the failed step, in the repo schedd resolves for its working directory. See [SELFTEST.md](SELFTEST.md#nightly-run). |
|
|
678
|
+
|
|
679
|
+
**Commands that use this section**: `forgedock selftest`, `forgedock review-staging` (release gate, only when `sandbox_repo` is set)
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
661
683
|
## `issue_watch` (OPTIONAL)
|
|
662
684
|
|
|
663
685
|
Named, scoped, capped watches for `forgedock issue-watch` (see [ISSUE-WATCH.md](ISSUE-WATCH.md)). Write `issue_watch:`, then `watches:`, then one block per watch name. The release keys need `mode: release` and at least a `threshold` or `immediate`.
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Operator-facing notes for behavior changes that need action or awareness. Newest first.
|
|
4
4
|
|
|
5
|
+
## Unreleased: `forgedock selftest`, opt-in release gate and nightly run; gate-marker check ignored
|
|
6
|
+
|
|
7
|
+
- **New command** `forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--pr N]`. It runs a live contract harness against a disposable sandbox repo, plus a static check of every `gh --json` field list. See [SELFTEST.md](SELFTEST.md).
|
|
8
|
+
- **Opt-in nightly run:** `selftest.nightly_interval_hours` makes the schedd daemon start a detached live selftest on that schedule and file one issue on failure. Restart schedd after upgrading to pick it up.
|
|
9
|
+
- **Gate-marker check ignored:** the `Check for FORGE gate markers` check is no longer read as a CI failure by `review-staging`, `/review-pr-staging` or `/deploy-pr`. It only echoes the latest gate marker, so after a GATE_FAILURE it blocked its own GATE_PASS (#4941).
|
|
10
|
+
|
|
11
|
+
### Live selftest gates the staging->main release review (when a sandbox repo is set)
|
|
12
|
+
|
|
13
|
+
When forge.yaml sets `selftest.sandbox_repo`, `forgedock review-staging` stops with `REVIEW_RESULT: status: BLOCKED` unless the staging->main PR carries a `FORGE:SELFTEST result=pass` comment for its current head commit (a missing, failing, malformed or stale marker refuses). Run `forgedock selftest --live --repo <owner/sandbox> --pr <N>` (sandbox repo or `selftest.sandbox_repo` in forge.yaml, see [CONFIG.md](CONFIG.md#selftest-optional)) after the last push to the release PR; it posts the marker. Without `selftest.sandbox_repo` the gate is skipped (the CLI prints `SELFTEST: skipped` on stderr), so repos without a sandbox are not blocked. Details: [SELFTEST.md](SELFTEST.md).
|
|
14
|
+
|
|
5
15
|
## v1.30.0: environment failures are recoverable, scoped pre-PR checks, decomposed parents can close
|
|
6
16
|
|
|
7
17
|
- **New recoverable codes.** A validate command the host cannot run (missing interpreter, module or executable) ends `env.tool_missing`, and a repro test the harness could not run on head ends `env.repro_unrunnable`. Both were terminal `conformance.unimplemented` before. The batch runner recovers them like `ci.red` (see docs/OUTCOME-CODES.md).
|
package/docs/SELFTEST.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Selftest
|
|
2
|
+
|
|
3
|
+
The engine's unit tests mock `gh`, git and the pool transport, so defects such as #4859 (`authorAssociation` is not a gh field), #4860 (issue body wiped to `FORGE:STATE`), #4817 and #4818 passed CI. `forgedock selftest` checks the real binaries.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
forgedock selftest --static
|
|
7
|
+
forgedock selftest --live [--repo <owner/sandbox>] [--lang node|python] [--pr <staging->main PR>]
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`--live` also runs the static check. The sandbox repo comes from `--repo` or `selftest.sandbox_repo` in forge.yaml ([CONFIG.md](CONFIG.md#selftest-optional)). Run it on the host or the pool, not in GitHub Actions. The exit code is non-zero and the output names the failed step.
|
|
11
|
+
|
|
12
|
+
## Static check
|
|
13
|
+
|
|
14
|
+
Every literal `gh issue|pr view|list --json a,b` field list in non-test `bin/**/*.mjs` must be a subset of the `JSON FIELDS` of `gh <cmd> --help` on this host. A violation is reported as `file:line`.
|
|
15
|
+
|
|
16
|
+
## Live steps
|
|
17
|
+
|
|
18
|
+
1. **create**: the fixture base files, then issues A and B (B has `**Depends on**: #A`), each a trivial change with a test that fails on the old code.
|
|
19
|
+
2. **add**: `orchestrate A --parallel 2`, then `orchestrate --add <id> B`; B must be added (not skipped or unreadable) and wait on A in the DAG.
|
|
20
|
+
3. **body**: after the add, after the resume and while waiting, each issue body must still contain its creation text (`FORGE:STATE` ignored).
|
|
21
|
+
4. **resume**: the orchestrate client is killed and `--resume`d; the live lane of A must be re-adopted, not launched again.
|
|
22
|
+
5. **merge**: both lanes merge; the PRs are `MERGED`, the issues closed with `workflow:merged`, and A merged before B.
|
|
23
|
+
6. **remote**: with `execution.mode` `remote` or `hybrid`, one lane runs remotely; the sandbox engine version must equal the CLI version and the lane must authenticate. Skipped (noted) when no pool is configured.
|
|
24
|
+
7. **cleanup**: always runs, even after a failure: the orchestrate client is stopped; the issues, PRs and branches are closed or deleted.
|
|
25
|
+
|
|
26
|
+
The first failing step stops the run.
|
|
27
|
+
|
|
28
|
+
## Release gate
|
|
29
|
+
|
|
30
|
+
With `--pr N`, the PR head is read before the run (from `--release-repo`, else the current repo), and the result is posted on the PR as `<!-- FORGE:SELFTEST result=pass|fail sha=<head> step=<failed step|none> -->`. When forge.yaml sets `selftest.sandbox_repo`, `forgedock review-staging` reads the newest marker and refuses (`REVIEW_RESULT: status: BLOCKED`, nothing reviewed) unless it is a pass for the PR's current head. Only markers from trusted commenters count (maintainer association, a GitHub App bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and only when the marker leads the comment. A push after the selftest makes the marker stale: re-run it. The harness refuses a sandbox repo that is the project repo (forge.yaml `project`) or the release repo. Without `selftest.sandbox_repo` the gate is skipped and the CLI prints `SELFTEST: skipped` on stderr.
|
|
31
|
+
|
|
32
|
+
## Nightly run
|
|
33
|
+
|
|
34
|
+
Off by default. Set `selftest.nightly_interval_hours` (see [CONFIG.md](CONFIG.md#selftest-optional)) and the schedd daemon starts `selftest --live --nightly-report <repo>` on the host, as a detached process, once that many hours have passed since that repo's last run (last run time cached per repo in `selftest-nightly-<owner>_<repo>.json` under the sched dir). The daemon never runs the harness itself, so a long run never holds its other sweeps. It never runs on GitHub Actions. A failing run, including one that cannot start (for example no `selftest.sandbox_repo`, step `config`) or a harness crash, files one issue titled `selftest --live failed at step <step> (nightly)` carrying `<!-- FORGE:SELFTEST_NIGHTLY -->`; the next failure edits that open issue instead of filing another. A passing run writes nothing. The issue lives in the repo schedd resolves for the working directory, and the sandbox repo must be set by `selftest.sandbox_repo`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|