forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Named post-deploy checks (#4626). A post-deploy requirement names a check id (`[check:<id>]` in its follow-up item);
|
|
5
|
+
* the command lives in the checks file on the default branch, `.forge/post-deploy-checks.json`:
|
|
6
|
+
* { "<id>": { "run": "sh command", "pass": "exit 0 | /regex/flags", "timeout": 120 } } (a bare string is `run`)
|
|
7
|
+
* and is read with `git show origin/<default>:<file>`: reviewed default-branch code, never an issue body or comment
|
|
8
|
+
* (#4468). When the #4389 gate reports a follow-up ready, `measureFollowup` runs every item's check (no model call, the
|
|
9
|
+
* probe runner's minimal env): all pass -> a FORGE:PROBE record and the follow-up closes; a fail -> the record, and the
|
|
10
|
+
* parent reopens with the output. An item without a runnable check is `awaiting-measurement`: no lane, ever.
|
|
11
|
+
*/
|
|
12
|
+
import { noComments } from "./render.mjs";
|
|
13
|
+
import { MAX_TIMEOUT_S, OUTPUT_TAIL, PROBE_MARKER, probeEnv, probeResultOf, readProbeConfig, runProbe } from "./probe.mjs";
|
|
14
|
+
import { parseMinAge } from "./prefilter.mjs";
|
|
15
|
+
|
|
16
|
+
export const CHECKS_FILE = ".forge/post-deploy-checks.json";
|
|
17
|
+
export const CHECK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
18
|
+
export const AWAITING_MEASUREMENT_LABEL = "awaiting-measurement";
|
|
19
|
+
export const MONITORING_LABEL = "post-deploy-monitoring";
|
|
20
|
+
export const OWNER_ASK_MARKER = "FORGE:OWNER_ASK"; // #4689
|
|
21
|
+
export const DEFAULT_EVERY_MS = 6 * 3600e3;
|
|
22
|
+
const ITEM_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
23
|
+
const CHECK_TOKEN_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:\s*\[check:\s*([^\]\s]*)\s*\]/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The checks file's runnable entries `{id: {run, pass, timeoutS, forMs?, everyMs?}}`; malformed input or entries are dropped.
|
|
27
|
+
* `for` (a window such as "7d") makes the check windowed (#4627); `every` (default 6h) is its cadence and needs a `for`.
|
|
28
|
+
*/
|
|
29
|
+
export function parseChecks(text) {
|
|
30
|
+
let j;
|
|
31
|
+
try { j = JSON.parse(String(text ?? "")); } catch { return {}; }
|
|
32
|
+
if (!j || typeof j !== "object" || Array.isArray(j)) return {};
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [id, v] of Object.entries(j)) {
|
|
35
|
+
const spec = typeof v === "string" ? { run: v } : v;
|
|
36
|
+
if (!CHECK_ID_RE.test(id) || !spec || typeof spec.run !== "string" || !spec.run.trim()) continue;
|
|
37
|
+
let pass = { kind: "exit" };
|
|
38
|
+
const p = String(spec.pass ?? "").trim();
|
|
39
|
+
if (p && !/^exit\s+0$/i.test(p)) {
|
|
40
|
+
const r = /^\/(.+)\/([a-z]*)$/.exec(p);
|
|
41
|
+
try { if (!r) continue; new RegExp(r[1], r[2]); } catch { continue; }
|
|
42
|
+
pass = { kind: "regex", re: r[1], flags: r[2] };
|
|
43
|
+
}
|
|
44
|
+
const t = spec.timeout == null ? null : Number(spec.timeout);
|
|
45
|
+
if (t != null && !(Number.isFinite(t) && t > 0)) continue;
|
|
46
|
+
const win = spec.for == null ? null : parseMinAge(spec.for);
|
|
47
|
+
const cad = spec.every == null ? null : parseMinAge(spec.every);
|
|
48
|
+
if ((spec.for != null && !win) || (spec.every != null && !cad) || (spec.every != null && spec.for == null)) continue;
|
|
49
|
+
out[id] = { run: spec.run.trim(), pass, timeoutS: t == null ? null : Math.min(Math.floor(t) || 1, MAX_TIMEOUT_S), ...(win ? { forMs: win, everyMs: cad ?? DEFAULT_EVERY_MS } : {}) };
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Per checklist item of a follow-up body: its `[check:<id>]` id, or null when it names none (or a malformed one). */
|
|
55
|
+
export function itemCheckIds(body) {
|
|
56
|
+
return noComments(String(body ?? ""), " ").split("\n").map((l) => ITEM_RE.exec(l)?.[1]).filter((t) => t != null)
|
|
57
|
+
.map((t) => { const m = CHECK_TOKEN_RE.exec(t); return m && CHECK_ID_RE.test(m[1]) ? m[1] : null; });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The checks file at each git ref (`git show <ref>:<file>`); a missing file or ref contributes nothing. */
|
|
61
|
+
export async function checksAt(git, refs, cwd) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const ref of refs) {
|
|
64
|
+
try { Object.assign(out, parseChecks(String(await git(["show", `${ref}:${CHECKS_FILE}`], { cwd })))); } catch { /* no file at this ref */ }
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
|
|
70
|
+
|
|
71
|
+
/** Run each distinct check id with the probe runner (minimal env, timeout, masked output): `[{id, result, exit, ms, output}]`. */
|
|
72
|
+
export async function runNamedChecks({ ids, checks, cwd, config, env, spawn }) {
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (const id of [...new Set(ids)]) {
|
|
75
|
+
const r = await runProbe(checks[id], { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
|
|
76
|
+
rows.push({ id, ...r });
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The windowed-monitoring fields of a FORGE:PROBE record body (`at`, `window_until`, `every_ms`, `regression`, ...), or null. */
|
|
82
|
+
export function monitorRecord(body) {
|
|
83
|
+
const base = probeResultOf(body);
|
|
84
|
+
if (!base) return null;
|
|
85
|
+
const line = String(body).split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
|
|
86
|
+
try {
|
|
87
|
+
const j = JSON.parse(line.trim().replace(/^`|`$/g, ""));
|
|
88
|
+
const at = Date.parse(j.at), until = Date.parse(j.window_until);
|
|
89
|
+
return Number.isFinite(at) ? { ...base, at, defaultSha: j.default_sha ?? null, everyMs: Number(j.every_ms) || DEFAULT_EVERY_MS, until: Number.isFinite(until) ? until : null, regression: j.regression ?? null } : null;
|
|
90
|
+
} catch { return null; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The post-deploy-monitoring label, created on first use; best effort. */
|
|
94
|
+
export async function addMonitoringLabel({ io, repo, issue }) {
|
|
95
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", MONITORING_LABEL]);
|
|
96
|
+
try { await edit(); } catch {
|
|
97
|
+
try { await io.gh(["label", "create", MONITORING_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up whose windowed check re-runs on later releases. Managed by ForgeDock."]); await edit(); } catch { /* the record still shows it */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** #4689: tick every unticked post-deploy checklist item of a follow-up body (the evidence is in the FORGE:PROBE comment). */
|
|
102
|
+
export const tickItems = (body) => String(body ?? "").replace(/^(\s*[-*]\s+)\[ \](\s+`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:)/gm, "$1[x]$2");
|
|
103
|
+
|
|
104
|
+
/** #4689: one typed `FORGE:OWNER_ASK` comment for the items with no runnable check, posted once; best effort, nothing in a dry run. */
|
|
105
|
+
async function postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def }) {
|
|
106
|
+
if (dryRun || (iss.comments ?? []).some((c) => isTrusted(c) && String(c?.body ?? "").includes(OWNER_ASK_MARKER))) return;
|
|
107
|
+
const what = ids.length ? missing.map((m) => (m ? `add \`${m}\` to ${CHECKS_FILE} on ${def}` : "name an item's check with `[check:<id>]`")).join("; ") : "name each item's check with `[check:<id>]` and add it to the checks file";
|
|
108
|
+
try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", `<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started and none will be. Needed from the owner: ${what}, or measure the item by hand.`]); } catch { /* the label still shows it */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** #4689: one engineering issue per failing check (never a lane): the output, the fix and the follow-up. Best effort. */
|
|
112
|
+
async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
|
|
113
|
+
for (const r of bad) {
|
|
114
|
+
const title = `Post-deploy check ${r.id} fails on ${def}${parent ? ` (fix of #${parent})` : ""}`;
|
|
115
|
+
const body = `Post-deploy check \`${r.id}\` failed (exit ${r.exit ?? "timeout"}) for fix \`${fix.slice(0, 12)}\` on \`${def}\`. Follow-up: #${iss.number}.\n\n${fenced(r.output)}`.slice(0, OUTPUT_TAIL * 2);
|
|
116
|
+
const create = (extra) => io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body, ...extra]);
|
|
117
|
+
try { await create(["--label", "followup:engineering"]); } catch { try { await create([]); } catch { /* the follow-up comment carries the output */ } }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Measure a ready post-deploy follow-up. Never runs a command that is not in the default branch's checks file.
|
|
123
|
+
* @returns {Promise<{state:"awaiting-measurement"|"pass"|"fail"|"recorded-fail"|"dry-run"|"monitoring", detail:string}>}
|
|
124
|
+
* When every item's check declares a window (#4627) a pass records the release and the window end and the follow-up stays
|
|
125
|
+
* open (`monitoring`); `sweepDue` (postdeploy-monitor.mjs) re-runs it.
|
|
126
|
+
*/
|
|
127
|
+
export async function measureFollowup({ iss, repo, parent, def, shas = [], cwd, io, dryRun = false, isTrusted = () => false, config = readProbeConfig(cwd), env = process.env, spawn, now = () => Date.now() }) {
|
|
128
|
+
const ids = itemCheckIds(iss.body);
|
|
129
|
+
const checks = await checksAt(io.git, [`origin/${def}`], cwd);
|
|
130
|
+
const missing = ids.filter((id) => !id || !checks[id]);
|
|
131
|
+
if (!ids.length || missing.length) {
|
|
132
|
+
await postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def });
|
|
133
|
+
return { state: "awaiting-measurement", detail: `no runnable check on ${def} for ${ids.length ? `${missing.map((m) => m ?? "an item without a check id").join(", ")}` : "any item"} (${CHECKS_FILE})` };
|
|
134
|
+
}
|
|
135
|
+
const fix = [...shas].sort().join(",");
|
|
136
|
+
const prior = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => probeResultOf(c?.body)).filter(Boolean).reverse().find((p) => p.fix === fix);
|
|
137
|
+
if (prior?.result === "fail") return { state: "recorded-fail", detail: `check failed for ${fix.slice(0, 12)} (FORGE:PROBE); waiting for a new fix of #${parent}` };
|
|
138
|
+
const windowed = ids.every((id) => checks[id].forMs);
|
|
139
|
+
const monitored = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => monitorRecord(c?.body)).filter(Boolean).find((p) => p.fix === fix && p.result === "pass" && p.until);
|
|
140
|
+
if (windowed && monitored) {
|
|
141
|
+
await addMonitoringLabel({ io, repo, issue: iss.number }); // retry a label that failed on the first pass; sweepDue selects by it
|
|
142
|
+
return { state: "monitoring", detail: `windowed checks ${[...new Set(ids)].join(", ")} monitored until ${new Date(monitored.until).toISOString()}` };
|
|
143
|
+
}
|
|
144
|
+
if (dryRun) return { state: "dry-run", detail: `checks ${[...new Set(ids)].join(", ")} not run (dry run)` };
|
|
145
|
+
let defaultSha = null;
|
|
146
|
+
try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
|
|
147
|
+
const rows = [];
|
|
148
|
+
if (prior?.result !== "pass") {
|
|
149
|
+
rows.push(...await runNamedChecks({ ids, checks, cwd, config, env, spawn }));
|
|
150
|
+
const ok = rows.every((r) => r.result === "pass");
|
|
151
|
+
const at = now();
|
|
152
|
+
const win = windowed && ok ? { at: new Date(at).toISOString(), window_until: new Date(at + Math.max(...ids.map((id) => checks[id].forMs))).toISOString(), every_ms: Math.min(...ids.map((id) => checks[id].everyMs)) } : {};
|
|
153
|
+
const body = [
|
|
154
|
+
`<!-- ${PROBE_MARKER} -->`, `## Post-deploy check: ${ok ? "pass" : "fail"}`,
|
|
155
|
+
JSON.stringify({ fix, default_sha: defaultSha, result: ok ? "pass" : "fail", ...win, checks: rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms })) }), "",
|
|
156
|
+
`Ran after the fix of #${parent} reached \`${def}\`; commands come from \`${CHECKS_FILE}\` on \`${def}\`.${ok ? (win.at ? ` Monitored until \`${win.window_until}\`, re-run on later releases.` : " The follow-up is closed.") : " The parent is reopened."}`,
|
|
157
|
+
...rows.filter((r) => r.result === "fail").flatMap((r) => ["", `**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output)]),
|
|
158
|
+
].join("\n").slice(0, OUTPUT_TAIL * 4);
|
|
159
|
+
if (!ok) {
|
|
160
|
+
const bad = rows.filter((r) => r.result === "fail");
|
|
161
|
+
// Reopen before recording: the FORGE:PROBE fail record parks later passes (recorded-fail), so it must only exist once the parent is open.
|
|
162
|
+
try { await io.gh(["issue", "reopen", String(parent), "-R", repo]); } catch (e) {
|
|
163
|
+
return { state: "fail", detail: `check ${bad.map((r) => r.id).join(", ")} failed; reopen of #${parent} failed, no record posted so the next pass retries (${String(e?.message ?? e).slice(0, 120)})` };
|
|
164
|
+
}
|
|
165
|
+
await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
|
|
166
|
+
await fileFailureIssues({ bad, iss, repo, io, parent, def, fix }); // #4689
|
|
167
|
+
try {
|
|
168
|
+
await io.gh(["issue", "comment", String(parent), "-R", repo, "--body", `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} failed for the fix on \`${def}\` (follow-up #${iss.number}); reopened.\n\n${bad.map((r) => fenced(r.output)).join("\n\n")}`.slice(0, OUTPUT_TAIL * 4)]);
|
|
169
|
+
} catch { /* the follow-up comment carries the output */ }
|
|
170
|
+
return { state: "fail", detail: `check ${bad.map((r) => r.id).join(", ")} failed; #${parent} reopened` };
|
|
171
|
+
}
|
|
172
|
+
await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
|
|
173
|
+
if (tickItems(iss.body) !== iss.body) { try { await io.gh(["issue", "edit", String(iss.number), "-R", repo, "--body", tickItems(iss.body)]); } catch { /* the PROBE record is the evidence */ } } // #4689
|
|
174
|
+
}
|
|
175
|
+
if (windowed) {
|
|
176
|
+
await addMonitoringLabel({ io, repo, issue: iss.number });
|
|
177
|
+
return { state: "monitoring", detail: `checks ${[...new Set(ids)].join(", ")} passed; monitored on later releases` };
|
|
178
|
+
}
|
|
179
|
+
await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]);
|
|
180
|
+
return { state: "pass", detail: `checks ${[...new Set(ids)].join(", ")} passed` };
|
|
181
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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 { parseChecks, itemCheckIds, measureFollowup, monitorRecord, CHECK_ID_RE, tickItems } from "./postdeploy-check.mjs";
|
|
6
|
+
import { postDeployOnly } from "./prefilter.mjs";
|
|
7
|
+
import { validateContract, applyAmendments, precheck, followupItems } from "./conformance.mjs";
|
|
8
|
+
import { PROBE_MARKER } from "./probe.mjs";
|
|
9
|
+
|
|
10
|
+
const item = (id, why) => `- [ ] \`${id}: some quote here\` — post-deploy: ${why}`;
|
|
11
|
+
const body = (items) => `## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
|
|
12
|
+
const CHECKS = JSON.stringify({ smoke: { run: "echo ok", timeout: 5 }, bad: "echo nope", "bad id!": "x", rx: { run: "echo up", pass: "/up/" }, norun: {} });
|
|
13
|
+
|
|
14
|
+
function world({ file = CHECKS, spawn } = {}) {
|
|
15
|
+
const w = { gh: [], git: [], spawned: [] };
|
|
16
|
+
w.io = {
|
|
17
|
+
gh: async (a) => { w.gh.push(a); return ""; },
|
|
18
|
+
git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } return "deadbeef\n"; },
|
|
19
|
+
};
|
|
20
|
+
w.spawn = async (o) => { w.spawned.push(o.run); return spawn ? spawn(o) : { code: o.run === "echo nope" ? 1 : 0, stdout: o.run === "echo nope" ? "boom" : "ok", stderr: "" }; };
|
|
21
|
+
return w;
|
|
22
|
+
}
|
|
23
|
+
const run = (w, bodyText, extra = {}) => measureFollowup({ iss: { number: 41, body: bodyText, comments: [] }, repo: "o/r", parent: 40, def: "main", shas: ["abc"], cwd: "/repo", io: w.io, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...extra });
|
|
24
|
+
|
|
25
|
+
describe("post-deploy named checks (#4626)", () => {
|
|
26
|
+
it("parses the checks file; malformed entries are dropped", () => {
|
|
27
|
+
const c = parseChecks(CHECKS);
|
|
28
|
+
assert.deepEqual(Object.keys(c).sort(), ["bad", "rx", "smoke"]);
|
|
29
|
+
assert.equal(c.smoke.timeoutS, 5); assert.equal(c.rx.pass.kind, "regex");
|
|
30
|
+
assert.deepEqual(parseChecks("not json"), {});
|
|
31
|
+
assert.ok(CHECK_ID_RE.test("login-smoke") && !CHECK_ID_RE.test("Bad Id"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("postDeployOnly still matches items with and without a [check:id] token", () => {
|
|
35
|
+
assert.ok(postDeployOnly(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])));
|
|
36
|
+
});
|
|
37
|
+
it("extracts [check:id] per item; an item without one yields null", () => {
|
|
38
|
+
assert.deepEqual(itemCheckIds(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])), ["smoke", null]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("pass: runs the default-branch command, records FORGE:PROBE, closes the follow-up, no reopen", async () => {
|
|
42
|
+
const w = world();
|
|
43
|
+
const r = await run(w, body([item("R1", "[check:smoke] x")]));
|
|
44
|
+
assert.equal(r.state, "pass");
|
|
45
|
+
assert.deepEqual(w.spawned, ["echo ok"]);
|
|
46
|
+
assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
|
|
47
|
+
const cm = w.gh.find((a) => a[1] === "comment");
|
|
48
|
+
assert.match(cm[cm.indexOf("--body") + 1], new RegExp(PROBE_MARKER));
|
|
49
|
+
assert.ok(w.gh.some((a) => a[1] === "close" && a[2] === "41"));
|
|
50
|
+
assert.ok(!w.gh.some((a) => a[1] === "reopen"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("fail: reopens the parent with the output and does not close", async () => {
|
|
54
|
+
const w = world();
|
|
55
|
+
const r = await run(w, body([item("R1", "[check:bad] x")]));
|
|
56
|
+
assert.equal(r.state, "fail");
|
|
57
|
+
assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
|
|
58
|
+
assert.ok(w.gh.some((a) => a[1] === "comment" && a[2] === "40" && a[a.indexOf("--body") + 1].includes("boom")));
|
|
59
|
+
assert.ok(!w.gh.some((a) => a[1] === "close"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("no runnable check: awaiting-measurement, nothing runs, no model call", async () => {
|
|
63
|
+
for (const [file, b] of [[CHECKS, body([item("R1", "[check:ghost] x")])], [null, body([item("R1", "[check:smoke] x")])], [CHECKS, body([item("R1", "x"), item("R2", "[check:smoke] y")])]]) {
|
|
64
|
+
const w = world({ file });
|
|
65
|
+
assert.equal((await run(w, b)).state, "awaiting-measurement");
|
|
66
|
+
assert.deepEqual(w.spawned, []); assert.ok(w.gh.every((a) => a[1] === "comment" && /FORGE:OWNER_ASK/.test(a[a.indexOf("--body") + 1])), "only the typed owner ask (#4689)");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a command declared in the issue body never runs", async () => {
|
|
71
|
+
const w = world();
|
|
72
|
+
const b = body([item("R1", "[check:ghost] x")]) + "\n```forge-probe\nrun: rm -rf /\n```\n";
|
|
73
|
+
assert.equal((await run(w, b)).state, "awaiting-measurement");
|
|
74
|
+
assert.deepEqual(w.spawned, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("migration: the six open follow-up shapes (no check id) all park awaiting-measurement", async () => {
|
|
78
|
+
for (const n of [4415, 4421, 4438, 4488, 4495, 4498]) {
|
|
79
|
+
const w = world();
|
|
80
|
+
const r = await run(w, body([item(`R${n % 7}`, "only checkable after the next deploy")]));
|
|
81
|
+
assert.equal(r.state, "awaiting-measurement", `#${n}`);
|
|
82
|
+
assert.deepEqual(w.spawned, []);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a recorded fail for the same fix is not re-run", async () => {
|
|
87
|
+
const w = world();
|
|
88
|
+
const c = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "fail" })}` };
|
|
89
|
+
const r = await run(w, body([item("R1", "[check:smoke] x")]), { iss: { number: 41, body: body([item("R1", "[check:smoke] x")]), comments: [c] }, isTrusted: () => true });
|
|
90
|
+
assert.equal(r.state, "recorded-fail"); assert.deepEqual(w.spawned, []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("contract: post-deploy needs a check id; amendment too; precheck rejects an unknown id; followup item names it", () => {
|
|
94
|
+
const Q = "The deploy-time health row must exist after promotion.";
|
|
95
|
+
const c = { requirements: [{ id: "R1", quote: Q, disposition: "post-deploy", why: "w" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] };
|
|
96
|
+
assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
|
|
97
|
+
assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
|
|
98
|
+
const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
|
|
99
|
+
assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
|
|
100
|
+
const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
|
|
101
|
+
assert.equal(ok.contract.requirements[0].check, "smoke");
|
|
102
|
+
const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
|
|
103
|
+
assert.ok(pc.failures.some((f) => f.kind === "check_unknown"));
|
|
104
|
+
assert.match(followupItems([{ ...c.requirements[0], check: "smoke" }])[0].why, /^post-deploy: \[check:smoke\] w/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("windowed post-deploy checks (#4627)", () => {
|
|
109
|
+
const WIN = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }, soakd: { run: "echo ok", for: "2d" }, noFor: { run: "echo ok", every: "6h" }, badFor: { run: "echo ok", for: "soon" }, zero: { run: "echo ok", for: "0" }, plain: "echo ok" });
|
|
110
|
+
const NOW = Date.parse("2026-10-26T00:00:00Z");
|
|
111
|
+
it("parses for/every; every defaults to 6h; every without for or a bad duration drops the entry", () => {
|
|
112
|
+
const c = parseChecks(WIN);
|
|
113
|
+
assert.deepEqual(Object.keys(c).sort(), ["plain", "soak", "soakd"]);
|
|
114
|
+
assert.equal(c.soak.forMs, 7 * 86400e3); assert.equal(c.soak.everyMs, 6 * 3600e3);
|
|
115
|
+
assert.equal(c.soakd.everyMs, 6 * 3600e3);
|
|
116
|
+
assert.equal(c.plain.forMs, undefined);
|
|
117
|
+
});
|
|
118
|
+
it("first pass records the release and window, labels, and keeps the follow-up open", async () => {
|
|
119
|
+
const w = world({ file: WIN });
|
|
120
|
+
const r = await run(w, body([item("R1", "[check:soak] x")]), { now: () => NOW });
|
|
121
|
+
assert.equal(r.state, "monitoring");
|
|
122
|
+
assert.ok(!w.gh.some((a) => a[1] === "close") && !w.gh.some((a) => a[1] === "reopen"));
|
|
123
|
+
assert.ok(w.gh.some((a) => a[1] === "edit" && a.includes("post-deploy-monitoring")));
|
|
124
|
+
const cm = w.gh.find((a) => a[1] === "comment");
|
|
125
|
+
const rec = monitorRecord(cm[cm.indexOf("--body") + 1]);
|
|
126
|
+
assert.equal(rec.result, "pass"); assert.equal(rec.until, NOW + 7 * 86400e3); assert.equal(rec.everyMs, 6 * 3600e3); assert.equal(rec.defaultSha, "deadbeef");
|
|
127
|
+
});
|
|
128
|
+
it("a recorded windowed pass is not re-measured by the pre-filter path", async () => {
|
|
129
|
+
const w = world({ file: WIN });
|
|
130
|
+
const rec = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "pass", at: new Date(NOW).toISOString(), window_until: new Date(NOW + 1e9).toISOString() })}` };
|
|
131
|
+
const b = body([item("R1", "[check:soak] x")]);
|
|
132
|
+
const r = await run(w, b, { iss: { number: 41, body: b, comments: [rec] }, isTrusted: () => true });
|
|
133
|
+
assert.equal(r.state, "monitoring"); assert.deepEqual(w.spawned, []);
|
|
134
|
+
});
|
|
135
|
+
it("a non-windowed check still closes on pass (part 1 unchanged)", async () => {
|
|
136
|
+
const w = world({ file: WIN });
|
|
137
|
+
assert.equal((await run(w, body([item("R1", "[check:plain] x")]))).state, "pass");
|
|
138
|
+
assert.ok(w.gh.some((a) => a[1] === "close"));
|
|
139
|
+
});
|
|
140
|
+
it("a first-run fail of a windowed check reopens the parent as before", async () => {
|
|
141
|
+
const w = world({ file: JSON.stringify({ soak: { run: "echo nope", for: "7d" } }), spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
|
|
142
|
+
assert.equal((await run(w, body([item("R1", "[check:soak] x")]))).state, "fail");
|
|
143
|
+
assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
|
|
144
|
+
});
|
|
145
|
+
it("a failed parent reopen posts no fail record, so the next pass re-runs and retries", async () => {
|
|
146
|
+
const w = world();
|
|
147
|
+
const gh = w.io.gh;
|
|
148
|
+
let failReopen = true;
|
|
149
|
+
w.io.gh = async (a) => { if (a[1] === "reopen" && failReopen) { w.gh.push(a); throw new Error("api down"); } return gh(a); };
|
|
150
|
+
const b = body([item("R1", "[check:bad] x")]);
|
|
151
|
+
const r = await run(w, b);
|
|
152
|
+
assert.equal(r.state, "fail"); assert.match(r.detail, /reopen of #40 failed/);
|
|
153
|
+
assert.ok(!w.gh.some((a) => a[1] === "comment"));
|
|
154
|
+
failReopen = false;
|
|
155
|
+
const r2 = await run(w, b);
|
|
156
|
+
assert.ok(w.gh.filter((a) => a[1] === "reopen").length === 2 && /#40 reopened/.test(r2.detail));
|
|
157
|
+
const order = w.gh.map((a) => a[1] === "comment" ? `c${a[2]}` : a[1]);
|
|
158
|
+
assert.ok(order.indexOf("reopen", 1) < order.indexOf("c41"));
|
|
159
|
+
});
|
|
160
|
+
it("a recorded windowed pass retries the monitoring label", async () => {
|
|
161
|
+
const w = world({ file: WIN });
|
|
162
|
+
const rec = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "pass", at: new Date(NOW).toISOString(), window_until: new Date(NOW + 1e9).toISOString() })}` };
|
|
163
|
+
const b = body([item("R1", "[check:soak] x")]);
|
|
164
|
+
await run(w, b, { iss: { number: 41, body: b, comments: [rec] }, isTrusted: () => true });
|
|
165
|
+
assert.ok(w.gh.some((a) => a[1] === "edit" && a.includes("post-deploy-monitoring")));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
describe("verification outcome (#4689)", () => {
|
|
171
|
+
it("a pass ticks the post-deploy items with the evidence in the PROBE record, and writes no code", async () => {
|
|
172
|
+
const w = world();
|
|
173
|
+
const r = await run(w, body([item("R1", "[check:smoke] x")]));
|
|
174
|
+
assert.equal(r.state, "pass");
|
|
175
|
+
const edit = w.gh.find((a) => a[0] === "issue" && a[1] === "edit");
|
|
176
|
+
const text = edit[edit.indexOf("--body") + 1];
|
|
177
|
+
assert.match(text, /- \[x\] `R1: some quote here`/);
|
|
178
|
+
assert.equal(tickItems("- [ ] `R9: other` — defer: code"), "- [ ] `R9: other` — defer: code");
|
|
179
|
+
const probe = w.gh.find((a) => a[1] === "comment" && /FORGE:PROBE/.test(a[a.indexOf("--body") + 1]));
|
|
180
|
+
assert.match(probe[probe.indexOf("--body") + 1], /"id":"smoke","result":"pass"/);
|
|
181
|
+
assert.ok(w.git.every((a) => ["show", "rev-parse"].includes(a[0])), "no git write: " + JSON.stringify(w.git));
|
|
182
|
+
assert.equal(tickItems("no items"), "no items");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("a fail files one engineering issue per failing check and ticks nothing", async () => {
|
|
186
|
+
const w = world({ file: JSON.stringify({ bad: "echo nope", worse: "echo nope", smoke: "echo ok" }) });
|
|
187
|
+
const r = await run(w, body([item("R1", "[check:bad] x"), item("R2", "[check:worse] x"), item("R3", "[check:smoke] x")]));
|
|
188
|
+
assert.equal(r.state, "fail");
|
|
189
|
+
const created = w.gh.filter((a) => a[0] === "issue" && a[1] === "create");
|
|
190
|
+
assert.equal(created.length, 2);
|
|
191
|
+
assert.ok(created.every((a) => a.includes("followup:engineering")));
|
|
192
|
+
assert.ok(created[0][created[0].indexOf("--title") + 1].includes("bad"));
|
|
193
|
+
assert.ok(!w.gh.some((a) => a[1] === "edit" && a.includes("--body")));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an item with no runnable check posts one typed owner ask, once", async () => {
|
|
197
|
+
const w = world({ file: null });
|
|
198
|
+
assert.equal((await run(w, body([item("R1", "[check:smoke] x")]))).state, "awaiting-measurement");
|
|
199
|
+
const asks = w.gh.filter((a) => a[1] === "comment" && /FORGE:OWNER_ASK/.test(a[a.indexOf("--body") + 1]));
|
|
200
|
+
assert.equal(asks.length, 1);
|
|
201
|
+
const seen = { number: 41, body: body([item("R1", "[check:smoke] x")]), comments: [{ body: "<!-- FORGE:OWNER_ASK -->\nask", author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR" }] };
|
|
202
|
+
const w2 = world({ file: null });
|
|
203
|
+
await run(w2, seen.body, { iss: seen, isTrusted: () => true });
|
|
204
|
+
assert.equal(w2.gh.filter((a) => a[1] === "comment").length, 0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Windowed post-deploy monitoring (#4627). A follow-up whose checks declare `for` (and `every`) stays open with the
|
|
5
|
+
* `post-deploy-monitoring` label after its first pass. `sweepDue` (run by schedd's timer and `forgedock probe --due`) re-runs
|
|
6
|
+
* each one when its cadence elapsed or `origin/<default>` moved (a release), with the command read from the default branch
|
|
7
|
+
* (never an issue). A pass is a FORGE:PROBE record; the window end closes the follow-up; a fail after a pass files one
|
|
8
|
+
* regression issue naming the fix commit and the last-passing and breaking release, then closes the follow-up. No model call.
|
|
9
|
+
*/
|
|
10
|
+
import { readProbeConfig, resolveDefaultBranch, PROBE_MARKER, OUTPUT_TAIL } from "./probe.mjs";
|
|
11
|
+
import { MONITORING_LABEL, checksAt, itemCheckIds, monitorRecord, runNamedChecks, CHECKS_FILE } from "./postdeploy-check.mjs";
|
|
12
|
+
import { deferredFrom, isTrustedIssueComment } from "./prefilter.mjs";
|
|
13
|
+
|
|
14
|
+
export const REGRESSION_MARKER = "FORGE:REGRESSION";
|
|
15
|
+
const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
|
|
16
|
+
/** An existing regression issue (any state) for this follow-up and fix commit, so a retry after a failed finish() reuses it (#4653). */
|
|
17
|
+
export async function findRegression(io, repo, issue, fix) {
|
|
18
|
+
const found = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "all", "--search", `"${REGRESSION_MARKER}" "Monitored follow-up: #${issue}" in:body`, "--limit", "30", "--json", "number,body"])));
|
|
19
|
+
const hit = (Array.isArray(found) ? found : []).find((i) => {
|
|
20
|
+
const b = String(i?.body ?? "");
|
|
21
|
+
return b.includes(`<!-- ${REGRESSION_MARKER} -->`) && new RegExp(`Monitored follow-up: #${issue}(?!\\d)`).test(b) && b.includes(`Fix commit: \`${fix}\``);
|
|
22
|
+
});
|
|
23
|
+
return hit ? String(hit.number) : null;
|
|
24
|
+
}
|
|
25
|
+
const short = (s) => String(s ?? "unknown").slice(0, 12);
|
|
26
|
+
|
|
27
|
+
/** One monitored follow-up. Outcomes: skipped | unrunnable | not-due | due (dry run) | pass | window-ended | fail. */
|
|
28
|
+
export async function monitorOne({ issue, repo, def, defaultSha, cwd, io, now, dryRun, config, env, spawn }) {
|
|
29
|
+
const row = (outcome, detail = "", extra = {}) => ({ issue, outcome, detail, ...extra });
|
|
30
|
+
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
31
|
+
if (String(iss.state).toUpperCase() !== "OPEN") return row("skipped", "not open");
|
|
32
|
+
const recs = (iss.comments ?? []).filter((c) => isTrustedIssueComment(c)).map((c) => monitorRecord(c?.body)).filter(Boolean);
|
|
33
|
+
const last = recs[recs.length - 1];
|
|
34
|
+
const finish = async (comment) => {
|
|
35
|
+
if (comment) await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", comment]);
|
|
36
|
+
await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]);
|
|
37
|
+
try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", MONITORING_LABEL]); } catch { /* label already gone */ }
|
|
38
|
+
};
|
|
39
|
+
if (!last || last.result !== "pass" || !last.until) return row("skipped", last?.result === "fail" ? "already failed (FORGE:PROBE)" : "no windowed pass record");
|
|
40
|
+
if (now >= last.until) {
|
|
41
|
+
if (!dryRun) await finish(`<!-- FORGE:MONITOR -->\nPost-deploy monitoring window ended ${new Date(last.until).toISOString()} with no failure; closed.`);
|
|
42
|
+
return row("window-ended", dryRun ? "dry run: not closed" : "closed", { fix: last.fix });
|
|
43
|
+
}
|
|
44
|
+
if (now - last.at < last.everyMs && defaultSha && defaultSha === last.defaultSha) return row("not-due", `next run after ${new Date(last.at + last.everyMs).toISOString()} or a new release`);
|
|
45
|
+
const ids = itemCheckIds(iss.body);
|
|
46
|
+
const checks = await checksAt(io.git, [`origin/${def}`], cwd);
|
|
47
|
+
if (!ids.length || ids.some((id) => !id || !checks[id])) return row("unrunnable", `no runnable check on ${def} (${CHECKS_FILE})`);
|
|
48
|
+
if (dryRun) return row("due", "dry run: not run", { fix: last.fix });
|
|
49
|
+
const rows = await runNamedChecks({ ids, checks, cwd, config, env, spawn });
|
|
50
|
+
const ok = rows.every((r) => r.result === "pass");
|
|
51
|
+
const rec = { fix: last.fix, default_sha: defaultSha, result: ok ? "pass" : "fail", at: new Date(now).toISOString(), ...(ok ? { window_until: new Date(last.until).toISOString(), every_ms: last.everyMs } : {}) };
|
|
52
|
+
const sum = rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms }));
|
|
53
|
+
if (ok) {
|
|
54
|
+
await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${PROBE_MARKER} -->\n## Post-deploy check: pass\n${JSON.stringify({ ...rec, checks: sum })}\n\nRe-run on \`${def}\` at \`${short(defaultSha)}\`.`]);
|
|
55
|
+
return row("pass", `checks ${[...new Set(ids)].join(", ")} passed at ${short(defaultSha)}`, { fix: last.fix });
|
|
56
|
+
}
|
|
57
|
+
const bad = rows.filter((r) => r.result === "fail");
|
|
58
|
+
const parent = deferredFrom(iss.body);
|
|
59
|
+
const title = `Regression: post-deploy check ${bad.map((r) => r.id).join(", ")} failed on ${def}${parent ? ` (fix of #${parent})` : ""}`;
|
|
60
|
+
const body = [
|
|
61
|
+
`<!-- ${REGRESSION_MARKER} -->`, `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} passed after the fix and now fails.`, "",
|
|
62
|
+
`- Fix commit: \`${last.fix}\``, `- Last passing release (\`${def}\`): \`${last.defaultSha ?? "unknown"}\``, `- Breaking release (\`${def}\`): \`${defaultSha ?? "unknown"}\``,
|
|
63
|
+
`- Monitored follow-up: #${issue}${parent ? `; parent: #${parent}` : ""}`, "", ...bad.flatMap((r) => [`**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output), ""]),
|
|
64
|
+
].join("\n").slice(0, OUTPUT_TAIL * 4);
|
|
65
|
+
const existing = await findRegression(io, repo, issue, last.fix);
|
|
66
|
+
const created = existing ? "" : String(await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]));
|
|
67
|
+
const num = existing ?? /\/issues\/(\d+)/.exec(created)?.[1] ?? null;
|
|
68
|
+
await finish(`<!-- ${PROBE_MARKER} -->\n## Post-deploy check: fail\n${JSON.stringify({ ...rec, regression: num ? Number(num) : null, checks: sum })}\n\nRegression issue ${num ? `#${num}` : "filed"}; monitoring stops.`);
|
|
69
|
+
return row("fail", `regression ${num ? `#${num}` : "filed"}: ${bad.map((r) => r.id).join(", ")} broke at ${short(defaultSha)} (fix ${short(last.fix)})`, { fix: last.fix, regression: num });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Re-run every due monitored follow-up of the repo. Sequential; an error is an `error` row, never a throw. */
|
|
73
|
+
export async function sweepDue({ repo, cwd = process.cwd(), io, dryRun = false, now = () => Date.now(), config = readProbeConfig(cwd), env = process.env, spawn, onResult = null } = {}) {
|
|
74
|
+
const def = await resolveDefaultBranch({ repo, io, cwd });
|
|
75
|
+
if (!def) throw new Error("no default branch");
|
|
76
|
+
const found = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", MONITORING_LABEL, "--limit", "100", "--json", "number"])));
|
|
77
|
+
const list = (Array.isArray(found) ? found : []).map((i) => i.number).filter(Number.isInteger);
|
|
78
|
+
if (!list.length) return [];
|
|
79
|
+
try { await io.git(["fetch", "origin", def], { cwd }); } catch { /* offline: the local ref */ }
|
|
80
|
+
let defaultSha = null;
|
|
81
|
+
try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* unknown release */ }
|
|
82
|
+
const rows = [];
|
|
83
|
+
for (const issue of list) {
|
|
84
|
+
try { rows.push(await monitorOne({ issue, repo, def, defaultSha, cwd, io, now: now(), dryRun, config, env, spawn })); } catch (e) {
|
|
85
|
+
rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
|
|
86
|
+
}
|
|
87
|
+
try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep */ }
|
|
88
|
+
}
|
|
89
|
+
return rows;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One line, e.g. `monitoring: pass #1; regression #2; closed #3`; "" when nothing is monitored. */
|
|
93
|
+
export function formatDueSummary(rows) {
|
|
94
|
+
const g = [["pass", "pass"], ["fail", "regression"], ["window-ended", "window ended"], ["due", "due"], ["not-due", "not due"], ["unrunnable", "unrunnable"], ["error", "error"]];
|
|
95
|
+
const parts = g.map(([k, l]) => [l, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)]).filter(([, a]) => a.length).map(([l, a]) => `${l} ${a.join(" ")}`);
|
|
96
|
+
return parts.length ? `monitoring: ${parts.join("; ")}` : "";
|
|
97
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 { sweepDue, formatDueSummary } from "./postdeploy-monitor.mjs";
|
|
6
|
+
import { PROBE_MARKER } from "./probe.mjs";
|
|
7
|
+
import { isTrustedIssueComment } from "./prefilter.mjs";
|
|
8
|
+
|
|
9
|
+
const T0 = Date.parse("2026-10-26T00:00:00Z");
|
|
10
|
+
const H = 3600e3;
|
|
11
|
+
const CHECKS = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" } });
|
|
12
|
+
const BODY = "## Acceptance Criteria\n\n- [ ] `R1: health row exists` — post-deploy: [check:soak] soak\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n";
|
|
13
|
+
const rec = (o) => ({ authorAssociation: "OWNER", author: { login: "bot" }, body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "fix1234567890", default_sha: "rel1", result: "pass", at: new Date(T0).toISOString(), window_until: new Date(T0 + 7 * 24 * H).toISOString(), every_ms: 6 * H, ...o })}` });
|
|
14
|
+
|
|
15
|
+
function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN", regressions = [], failClose = false } = {}) {
|
|
16
|
+
const w = { gh: [], git: [], spawned: [] };
|
|
17
|
+
w.io = {
|
|
18
|
+
gh: async (a) => {
|
|
19
|
+
w.gh.push(a);
|
|
20
|
+
const k = a.slice(0, 2).join(" ");
|
|
21
|
+
if (k === "repo view") return "main\n";
|
|
22
|
+
if (k === "issue list" && a.includes("--search")) return JSON.stringify(regressions);
|
|
23
|
+
if (failClose && k === "issue close") throw new Error("rate limit");
|
|
24
|
+
if (k === "issue list") return JSON.stringify([{ number: 41 }]);
|
|
25
|
+
if (k === "issue view") return JSON.stringify({ number: 41, state, title: "", body: BODY, labels: [{ name: "post-deploy-monitoring" }], comments });
|
|
26
|
+
if (k === "issue create") return "https://github.com/o/r/issues/77\n";
|
|
27
|
+
return "";
|
|
28
|
+
},
|
|
29
|
+
git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } if (a[0] === "rev-parse") return `${head}\n`; return ""; },
|
|
30
|
+
};
|
|
31
|
+
w.spawn = async (o) => { w.spawned.push(o.run); return { code, stdout: code ? "broke" : "ok", stderr: "" }; };
|
|
32
|
+
return w;
|
|
33
|
+
}
|
|
34
|
+
const sweep = (w, o = {}) => sweepDue({ repo: "o/r", cwd: "/repo", io: w.io, now: () => T0 + H, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...o });
|
|
35
|
+
const calls = (w, k) => w.gh.filter((a) => a.slice(0, 2).join(" ") === k);
|
|
36
|
+
|
|
37
|
+
const trustedOk = isTrustedIssueComment(rec({}));
|
|
38
|
+
|
|
39
|
+
describe("post-deploy windowed monitoring (#4627)", () => {
|
|
40
|
+
it("fixture comments count as trusted", () => assert.ok(trustedOk));
|
|
41
|
+
|
|
42
|
+
it("not due: cadence not elapsed and no new release", async () => {
|
|
43
|
+
const w = world();
|
|
44
|
+
const rows = await sweep(w);
|
|
45
|
+
assert.equal(rows[0].outcome, "not-due"); assert.deepEqual(w.spawned, []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("due by cadence: re-runs the default-branch command and records a pass; the follow-up stays open", async () => {
|
|
49
|
+
const w = world();
|
|
50
|
+
const rows = await sweep(w, { now: () => T0 + 7 * H });
|
|
51
|
+
assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
|
|
52
|
+
assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
|
|
53
|
+
assert.equal(calls(w, "issue comment").length, 1);
|
|
54
|
+
assert.equal(calls(w, "issue close").length, 0); assert.equal(calls(w, "issue create").length, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("due by a new release (origin/main moved) well before the cadence", async () => {
|
|
58
|
+
const w = world({ head: "rel2" });
|
|
59
|
+
const rows = await sweep(w);
|
|
60
|
+
assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("window end closes the follow-up and drops the label without running", async () => {
|
|
64
|
+
const w = world();
|
|
65
|
+
const rows = await sweep(w, { now: () => T0 + 8 * 24 * H });
|
|
66
|
+
assert.equal(rows[0].outcome, "window-ended"); assert.deepEqual(w.spawned, []);
|
|
67
|
+
assert.equal(calls(w, "issue close").length, 1);
|
|
68
|
+
assert.ok(calls(w, "issue edit").some((a) => a.includes("--remove-label")));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("pass then fail files one regression naming the fix commit and both releases, then closes the follow-up", async () => {
|
|
72
|
+
const w = world({ head: "rel2", code: 1 });
|
|
73
|
+
const rows = await sweep(w);
|
|
74
|
+
assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "77");
|
|
75
|
+
const [create] = calls(w, "issue create");
|
|
76
|
+
const text = create[create.indexOf("--body") + 1];
|
|
77
|
+
assert.match(create[create.indexOf("--title") + 1], /Regression: post-deploy check soak failed on main \(fix of #40\)/);
|
|
78
|
+
assert.match(text, /FORGE:REGRESSION/); assert.match(text, /fix1234567890/); assert.match(text, /`rel1`/); assert.match(text, /`rel2`/); assert.match(text, /broke/);
|
|
79
|
+
assert.equal(calls(w, "issue close").length, 1);
|
|
80
|
+
assert.ok(!w.gh.some((a) => a[1] === "reopen"));
|
|
81
|
+
const cm = calls(w, "issue comment")[0];
|
|
82
|
+
assert.match(cm[cm.indexOf("--body") + 1], /"result":"fail".*"regression":77/s);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const reg = (n, o = {}) => ({ number: n, body: `<!-- FORGE:REGRESSION -->\n- Fix commit: \`${o.fix ?? "fix1234567890"}\`\n- Monitored follow-up: #${o.issue ?? 41}\n` });
|
|
86
|
+
|
|
87
|
+
it("#4653: a regression already filed for this follow-up and fix is reused, not duplicated", async () => {
|
|
88
|
+
const w = world({ head: "rel2", code: 1, regressions: [reg(90)] });
|
|
89
|
+
const rows = await sweep(w);
|
|
90
|
+
assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "90");
|
|
91
|
+
assert.equal(calls(w, "issue create").length, 0); assert.equal(calls(w, "issue close").length, 1);
|
|
92
|
+
const cm = calls(w, "issue comment")[0];
|
|
93
|
+
assert.match(cm[cm.indexOf("--body") + 1], /"regression":90/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("#4653: finish() failing after create does not file a second regression on the next sweep", async () => {
|
|
97
|
+
const w = world({ head: "rel2", code: 1, failClose: true });
|
|
98
|
+
assert.equal((await sweep(w))[0].outcome, "error"); assert.equal(calls(w, "issue create").length, 1);
|
|
99
|
+
const w2 = world({ head: "rel2", code: 1, regressions: [reg(77)] });
|
|
100
|
+
assert.equal((await sweep(w2))[0].regression, "77"); assert.equal(calls(w2, "issue create").length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("#4653: a regression for another follow-up, fix commit or issue number prefix is not reused", async () => {
|
|
104
|
+
const w = world({ head: "rel2", code: 1, regressions: [reg(5, { issue: 4 }), reg(6, { issue: 410 }), reg(7, { fix: "other" })] });
|
|
105
|
+
const rows = await sweep(w);
|
|
106
|
+
assert.equal(rows[0].regression, "77"); assert.equal(calls(w, "issue create").length, 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a follow-up whose latest record is a fail is never re-run or re-filed", async () => {
|
|
110
|
+
const w = world({ head: "rel3", code: 1, comments: [rec({}), rec({ result: "fail", default_sha: "rel2", regression: 77 })] });
|
|
111
|
+
const rows = await sweep(w);
|
|
112
|
+
assert.equal(rows[0].outcome, "skipped"); assert.deepEqual(w.spawned, []); assert.equal(calls(w, "issue create").length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("an untrusted record never counts", async () => {
|
|
116
|
+
const w = world({ comments: [{ authorAssociation: "NONE", author: { login: "rando" }, body: rec({}).body }] });
|
|
117
|
+
assert.equal((await sweep(w, { now: () => T0 + 9 * H }))[0].outcome, "skipped");
|
|
118
|
+
assert.deepEqual(w.spawned, []);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a check missing on the default branch is unrunnable and never run", async () => {
|
|
122
|
+
for (const file of [null, JSON.stringify({ other: "echo hi" })]) {
|
|
123
|
+
const w = world({ file, head: "rel2" });
|
|
124
|
+
assert.equal((await sweep(w))[0].outcome, "unrunnable"); assert.deepEqual(w.spawned, []);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("dry run runs nothing and writes nothing", async () => {
|
|
129
|
+
const w = world({ head: "rel2" });
|
|
130
|
+
assert.equal((await sweep(w, { dryRun: true }))[0].outcome, "due");
|
|
131
|
+
assert.deepEqual(w.spawned, []);
|
|
132
|
+
assert.equal(calls(w, "issue comment").length + calls(w, "issue close").length + calls(w, "issue create").length, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("no monitored follow-up: no git, no run; the summary names outcomes", async () => {
|
|
136
|
+
const w = world();
|
|
137
|
+
w.io.gh = async (a) => (a[0] === "repo" ? "main\n" : "[]");
|
|
138
|
+
assert.deepEqual(await sweep(w), []); assert.equal(w.git.length, 0);
|
|
139
|
+
assert.equal(formatDueSummary([{ issue: 1, outcome: "pass" }, { issue: 2, outcome: "fail" }]), "monitoring: pass #1; regression #2");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reports sink rows for pass and fail only when not a dry run", async () => {
|
|
143
|
+
const seen = [];
|
|
144
|
+
await sweep(world({ head: "rel2", code: 1 }), { onResult: (r) => seen.push(r.outcome) });
|
|
145
|
+
assert.deepEqual(seen, ["fail"]);
|
|
146
|
+
});
|
|
147
|
+
});
|