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.
Files changed (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,189 @@
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 { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState } from "./integration.mjs";
6
+ import { orchestrate } from "./orchestrate.mjs";
7
+
8
+ const SHA = "a".repeat(40), SHA2 = "b".repeat(40);
9
+ const CFG = parseIntegration({ workflow: "e2e.yml", every_merges: "2", block_admission_after: "2" });
10
+ const NOW = Date.parse("2026-10-27T10:00:00Z");
11
+
12
+ /** One milestone "M One" (branch milestone/m-one), a workflow whose conclusion is `st.conclusion`, and a log of every gh call. */
13
+ function fake({ description = "Ship it.", conclusion = "success", branch = true, open = [] } = {}) {
14
+ const st = { description, conclusion, open: [...open], calls: [], created: [], edited: [], closed: [], head: SHA };
15
+ const gh = async (a) => {
16
+ st.calls.push(a);
17
+ if (a[0] === "api" && /milestones\?state=open/.test(a[1])) return JSON.stringify([{ number: 3, title: "M One", description: st.description }, { number: 4, title: "No Branch", description: "" }]);
18
+ if (a[0] === "api" && /milestones\?state=all/.test(a[1])) return JSON.stringify([{ number: 3, title: "M One", description: st.description }]);
19
+ if (a[0] === "api" && /\/branches\//.test(a[1])) { if (branch && /milestone%2Fm-one/.test(a[1])) return `${st.head}\n`; throw new Error("404"); }
20
+ if (a[0] === "api" && a[1] === "-X" && a[2] === "PATCH") { st.description = a[a.indexOf("-f") + 1].slice("description=".length); return "{}"; }
21
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(st.open);
22
+ if (a[0] === "issue" && a[1] === "create") {
23
+ const n = 900 + st.created.length;
24
+ st.created.push({ n, title: a[a.indexOf("--title") + 1], body: a[a.indexOf("--body") + 1], label: a.includes("--label") });
25
+ st.open.push({ number: n, body: a[a.indexOf("--body") + 1] });
26
+ return `https://github.com/o/r/issues/${n}\n`;
27
+ }
28
+ if (a[0] === "issue" && a[1] === "edit") { st.edited.push({ n: a[2], body: a[a.indexOf("--body") + 1] }); st.open = st.open.map((x) => (String(x.number) === a[2] ? { ...x, body: a[a.indexOf("--body") + 1] } : x)); return ""; }
29
+ if (a[0] === "issue" && a[1] === "close") { st.closed.push(a[2]); st.open = st.open.filter((x) => String(x.number) !== a[2]); return ""; }
30
+ throw new Error(`unexpected gh ${a.join(" ")}`);
31
+ };
32
+ const io = { gh, dispatchWorkflow: async () => {},
33
+ workflowRuns: async () => [{ databaseId: 5, headSha: st.head, status: "completed", conclusion: st.conclusion, url: "https://run/5", name: "e2e" }],
34
+ failedLogs: async () => "FAIL e2e/cart.spec.ts\nFAIL e2e/login.e2e.ts\n" };
35
+ return { st, io };
36
+ }
37
+ const run = (io, st, extra = {}) => runIntegration({ io, repo: "o/r", cfg: CFG, pattern: "milestone/{slug}", nowFn: () => NOW, sleep: async () => {}, ...extra });
38
+
39
+ describe("config and state", () => {
40
+ it("is off without a workflow and parses the keys", () => {
41
+ assert.equal(parseIntegration({}), null);
42
+ assert.equal(parseIntegration({ workflow: "a b" }), null);
43
+ assert.deepEqual([CFG.workflow, CFG.intervalHours, CFG.everyMerges, CFG.blockAdmissionAfter, CFG.projectInput], ["e2e.yml", 24, 2, 2, "project"]);
44
+ assert.equal(parseIntegration({ workflow: "e.yml", interval_hours: "x", block_admission_after: "0" }).blockAdmissionAfter, null);
45
+ });
46
+ it("round-trips the state block and keeps the description text", () => {
47
+ const d = renderState("Ship it.", { ...emptyState(), redStreak: 3 });
48
+ assert.match(d, /^Ship it\./);
49
+ assert.equal(parseIntegrationState(d).redStreak, 3);
50
+ assert.equal(parseIntegrationState(renderState(d, { ...emptyState(), redStreak: 1 })).redStreak, 1);
51
+ assert.equal(parseIntegrationState("none"), null);
52
+ });
53
+ it("is due when never run, aged, or after N merges", () => {
54
+ assert.equal(isDue(null, CFG, NOW).due, true);
55
+ const s = { ...emptyState(), lastRun: { at: new Date(NOW - 3600000).toISOString() } };
56
+ assert.equal(isDue(s, CFG, NOW).due, false);
57
+ assert.equal(isDue({ ...s, mergesSince: 2 }, CFG, NOW).due, true);
58
+ assert.equal(isDue({ ...s, lastRun: { at: new Date(NOW - 25 * 3600000).toISOString() } }, CFG, NOW).due, true);
59
+ });
60
+ it("names failing specs from logs", () => assert.deepEqual(failingSpecs([{ log: "FAIL e2e/a.spec.ts x e2e/a.spec.ts ./b.test.js" }]), ["b.test.js", "e2e/a.spec.ts"]));
61
+ });
62
+
63
+ describe("runIntegration", () => {
64
+ it("skips a milestone without a branch and records a green run", async () => {
65
+ const { st, io } = fake();
66
+ const res = await run(io, st);
67
+ assert.equal(res.find((r) => r.title === "No Branch").ran, false);
68
+ assert.equal(res.find((r) => r.title === "M One").result, "pass");
69
+ const s = parseIntegrationState(st.description);
70
+ assert.equal(s.lastGreen.sha, SHA);
71
+ assert.equal(s.redStreak, 0);
72
+ assert.equal(st.created.length, 0);
73
+ });
74
+ it("a red run files ONE integration-red issue and a second red updates it", async () => {
75
+ const { st, io } = fake({ conclusion: "failure" });
76
+ await run(io, st);
77
+ assert.equal(st.created.length, 1);
78
+ assert.match(st.created[0].title, /integration-red\(milestone\/m-one\)/);
79
+ assert.ok(st.created[0].label);
80
+ assert.match(st.created[0].body, /e2e\/cart\.spec\.ts/);
81
+ assert.match(st.created[0].body, /Merge range/);
82
+ assert.match(st.created[0].body, /<!-- FORGE:INTEGRATION:m-one -->/);
83
+ st.head = SHA2;
84
+ await run(io, st, { force: true });
85
+ assert.equal(st.created.length, 1, "no second issue");
86
+ assert.equal(st.edited.length, 1);
87
+ assert.match(st.edited[0].body, /2 consecutive red runs/);
88
+ assert.equal(parseIntegrationState(st.description).redStreak, 2);
89
+ });
90
+ it("a green run after red closes the issue, records the range start and resets the streak", async () => {
91
+ const { st, io } = fake({ conclusion: "failure" });
92
+ await run(io, st);
93
+ st.conclusion = "success"; st.head = SHA2;
94
+ await run(io, st, { force: true });
95
+ assert.deepEqual(st.closed, ["900"]);
96
+ const s = parseIntegrationState(st.description);
97
+ assert.equal(s.redStreak, 0);
98
+ assert.equal(s.lastGreen.sha, SHA2);
99
+ });
100
+ it("the red issue names the merge range from the last green run", async () => {
101
+ const { st, io } = fake();
102
+ await run(io, st);
103
+ st.conclusion = "failure"; st.head = SHA2;
104
+ await run(io, st, { force: true });
105
+ assert.match(st.created[0].body, new RegExp(`${SHA.slice(0, 7)}\\.\\.${SHA2.slice(0, 7)}`));
106
+ });
107
+ it("is not run again when not due", async () => {
108
+ const { st, io } = fake();
109
+ await run(io, st);
110
+ const again = await run(io, st);
111
+ assert.equal(again.find((r) => r.title === "M One").ran, false);
112
+ });
113
+ it("a timed-out run records nothing", async () => {
114
+ const { st, io } = fake();
115
+ io.workflowRuns = async () => [{ databaseId: 5, headSha: st.head, status: "in_progress", conclusion: null }];
116
+ let t = NOW;
117
+ const r = await runMilestone({ io, repo: "o/r", ms: { number: 3, title: "M One", description: "" }, cfg: { ...CFG, maxMinutes: 1 }, pattern: "milestone/{slug}", nowFn: () => (t += 120000), sleep: async () => {} });
118
+ assert.equal(r.result, "timeout");
119
+ assert.equal(parseIntegrationState(st.description), null);
120
+ });
121
+ });
122
+
123
+ describe("makeIntegration seam", () => {
124
+ it("is null when off", () => assert.equal(makeIntegration({ repo: "o/r", io: {}, cfg: null }), null));
125
+ it("holds a lane after N consecutive red runs and releases it on green", async () => {
126
+ const red = { ...emptyState(), redStreak: 2 };
127
+ const { st, io } = fake({ description: renderState("", red) });
128
+ const seam = makeIntegration({ repo: "o/r", io, cfg: CFG, nowFn: () => NOW, sleep: async () => {} });
129
+ await seam.prime();
130
+ assert.match(seam.holds(1, { lane: "milestone/m-one" }), /2 consecutive red integration runs/);
131
+ assert.equal(seam.holds(1, { lane: "staging" }), null);
132
+ st.head = SHA2;
133
+ for (const pr of [11, 12]) await seam.onMerged(1, {}, { milestone: "M One", pr });
134
+ await seam.settle();
135
+ assert.equal(seam.holds(1, { lane: "milestone/m-one" }), null, "green run released the hold");
136
+ assert.equal(parseIntegrationState(st.description).redStreak, 0);
137
+ });
138
+ it("records merged PRs and starts a run only at every_merges", async () => {
139
+ const { st, io } = fake({ conclusion: "failure" });
140
+ const seam = makeIntegration({ repo: "o/r", io, cfg: CFG, nowFn: () => NOW, sleep: async () => {} });
141
+ await seam.onMerged(1, {}, { milestone: "M One", pr: 11 });
142
+ await seam.settle();
143
+ assert.equal(st.created.length, 0);
144
+ assert.deepEqual(parseIntegrationState(st.description).mergedPrs, [11]);
145
+ await seam.onMerged(2, {}, { milestone: "M One", pr: 12 });
146
+ await seam.settle();
147
+ assert.equal(st.created.length, 1);
148
+ assert.match(st.created[0].body, /#11, #12/);
149
+ });
150
+ it("orchestrate ends a held lane `held` and starts nothing while the streak is red", async () => {
151
+ const started = [];
152
+ const integration = { holds: (n, { lane }) => (lane === "milestone/m-one" ? "2 consecutive red integration runs on milestone/m-one" : null) };
153
+ const rows = await orchestrate({ issues: [1], parallel: 1, readBody: async () => "body", resolveLane: async () => ({ lane: "milestone/m-one" }),
154
+ runOne: async (n) => { started.push(n); return { outcome: "merged" }; }, integration, pollMs: 1 });
155
+ assert.deepEqual(started, []);
156
+ assert.equal(rows[0].outcome, "held");
157
+ assert.match(rows[0].detail, /integration: 2 consecutive red/);
158
+ });
159
+ });
160
+
161
+ describe("runIntegrationCli", () => {
162
+ it("does nothing without ci.workflow", async () => {
163
+ const out = [];
164
+ assert.equal(await runIntegrationCli(["run"], { cfg: null, print: (t) => out.push(t) }), 0);
165
+ assert.match(out[0], /nothing to run/);
166
+ });
167
+ it("prints a line per milestone and exits 1 on a red run", async () => {
168
+ const { st, io } = fake({ conclusion: "failure" });
169
+ const out = [];
170
+ const code = await runIntegrationCli(["run", "--repo", "o/r"], { cfg: CFG, io, pattern: "milestone/{slug}", now: () => NOW, sleep: async () => {}, print: (t) => out.push(t) });
171
+ assert.equal(code, 1);
172
+ assert.ok(out.some((l) => /M One: fail/.test(l)));
173
+ assert.equal(st.created.length, 1);
174
+ });
175
+ });
176
+
177
+ describe("integration status", () => {
178
+ it("prints the last green run per milestone", async () => {
179
+ const { st, io } = fake();
180
+ await run(io, st);
181
+ const out = [];
182
+ assert.equal(await runIntegrationCli(["status", "--repo", "o/r"], { io, cfg: CFG, print: (t) => out.push(t) }), 0);
183
+ assert.ok(out.some((l) => /M One: last green full-suite run 2026-10-27T10:00:00\.000Z \(red streak 0\)/.test(l)));
184
+ assert.ok(out.some((l) => /No Branch: last green full-suite run never/.test(l)));
185
+ const off = [];
186
+ await runIntegrationCli(["status", "--repo", "o/r"], { io, cfg: null, print: (t) => off.push(t) });
187
+ assert.equal(off.length, 1, "without ci.workflow only milestones with a state block");
188
+ });
189
+ });
package/bin/lean/lane.mjs CHANGED
@@ -85,7 +85,7 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
85
85
  }
86
86
 
87
87
  /**
88
- * #4593: true when `lane` is a non-staging branch fully merged into `staging` (a shipped milestone branch).
88
+ * #4593: true when `lane` is a non-staging branch fully merged into `staging` by a merge commit (a shipped milestone branch).
89
89
  * Fail closed: any git error (no such ref, offline) is "not shipped".
90
90
  */
91
91
  export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io, cwd }) {
@@ -93,6 +93,9 @@ export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io
93
93
  try {
94
94
  try { await io.git(["fetch", "origin", lane, staging], { cwd }); } catch { /* offline: use the local refs */ }
95
95
  await io.git(["merge-base", "--is-ancestor", `origin/${lane}`, `origin/${staging}`], { cwd });
96
- return true;
96
+ // #4623: an unused lane cut from staging is trivially an ancestor; shipped also needs a merge commit that took the lane tip.
97
+ const tip = String(await io.git(["rev-parse", `origin/${lane}`], { cwd })).trim();
98
+ const merges = String(await io.git(["rev-list", "--merges", "--parents", `origin/${staging}`, `^origin/${lane}`], { cwd }));
99
+ return !!tip && merges.split("\n").some((l) => l.trim().split(/\s+/).slice(2).includes(tip));
97
100
  } catch { return false; }
98
101
  }