forgedock 1.40.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/outcome-codes.mjs +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -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/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
|
@@ -35,7 +35,17 @@ function world({ alerts = [], issues = [], probe = 0, killed = false, llmCost =
|
|
|
35
35
|
now: () => NOW, exists: () => killed, emit: (r) => w.emits.push(r), page: (p) => { w.pages.push(p); return true; },
|
|
36
36
|
exec: async (cmd) => { w.execs.push(cmd); return cmd === "am-alerts" ? { code: 0, stdout: JSON.stringify(alerts) } : { code: probe, stdout: "" }; },
|
|
37
37
|
llm: async () => { w.llm++; return { text: "Hypothesis: pool exhausted.", cost_usd: llmCost }; },
|
|
38
|
-
gh: async (a) => {
|
|
38
|
+
gh: async (a) => {
|
|
39
|
+
w.gh.push(a);
|
|
40
|
+
if (a[0] === "api") {
|
|
41
|
+
const open = issues.filter((i) => i.state === "OPEN").sort((x, y) => y.createdAt.localeCompare(x.createdAt))
|
|
42
|
+
.map((i) => ({ number: i.number, state: "open", state_reason: i.stateReason ?? null, body: i.body, created_at: i.createdAt, closed_at: null }));
|
|
43
|
+
const pages = []; for (let k = 0; k < open.length; k += 100) pages.push(open.slice(k, k + 100));
|
|
44
|
+
if (pages.length) pages[0].push({ number: 9999, state: "open", body: "a PR", pull_request: {}, created_at: "2026-10-31T00:00:00Z" });
|
|
45
|
+
return JSON.stringify(pages.length ? pages : [[]]);
|
|
46
|
+
}
|
|
47
|
+
if (a[1] === "list") return JSON.stringify(issues.filter((i) => i.state === "CLOSED").slice(0, Number(a[a.indexOf("--limit") + 1])));
|
|
48
|
+
return `https://github.com/o/r/issues/${100 + w.gh.length}\n`; },
|
|
39
49
|
};
|
|
40
50
|
return w;
|
|
41
51
|
}
|
|
@@ -95,7 +105,7 @@ describe("roundOnce", () => {
|
|
|
95
105
|
const state = {};
|
|
96
106
|
const r = await roundOnce({ cfg, io: w.io, state, dryRun: true });
|
|
97
107
|
assert.equal(r.would_file.length, 1);
|
|
98
|
-
assert.equal(w.gh.every((a) => a[1] === "list"), true);
|
|
108
|
+
assert.equal(w.gh.every((a) => a[1] === "list" || a[0] === "api"), true);
|
|
99
109
|
assert.equal(w.llm, 0); assert.equal(w.pages.length, 0); assert.equal(w.emits.length, 0);
|
|
100
110
|
assert.deepEqual(state.filed, []);
|
|
101
111
|
});
|
|
@@ -271,11 +281,30 @@ describe("roundOnce", () => {
|
|
|
271
281
|
});
|
|
272
282
|
it("files nothing when the issue list fails (no blind duplicates)", async () => {
|
|
273
283
|
const w = world({ alerts: [alert("Slow", "P2")] });
|
|
274
|
-
const orig = w.io.gh; w.io.gh = async (a) => { if (a[
|
|
284
|
+
const orig = w.io.gh; w.io.gh = async (a) => { if (a[0] === "api") throw new Error("rate limited"); return orig(a); };
|
|
275
285
|
const r = await roundOnce({ cfg, io: w.io, state: {} });
|
|
276
286
|
assert.equal(r.filed.length, 0);
|
|
277
287
|
assert.match(r.errors[0], /nothing filed/);
|
|
278
288
|
});
|
|
289
|
+
it("still files when only the closed lookup fails", async () => {
|
|
290
|
+
const w = world({ alerts: [alert("Slow", "P2")] });
|
|
291
|
+
const orig = w.io.gh; w.io.gh = async (a) => { if (a[1] === "list") throw new Error("boom"); return orig(a); };
|
|
292
|
+
const r = await roundOnce({ cfg, io: w.io, state: {} });
|
|
293
|
+
assert.equal(r.filed.length, 1);
|
|
294
|
+
assert.match(r.errors.join(), /closed issue list failed/);
|
|
295
|
+
});
|
|
296
|
+
it("sees an open issue older than 200 newer ones: no duplicate filed", async () => {
|
|
297
|
+
const filed = world({ alerts: [alert("Slow", "P2")] });
|
|
298
|
+
await roundOnce({ cfg, io: filed.io, state: {} });
|
|
299
|
+
const c = creates(filed)[0], oldBody = c[c.indexOf("--body") + 1];
|
|
300
|
+
const mk = (n) => ({ number: n, state: "OPEN", body: "unrelated", createdAt: `2026-10-31T00:${String(Math.floor((n - 1000) / 60)).padStart(2, "0")}:${String((n - 1000) % 60).padStart(2, "0")}Z` });
|
|
301
|
+
const issues = [...Array.from({ length: 250 }, (_, k) => mk(1000 + k)), { number: 5, state: "OPEN", body: oldBody, createdAt: "2026-10-01T00:00:00Z" }];
|
|
302
|
+
const w = world({ alerts: [alert("Slow", "P2")], issues });
|
|
303
|
+
const r = await roundOnce({ cfg, io: w.io, state: {} });
|
|
304
|
+
assert.equal(creates(w).length, 0);
|
|
305
|
+
assert.equal(r.filed.length, 0);
|
|
306
|
+
assert.ok(w.gh.some((a) => a.includes("--paginate")));
|
|
307
|
+
});
|
|
279
308
|
});
|
|
280
309
|
|
|
281
310
|
describe("helpers and CLI", () => {
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { reviewSkeleton } from "./conformance.mjs";
|
|
|
7
7
|
import { seqNumber } from "./sequence.mjs";
|
|
8
8
|
import { capText, FIX_CAPS, FIX_PROMPT_MAX } from "./fixround.mjs";
|
|
9
9
|
import { SPLIT_MIN_FILES, SPLIT_MIN_LINES } from "./decompose.mjs";
|
|
10
|
+
import { isTrustedIssueComment } from "./prefilter.mjs"; // call-time use only: prefilter.mjs imports this module
|
|
10
11
|
|
|
11
12
|
export const approxTokens = (s) => Math.ceil(String(s).length / 4);
|
|
12
13
|
export const LEAN_DIR = ".forge-lean";
|
|
@@ -424,8 +425,26 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
|
|
|
424
425
|
+ (list.length ? `\n<omitted_files>\n${list.join("\n")}\n</omitted_files>` : "");
|
|
425
426
|
}
|
|
426
427
|
|
|
428
|
+
const isDecision = (b) => /^<!-- FORGE:DECISION\b/.test(String(b ?? "").trim());
|
|
429
|
+
const DECISION_NOTE_MAX = 4000;
|
|
430
|
+
/**
|
|
431
|
+
* #4901: the trusted `FORGE:DECISION` comments posted after the last `FORGE:CONTRACT` comment (issue order), as a binding note for a
|
|
432
|
+
* lane that resumes a frozen contract without a plan session. The lane's own recovery records are skipped; the newest decisions survive the cap.
|
|
433
|
+
* "" when there are none.
|
|
434
|
+
*/
|
|
435
|
+
export function decisionNote(comments = []) {
|
|
436
|
+
const list = Array.isArray(comments) ? comments : [];
|
|
437
|
+
const at = list.map((c) => String(c?.body ?? "").trimStart().startsWith("<!-- FORGE:CONTRACT")).lastIndexOf(true);
|
|
438
|
+
const ds = list.slice(at + 1).filter((c) => isTrustedIssueComment(c) && isDecision(c?.body) && !/^##\s*Decision: recovery pass/m.test(String(c.body))).map((c) => String(c.body).trim());
|
|
439
|
+
let kept = [], n = 0;
|
|
440
|
+
for (const d of ds.reverse()) { if (n + d.length > DECISION_NOTE_MAX && kept.length) break; kept.unshift(d.length > DECISION_NOTE_MAX ? d.slice(0, DECISION_NOTE_MAX) : d); n += d.length; }
|
|
441
|
+
return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
|
|
442
|
+
}
|
|
443
|
+
|
|
427
444
|
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
|
|
428
445
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
446
|
+
const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
|
|
447
|
+
notes.push(...decisions);
|
|
429
448
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
430
449
|
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
431
450
|
}
|
package/bin/lean/render.mjs
CHANGED
|
@@ -140,8 +140,16 @@ export function parseContractComment(body) {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** Typed amendments, posted visibly before review. */
|
|
143
|
-
export function renderAmendments(applied, filed = {}) {
|
|
144
|
-
|
|
143
|
+
export function renderAmendments(applied, filed = {}, state = null) {
|
|
144
|
+
const block = state ? `\n\n<!-- FORGE:AMENDMENT-STATE ${JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e")} -->` : "";
|
|
145
|
+
return `## Contract Amendments\n| Kind | Target | Why |\n| --- | --- | --- |\n${applied.map((a) => `| ${esc(a.kind)} | ${esc(a.target)}${filed[a.target] ? ` → ${filed[a.target]}` : ""} | ${esc(a.why)} |`).join("\n")}${block}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** #4899: the raw amendments a FORGE:AMENDMENT comment carries, `{contract, amendments}` (contract = frozen hash), or null. */
|
|
149
|
+
export function parseAmendmentsComment(body) {
|
|
150
|
+
const m = /<!-- FORGE:AMENDMENT-STATE (.*?) --!?>/.exec(String(body ?? ""));
|
|
151
|
+
if (!m) return null;
|
|
152
|
+
try { const j = JSON.parse(m[1]); return typeof j?.contract === "string" && Array.isArray(j.amendments) ? j : null; } catch { return null; }
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
/** FORGE:REVIEW for a conforming change: the engine-computed verdict and the reviewer's rows. */
|
|
@@ -373,3 +373,51 @@ describe("remote stop at the checkpoint (#4612)", () => {
|
|
|
373
373
|
assert.deepEqual(transport.destroyed, []);
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
describe("remote provider: warm sandboxes (#4912)", () => {
|
|
378
|
+
const warmFixture = ({ warm, claim } = {}) => {
|
|
379
|
+
const work = tmpDir("contract-warm-"); const batch = openBatch(join(work, "batches"), "bt");
|
|
380
|
+
const transport = fakeTransport();
|
|
381
|
+
const calls = [];
|
|
382
|
+
if (claim) transport.claim = async (spec) => { calls.push(spec); return claim(spec); };
|
|
383
|
+
const provider = remoteProvider({ batch, repo: "o/r", settings: { image: "img:1", secrets: ["GH_TOKEN"], ...(warm !== undefined ? { warmSandboxes: warm } : {}) }, transport,
|
|
384
|
+
env: { GH_TOKEN: SECRET }, credentials: () => null, gitIdentity: () => ({}) });
|
|
385
|
+
return { provider, transport, calls };
|
|
386
|
+
};
|
|
387
|
+
const start = (provider) => provider.start({ issue: 7, args: ["--repo", "o/r", "--lane", "staging"], env: { GH_TOKEN: SECRET }, secrets: ["GH_TOKEN"] });
|
|
388
|
+
|
|
389
|
+
it("warm_sandboxes > 0: a ready warm sandbox is claimed and no cold launch happens", async () => {
|
|
390
|
+
const { provider, transport, calls } = warmFixture({ warm: 2, claim: async () => ({ id: "fdrsbx-bt-7" }) });
|
|
391
|
+
const h = await start(provider);
|
|
392
|
+
assert.equal(h.id, "fdrsbx-bt-7");
|
|
393
|
+
assert.equal(calls.length, 1);
|
|
394
|
+
assert.equal(calls[0].warm, 2);
|
|
395
|
+
assert.equal(transport.specs.length, 0, "no cold launch");
|
|
396
|
+
});
|
|
397
|
+
it("warm_sandboxes > 0: none ready (null) or a claim error falls back to the cold launch", async () => {
|
|
398
|
+
for (const claim of [async () => null, async () => { throw new Error("sandbox claim: HTTP 500"); }]) {
|
|
399
|
+
const { provider, transport } = warmFixture({ warm: 1, claim });
|
|
400
|
+
await start(provider);
|
|
401
|
+
assert.equal(transport.specs.length, 1);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
it("default (0 or unset): claim is never called and the spec carries no warm key", async () => {
|
|
405
|
+
for (const warm of [undefined, 0]) {
|
|
406
|
+
const { provider, transport, calls } = warmFixture({ warm, claim: async () => ({}) });
|
|
407
|
+
await start(provider);
|
|
408
|
+
assert.equal(calls.length, 0);
|
|
409
|
+
assert.equal("warm" in transport.specs[0], false);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
it("a transport without claim is a cold start", async () => {
|
|
413
|
+
const { provider, transport } = warmFixture({ warm: 1 });
|
|
414
|
+
await start(provider);
|
|
415
|
+
assert.equal(transport.specs.length, 1);
|
|
416
|
+
});
|
|
417
|
+
it("httpTransport.claim: 200 = claimed, 404/204/405 = none, 500 throws", async () => {
|
|
418
|
+
const mk = (status, data) => httpTransport({ endpoint: "http://p", fetchFn: async () => ({ status, ok: status < 300, text: async () => (data ? JSON.stringify(data) : ""), headers: { get: () => null } }) });
|
|
419
|
+
assert.deepEqual(await mk(200, { id: "x" }).claim({ id: "x" }), { id: "x" });
|
|
420
|
+
for (const s of [204, 404, 405]) assert.equal(await mk(s).claim({ id: "x" }), null);
|
|
421
|
+
await assert.rejects(() => mk(500).claim({ id: "x" }), /HTTP 500/);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -69,6 +69,8 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
69
69
|
const p = (id) => `/sandboxes/${encodeURIComponent(id)}`;
|
|
70
70
|
return {
|
|
71
71
|
async launch(spec) { const r = await call("POST", "/sandboxes", spec); if (!r.ok) throw fail("launch", r); return r.data ?? {}; },
|
|
72
|
+
/** #4912: hand the lane to a ready warm sandbox. 200/201 = claimed; 204/404/405 (none ready, or a pool without warm sandboxes) = null. */
|
|
73
|
+
async claim(spec) { const r = await call("POST", "/sandboxes/claim", spec); if (r.status === 200 || r.status === 201) return r.data ?? {}; if (r.status === 204 || r.status === 404 || r.status === 405) return null; throw fail("claim", r); },
|
|
72
74
|
async status(id) {
|
|
73
75
|
const r = await call("GET", p(id));
|
|
74
76
|
if (r.status === 404) return "gone";
|
|
@@ -185,16 +187,21 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
185
187
|
const li = args.indexOf("--lane");
|
|
186
188
|
const resultFile = `/result/${issue}.json`;
|
|
187
189
|
const relayEnv = relays.length ? { CS_RELAY_COMMANDS: relayCommandNames(relay).join(","), CS_RELAY_CAPS: relays.join(","), ...(await prefetchEnv(issue)) } : {};
|
|
190
|
+
const warm = Number.isInteger(settings.warmSandboxes) && settings.warmSandboxes > 0 ? settings.warmSandboxes : 0;
|
|
188
191
|
const spec = {
|
|
189
192
|
id, issue, batch: batch.id, image: settings.image ?? null,
|
|
190
193
|
repo: { slug: repo, url: repo ? `https://github.com/${repo}.git` : null, ref: li >= 0 ? args[li + 1] ?? null : null },
|
|
194
|
+
...(warm ? { warm } : {}), // #4912: the pool agent keeps this many warm sandboxes for the repo
|
|
191
195
|
cache: settings.cacheStore ? { store: settings.cacheStore, key: safeSegment(String(repo ?? "repo")) } : null,
|
|
192
196
|
// The host's git identity: without it the lane image falls back to a placeholder author that fails DCO.
|
|
193
197
|
env: { ...gitIdentityEnv(gitIdentity(), laneEnv ?? {}), ...scopedEnv({ env: resolved, secrets, batchId: batch.id, repo }), ...(settings.forgeYaml ? { CS_FORGE_YAML_B64: Buffer.from(settings.forgeYaml).toString("base64") } : {}), ...relayEnv }, // #4546: the dispatcher's forge.yaml (gitignored in the repo)
|
|
194
198
|
command: ["forgedock", "run-issue", String(issue), ...args, "--result-file", resultFile], result_file: resultFile,
|
|
195
199
|
};
|
|
196
200
|
const startedAt = now();
|
|
197
|
-
|
|
201
|
+
// #4912: a ready warm sandbox first; none, an error or a transport without claim is the cold start.
|
|
202
|
+
let claimed = null;
|
|
203
|
+
if (warm && typeof transport.claim === "function") { try { claimed = await transport.claim(spec); } catch { claimed = null; } }
|
|
204
|
+
if (!claimed) await transport.launch(spec);
|
|
198
205
|
return { provider: name, id, issue, startedAt, minutes: 0 };
|
|
199
206
|
},
|
|
200
207
|
async status(h) {
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -130,7 +130,7 @@ function takeLock(lock, pid, alive) {
|
|
|
130
130
|
*/
|
|
131
131
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
132
132
|
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
133
|
-
lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS } = {}) {
|
|
133
|
+
lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
|
|
134
134
|
const p = scheddPaths(dir);
|
|
135
135
|
mkdirSync(dir, { recursive: true });
|
|
136
136
|
const lock = takeLock(p.lock, pid, alive);
|
|
@@ -318,6 +318,10 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
318
318
|
}, dueMs) : null;
|
|
319
319
|
dueTimer?.unref?.();
|
|
320
320
|
|
|
321
|
+
// #4866: lane preflight at daemon start: each failing check is logged once with its typed code (batches re-check on every admission pass).
|
|
322
|
+
// Not awaited: gh and pool pings are slow, and startup (resume, socket) must not wait on them.
|
|
323
|
+
if (preflight) Promise.resolve().then(preflight).then((fails) => { for (const f of fails ?? []) log(`schedd: ${f.code}: ${f.detail}`); }).catch(() => { /* advisory */ });
|
|
324
|
+
|
|
321
325
|
// #4787: plans written before secrets were dropped still hold token values; scrub them on start.
|
|
322
326
|
for (const id of listBatches(root)) { try { const n = openBatch(root, id).scrubPlan(); if (n.length) log(`schedd: scrubbed ${n.join(", ")} from batch ${id}`); } catch { /* advisory */ } }
|
|
323
327
|
|
|
@@ -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
|
+
}
|