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,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4629: `forgedock stats --experiment <id>`. Finished runs tagged with the experiment id, grouped by arm; each metric
|
|
5
|
+
* is null below the declared min n (release.mjs rule), a target is null when a value it needs is null, never a pass.
|
|
6
|
+
*/
|
|
7
|
+
import { aggregate } from "./aggregate.mjs";
|
|
8
|
+
import { releaseMetrics } from "./release.mjs";
|
|
9
|
+
import { benchRecord } from "../experiment.mjs";
|
|
10
|
+
|
|
11
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
12
|
+
|
|
13
|
+
/** One target against the per-arm metrics: absolute limit, or limit x the other arm's value. */
|
|
14
|
+
export function evalTarget(t, byArm) {
|
|
15
|
+
const value = byArm[t.arm]?.[t.metric], base = t.vs ? byArm[t.vs]?.[t.metric] : 1;
|
|
16
|
+
const limit = isNum(base) ? t.limit * base : null;
|
|
17
|
+
const pass = isNum(value) && isNum(limit) ? (t.op === "<=" ? value <= limit : value >= limit) : null;
|
|
18
|
+
return { pass, value: isNum(value) ? value : null, limit };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function experimentReport(runs, exp) {
|
|
22
|
+
const mine = runs.filter((r) => r.finished && r.experiment === exp.id);
|
|
23
|
+
const arms = {}, byArm = {};
|
|
24
|
+
for (const arm of exp.arms) {
|
|
25
|
+
const rs = mine.filter((r) => r.arm === arm).map((r) => ({ ...r, version: "0.0.0" })); // one group per arm
|
|
26
|
+
const g = aggregate({ runs: rs }, { minN: exp.minN }).groups[0];
|
|
27
|
+
const m = g ? releaseMetrics([g], { minN: exp.minN }).versions[0] : null;
|
|
28
|
+
byArm[arm] = Object.fromEntries(exp.metrics.map((k) => [k, m?.[k] ?? null]));
|
|
29
|
+
arms[arm] = { n: rs.length, ...byArm[arm] };
|
|
30
|
+
}
|
|
31
|
+
const targets = Object.fromEntries(exp.targets.map((t) => [t.text, evalTarget(t, byArm)]));
|
|
32
|
+
const verdicts = Object.values(targets);
|
|
33
|
+
return { id: exp.id, minN: exp.minN, ...benchRecord({ source: "stats", arms, targets }),
|
|
34
|
+
targets_met: verdicts.length > 0 && verdicts.every((v) => v.pass === true) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fmt = (v) => (v == null ? "n/a" : Number.isInteger(v) ? String(v) : v.toFixed(2));
|
|
38
|
+
export function renderExperiment(r) {
|
|
39
|
+
const keys = Object.keys(Object.values(r.arms)[0] ?? {}).filter((k) => k !== "n");
|
|
40
|
+
const line = (c) => `| ${c.join(" | ")} |`;
|
|
41
|
+
const rows = Object.entries(r.arms).map(([a, v]) => line([a, String(v.n), ...keys.map((k) => fmt(v[k]))]));
|
|
42
|
+
const tg = Object.entries(r.targets).map(([t, v]) => `${t}: ${v.pass == null ? "n/a" : v.pass ? "met" : "missed"} (${fmt(v.value)} vs ${fmt(v.limit)})`);
|
|
43
|
+
return [`Experiment ${r.id} (min n ${r.minN}; n/a below it)`, line(["arm", "n", ...keys]), line(["---", "---", ...keys.map(() => "---")]), ...rows, ...tg, `targets met: ${r.targets_met}`].join("\n");
|
|
44
|
+
}
|
|
@@ -77,9 +77,10 @@ export function runsFromRecords(records) {
|
|
|
77
77
|
const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
|
|
78
78
|
const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
|
|
79
79
|
finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
|
|
80
|
-
phases: {}, builderAttempts: [], _first: null };
|
|
80
|
+
phases: {}, builderAttempts: [], experiment: null, arm: null, _first: null };
|
|
81
81
|
byRun.set(key, run);
|
|
82
82
|
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
83
|
+
if (r.experiment != null && r.arm != null) { run.experiment = String(r.experiment); run.arm = String(r.arm); } // #4629
|
|
83
84
|
if (r.event === "run_terminal") {
|
|
84
85
|
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
|
|
85
86
|
continue;
|
package/bin/stats/index.mjs
CHANGED
|
@@ -16,6 +16,10 @@ import { parseForgeYaml } from "../forge-utils.mjs";
|
|
|
16
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
17
|
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
18
|
import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyLines, formatCompareLines, parseWindows } from "./efficiency.mjs";
|
|
19
|
+
import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
|
|
20
|
+
import { experimentReport, renderExperiment } from "./experiment.mjs";
|
|
21
|
+
import { readExperiment } from "../experiment.mjs";
|
|
22
|
+
import { compoundingReport, gatherCompounding, renderCompounding, DEFAULT_BUCKETS, DEFAULT_WINDOW_MS } from "./compounding.mjs";
|
|
19
23
|
import { poolUtilisation, formatPoolLines } from "./pool.mjs";
|
|
20
24
|
import { batchRoot } from "../lean/batch.mjs";
|
|
21
25
|
|
|
@@ -23,7 +27,10 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
23
27
|
|
|
24
28
|
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
25
29
|
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
30
|
+
forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
|
|
31
|
+
forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
|
|
26
32
|
forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
33
|
+
forgedock stats --compounding [--window 30d] [--bucket 100,500] [--since <date>] [--min-n N] [--repo <owner/repo>] [--json]
|
|
27
34
|
forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
|
|
28
35
|
|
|
29
36
|
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
@@ -42,6 +49,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
|
|
|
42
49
|
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
43
50
|
(default: git tags v* of the ForgeDock checkout)
|
|
44
51
|
|
|
52
|
+
Per-release regression rule (forge#4628):
|
|
53
|
+
--require-met with --experiment: exit 1 unless every target is met (#4634; for a named post-deploy check)
|
|
54
|
+
--experiment <id> arms of the forge.yaml experiment: section (arms, ratio, metrics, targets, min_n) against its targets; n/a below min n
|
|
55
|
+
--emit-event with --experiment: emit one experiment.verdict event (met or missed; docs/EVENTS.md)
|
|
56
|
+
--by version median cost per run, wall p50, first-pass rate and needs-human rate of each release against the
|
|
57
|
+
previous one; a metric under --min-n runs is null and its verdict null, never a pass
|
|
58
|
+
--post-metrics <pr> post (or update) the <!-- FORGE:METRICS v1 --> snapshot comment on that release PR
|
|
59
|
+
|
|
45
60
|
ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
|
|
46
61
|
(DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
|
|
47
62
|
thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
|
|
@@ -71,6 +86,13 @@ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues
|
|
|
71
86
|
their delta and full-lane cohorts matched per size (forge#4491)
|
|
72
87
|
--offline skip the GitHub crawl for merged issues (issue quality and acceptance print n/a)
|
|
73
88
|
|
|
89
|
+
Compounding (forge#4696; GitHub + git of the current clone, no model):
|
|
90
|
+
--compounding reintroduction rate (guarded fix reopened, or its lines blamed by a later guarded fix within 90 days),
|
|
91
|
+
reopen-after-close rate, guard activity (guard_run, guard_broken, supersedes_accepted) and decision
|
|
92
|
+
churn (decision_superseded, decision_violation), as a rolling window, per release and per lane-age
|
|
93
|
+
bucket; n/a where no data is recorded. Pair with --efficiency --compare for $/merge and turns/merge
|
|
94
|
+
--bucket <list> lane-age bucket upper bounds (default 100,500: lanes 1-100, 101-500, 501+)
|
|
95
|
+
|
|
74
96
|
Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
|
|
75
97
|
--tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
|
|
76
98
|
(mid, mid->top escalated, top) and routing class; --json for JSON
|
|
@@ -81,8 +103,11 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
|
|
|
81
103
|
--issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
|
|
82
104
|
`;
|
|
83
105
|
|
|
106
|
+
/** #4634: 0, or 1 when --require-met and the experiment's targets are not all met (a named post-deploy check gates on it). */
|
|
107
|
+
export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
|
|
108
|
+
|
|
84
109
|
export function parseArgs(argv) {
|
|
85
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
110
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, compounding: false, bucket: null, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
86
111
|
const val = (i, name) => {
|
|
87
112
|
const v = argv[i + 1];
|
|
88
113
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -101,6 +126,8 @@ export function parseArgs(argv) {
|
|
|
101
126
|
else if (name === "--context-ab") o.contextAb = true;
|
|
102
127
|
else if (name === "--explore-ab") o.exploreAb = true;
|
|
103
128
|
else if (name === "--tiers") o.tiers = true;
|
|
129
|
+
else if (name === "--compounding") o.compounding = true;
|
|
130
|
+
else if (name === "--bucket") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--bucket must be a comma list of lane counts"); const b = v.split(",").map(Number); if (b.some((x, i) => i && x <= b[i - 1])) throw new Error("--bucket must be increasing"); o.bucket = b; }
|
|
104
131
|
else if (name === "--lane-profile") o.laneProfile = true;
|
|
105
132
|
else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
|
|
106
133
|
else if (name === "--efficiency") o.efficiency = true;
|
|
@@ -112,8 +139,13 @@ export function parseArgs(argv) {
|
|
|
112
139
|
try { if (!v || !Object.keys(parseSince(v)).length) throw bad(); } catch { throw bad(); }
|
|
113
140
|
o.compare = v;
|
|
114
141
|
}
|
|
142
|
+
else if (name === "--by") { const v = take(); if (v !== "version") throw new Error("--by must be version"); o.by = v; }
|
|
143
|
+
else if (name === "--experiment") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error("--experiment must be an experiment id"); o.experiment = v; }
|
|
144
|
+
else if (name === "--emit-event") o.emitEvent = true;
|
|
145
|
+
else if (name === "--post-metrics") { const v = take(); if (!/^[1-9]\d*$/.test(v)) throw new Error("--post-metrics must be a PR number"); o.postMetrics = Number(v); }
|
|
146
|
+
else if (name === "--require-met") o.requireMet = true;
|
|
115
147
|
else if (name === "--offline") o.offline = true;
|
|
116
|
-
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
148
|
+
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); o.minNSet = true; }
|
|
117
149
|
else if (name === "--json") o.json = true;
|
|
118
150
|
else if (name === "--refresh") o.refresh = true;
|
|
119
151
|
else if (name === "--compact") o.compact = true;
|
|
@@ -123,8 +155,10 @@ export function parseArgs(argv) {
|
|
|
123
155
|
else throw new Error(`unknown argument: ${a}`);
|
|
124
156
|
}
|
|
125
157
|
if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
126
|
-
if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
|
|
158
|
+
if (o.windows && !o.efficiency && !o.pool && !o.compounding) throw new Error("--batch and --window need --efficiency");
|
|
159
|
+
if (o.bucket && !o.compounding) throw new Error("--bucket needs --compounding");
|
|
127
160
|
if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
|
|
161
|
+
if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
|
|
128
162
|
return o;
|
|
129
163
|
}
|
|
130
164
|
|
|
@@ -280,6 +314,12 @@ export async function buildReport(opts, deps = {}) {
|
|
|
280
314
|
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
|
|
281
315
|
}
|
|
282
316
|
const model = toModel(repo, gh);
|
|
317
|
+
if (opts.compounding) { // forge#4696: GitHub records + guards + git, no model
|
|
318
|
+
const inputs = await (deps.gatherCompounding ?? gatherCompounding)({ repo, gh: deps.gh ?? defaultGh });
|
|
319
|
+
const query = { compounding: compoundingReport({ ...inputs, runs: model.runs, events: model.events, windowMs: opts.windows?.[0] ?? DEFAULT_WINDOW_MS,
|
|
320
|
+
buckets: opts.bucket ?? DEFAULT_BUCKETS, now: deps.now?.() ?? Date.now(), minN: opts.minN }) };
|
|
321
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
|
|
322
|
+
}
|
|
283
323
|
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
284
324
|
let local = [];
|
|
285
325
|
if (opts.local) {
|
|
@@ -287,6 +327,12 @@ export async function buildReport(opts, deps = {}) {
|
|
|
287
327
|
}
|
|
288
328
|
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
289
329
|
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
330
|
+
if (opts.experiment) { // forge#4629
|
|
331
|
+
const exp = deps.experiment ?? readExperiment();
|
|
332
|
+
if (!exp || exp.id !== opts.experiment) throw new Error(`experiment ${opts.experiment} is not declared in forge.yaml (experiment: id, arms, ...)`);
|
|
333
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { experiment: experimentReport([...model.runs, ...local], { ...exp, minN: opts.minNSet ? opts.minN : exp.minN }) } };
|
|
334
|
+
}
|
|
335
|
+
if (opts.by) return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { release_metrics: releaseMetrics(report.groups, { minN: opts.minN, repo }) } };
|
|
290
336
|
// forge#4354: fail-soft alerts from the local run logs only (no gh call)
|
|
291
337
|
const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
|
|
292
338
|
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
|
|
@@ -299,6 +345,10 @@ export async function runStats(argv, deps = {}) {
|
|
|
299
345
|
return 2;
|
|
300
346
|
}
|
|
301
347
|
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
348
|
+
if (opts.requireMet && !opts.experiment) {
|
|
349
|
+
process.stderr.write(`forgedock stats: --require-met needs --experiment <id>\n`);
|
|
350
|
+
return 2;
|
|
351
|
+
}
|
|
302
352
|
opts.repo = resolveRepo(opts.repo);
|
|
303
353
|
if (!opts.repo || !isValidRepo(opts.repo)) {
|
|
304
354
|
process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
|
|
@@ -306,8 +356,17 @@ export async function runStats(argv, deps = {}) {
|
|
|
306
356
|
}
|
|
307
357
|
try {
|
|
308
358
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
359
|
+
if (report.query?.release_metrics) {
|
|
360
|
+
const rm = report.query.release_metrics;
|
|
361
|
+
if (opts.postMetrics) await postMetrics({ gh: deps.gh ?? defaultGh, repo: opts.repo, pr: opts.postMetrics, body: metricsComment(rm) });
|
|
362
|
+
process.stdout.write(`${opts.json ? renderJson(report) : renderReleaseMetrics(rm)}\n`);
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
if (report.query?.experiment && opts.emitEvent) await (deps.emitExperiment ?? (async (r, x) => (await import("../engine-cli.mjs")).experimentEventSink(r, x)))(opts.repo, report.query.experiment);
|
|
366
|
+
if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
|
|
309
367
|
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
310
368
|
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
369
|
+
if (report.query?.compounding && !opts.json) { process.stdout.write(`${renderCompounding(report.query.compounding)}\n`); return 0; }
|
|
311
370
|
if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
|
|
312
371
|
if (report.query?.efficiency && !opts.json) {
|
|
313
372
|
const e = report.query.efficiency;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4628: per-release regression rule over the `aggregate()` groups. Each metric is null when its own sample is
|
|
5
|
+
* below minN; a verdict is null when either side is null. There is no "pass" value, only regressed|improved|flat|null.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_MIN_N, UNKNOWN, compareVersions } from "./aggregate.mjs";
|
|
8
|
+
|
|
9
|
+
export const METRICS_MARKER = "<!-- FORGE:METRICS v1 -->";
|
|
10
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
11
|
+
// lower is better for all but the first-pass rate
|
|
12
|
+
const METRICS = [
|
|
13
|
+
{ key: "usd_median", label: "median cost/run", lowerBetter: true, flat: 0.05 },
|
|
14
|
+
{ key: "wall_p50_ms", label: "wall p50", lowerBetter: true, flat: 0.05 },
|
|
15
|
+
{ key: "first_pass_rate", label: "first-pass rate", lowerBetter: false, flat: 0.02 },
|
|
16
|
+
{ key: "needs_human_rate", label: "needs-human rate", lowerBetter: true, flat: 0.02 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The metric values of one group, each null below minN (its own n). */
|
|
20
|
+
function valuesOf(g, minN) {
|
|
21
|
+
const pick = (n, v) => (isNum(n) && n >= minN && isNum(v) ? v : null);
|
|
22
|
+
return {
|
|
23
|
+
n: g.n,
|
|
24
|
+
usd_median: pick(g.cost?.usdN, g.cost?.usdMedian),
|
|
25
|
+
wall_p50_ms: pick(g.time?.wall?.n, g.time?.wall?.p50),
|
|
26
|
+
first_pass_rate: pick(g.firstPass?.n, g.firstPass?.rate),
|
|
27
|
+
needs_human_rate: pick(g.flow?.n, g.flow?.needsHumanPct),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Relative change for cost and time, absolute for rates; flat inside the tolerance. */
|
|
32
|
+
function verdict(m, cur, prev) {
|
|
33
|
+
if (!isNum(cur) || !isNum(prev)) return null;
|
|
34
|
+
const rate = m.key.endsWith("_rate");
|
|
35
|
+
const d = rate ? cur - prev : prev === 0 ? (cur === 0 ? 0 : Infinity) : (cur - prev) / prev;
|
|
36
|
+
if (Math.abs(d) <= m.flat) return "flat";
|
|
37
|
+
return (d < 0) === m.lowerBetter ? "improved" : "regressed";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Known versions of one repo, oldest first, each with its metrics and the verdicts against the previous one. */
|
|
41
|
+
export function releaseMetrics(groups, { minN = DEFAULT_MIN_N, repo = null } = {}) {
|
|
42
|
+
const known = groups.filter((g) => g.version !== UNKNOWN && (repo == null || g.repo === repo))
|
|
43
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
44
|
+
const versions = known.map((g, i) => {
|
|
45
|
+
const metrics = valuesOf(g, minN), prev = i ? valuesOf(known[i - 1], minN) : null;
|
|
46
|
+
const verdicts = Object.fromEntries(METRICS.map((m) => [m.key, prev ? verdict(m, metrics[m.key], prev[m.key]) : null]));
|
|
47
|
+
return { version: g.version, against: i ? known[i - 1].version : null, ...metrics, verdicts };
|
|
48
|
+
});
|
|
49
|
+
return { repo, minN, versions };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fmt = (key, v) => (v == null ? "n/a" : key === "usd_median" ? `$${v.toFixed(2)}` : key === "wall_p50_ms" ? `${(v / 60000).toFixed(1)}m` : `${(v * 100).toFixed(0)}%`);
|
|
53
|
+
|
|
54
|
+
export function renderReleaseMetrics(rm) {
|
|
55
|
+
const rows = rm.versions.map((v) => [v.version, String(v.n), ...METRICS.map((m) => `${fmt(m.key, v[m.key])} (${v.verdicts[m.key] ?? "n/a"})`)]);
|
|
56
|
+
const head = ["version", "n", ...METRICS.map((m) => m.label)];
|
|
57
|
+
const line = (r) => `| ${r.join(" | ")} |`;
|
|
58
|
+
return [`Release metrics (min n ${rm.minN}; n/a below it)`, line(head), line(head.map(() => "---")), ...rows.map(line)].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The FORGE:METRICS comment: marker, table and the snapshot as fenced JSON. */
|
|
62
|
+
export function metricsComment(rm) {
|
|
63
|
+
return `${METRICS_MARKER}\n${renderReleaseMetrics(rm)}\n\n\`\`\`json\n${JSON.stringify(rm)}\n\`\`\`\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Create the FORGE:METRICS comment on the PR, or update the existing one (argv-based gh). */
|
|
67
|
+
export async function postMetrics({ gh, repo, pr, body }) {
|
|
68
|
+
const raw = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`]);
|
|
69
|
+
const pages = String(raw ?? "").trim().replace(/\]\s*\[/g, ",");
|
|
70
|
+
const list = pages ? JSON.parse(pages.startsWith("[") ? pages : `[${pages}]`) : [];
|
|
71
|
+
const mine = list.filter((c) => typeof c?.body === "string" && c.body.startsWith(METRICS_MARKER)).pop();
|
|
72
|
+
if (mine) await gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${mine.id}`, "-f", `body=${body}`]);
|
|
73
|
+
else await gh(["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
|
|
74
|
+
return mine ? "updated" : "created";
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { aggregate } from "./aggregate.mjs";
|
|
6
|
+
import { parseArgs } from "./index.mjs";
|
|
7
|
+
import { METRICS_MARKER, metricsComment, postMetrics, releaseMetrics, renderReleaseMetrics } from "./release.mjs";
|
|
8
|
+
|
|
9
|
+
const trig = (fp) => [{ trigger: "build" }, ...(fp ? [] : [{ trigger: "review" }])];
|
|
10
|
+
function runs(version, n, { usd, wall, fp, human }) {
|
|
11
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
12
|
+
source: "github", repo: "o/r", version, issue: i + (version === "1.2.0" ? 100 : 0),
|
|
13
|
+
finished: true, usageEvents: 1, reason: i < human ? "needs-human" : "merged", wallMs: wall, endTs: "2026-10-01T00:00:00Z",
|
|
14
|
+
phases: { build: { usd, cr: 1, ms: 1 } }, builderAttempts: trig(i < fp),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
const groupsFor = (all, minN = 10) => aggregate({ runs: all }, { minN }).groups;
|
|
18
|
+
|
|
19
|
+
describe("releaseMetrics (#4628)", () => {
|
|
20
|
+
const prev = runs("1.1.0", 12, { usd: 2, wall: 600000, fp: 6, human: 1 });
|
|
21
|
+
it("compares each metric with the previous release and never reports a pass", () => {
|
|
22
|
+
const cur = runs("1.2.0", 12, { usd: 3, wall: 300000, fp: 11, human: 1 });
|
|
23
|
+
const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
|
|
24
|
+
const v = rm.versions[1];
|
|
25
|
+
assert.equal(v.against, "1.1.0");
|
|
26
|
+
assert.deepEqual(v.verdicts, { usd_median: "regressed", wall_p50_ms: "improved", first_pass_rate: "improved", needs_human_rate: "flat" });
|
|
27
|
+
assert.equal(rm.versions[0].verdicts.usd_median, null);
|
|
28
|
+
assert.ok(!JSON.stringify(rm).includes("pass\""));
|
|
29
|
+
});
|
|
30
|
+
it("is null below min n, and the verdict is null when either side is", () => {
|
|
31
|
+
const cur = runs("1.2.0", 3, { usd: 9, wall: 1, fp: 0, human: 3 });
|
|
32
|
+
const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
|
|
33
|
+
const v = rm.versions[1];
|
|
34
|
+
assert.deepEqual([v.usd_median, v.wall_p50_ms, v.first_pass_rate, v.needs_human_rate], [null, null, null, null]);
|
|
35
|
+
assert.deepEqual(Object.values(v.verdicts), [null, null, null, null]);
|
|
36
|
+
assert.match(renderReleaseMetrics(rm), /n\/a/);
|
|
37
|
+
});
|
|
38
|
+
it("skips unknown versions", () => {
|
|
39
|
+
const rm = releaseMetrics(groupsFor(runs(null, 12, { usd: 1, wall: 1, fp: 1, human: 0 })), { minN: 1 });
|
|
40
|
+
assert.deepEqual(rm.versions, []);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("FORGE:METRICS comment (#4628)", () => {
|
|
45
|
+
const rm = releaseMetrics([], { minN: 10, repo: "o/r" });
|
|
46
|
+
it("carries the marker and the JSON snapshot", () => {
|
|
47
|
+
const b = metricsComment(rm);
|
|
48
|
+
assert.ok(b.startsWith(METRICS_MARKER));
|
|
49
|
+
assert.deepEqual(JSON.parse(/```json\n(.*)\n```/s.exec(b)[1]), rm);
|
|
50
|
+
});
|
|
51
|
+
it("creates the comment, then updates it", async () => {
|
|
52
|
+
const calls = [];
|
|
53
|
+
const mk = (list) => (args) => { calls.push(args.join(" ")); return args[0] === "api" && args.length === 3 ? JSON.stringify(list) : ""; };
|
|
54
|
+
assert.equal(await postMetrics({ gh: mk([{ id: 1, body: "x" }]), repo: "o/r", pr: 7, body: "b" }), "created");
|
|
55
|
+
assert.match(calls.at(-1), /-X POST repos\/o\/r\/issues\/7\/comments/);
|
|
56
|
+
assert.equal(await postMetrics({ gh: mk([{ id: 5, body: `${METRICS_MARKER}\nold` }]), repo: "o/r", pr: 7, body: "b" }), "updated");
|
|
57
|
+
assert.match(calls.at(-1), /-X PATCH repos\/o\/r\/issues\/comments\/5/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("--by / --post-metrics parsing (#4628)", () => {
|
|
62
|
+
it("accepts version and a PR number; rejects the rest", () => {
|
|
63
|
+
assert.equal(parseArgs(["--by", "version", "--post-metrics", "12"]).postMetrics, 12);
|
|
64
|
+
assert.throws(() => parseArgs(["--by", "repo"]), /--by must be version/);
|
|
65
|
+
assert.throws(() => parseArgs(["--post-metrics", "12"]), /needs --by version/);
|
|
66
|
+
assert.throws(() => parseArgs(["--by", "version", "--post-metrics", "x"]), /PR number/);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/commands/bootstrap.md
CHANGED
|
@@ -18,18 +18,20 @@ BOOT_TPL="$(forge_root)/templates/bootstrap"; VALIDATE=$(forge_resolve bootstrap
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
1. **Read the input.** Use the free text, or read each doc at the given path(s).
|
|
21
|
-
2. **Interview** from `interview.md`. Cover
|
|
22
|
-
3. **Fill the blanks.**
|
|
23
|
-
4. **Ask only genuine doubts** (
|
|
21
|
+
2. **Interview** from `interview.md`. Cover each aspect briefly; skip what the input answers.
|
|
22
|
+
3. **Fill the blanks.** Default every unanswered aspect with a one-line rationale, using the closest `stack-profiles.md` preset. Show them in ONE editable "Here's what I assumed" list and apply the user's edits.
|
|
23
|
+
4. **Ask only genuine doubts** (architecture or cost: native vs PWA, data residency, self-host vs managed DB, product name, repo owner). Ask with AskUserQuestion, at most 4 multiple-choice questions per batch, each with a recommended option. Everything else stays a default. Headless (no AskUserQuestion, e.g. `npx forgedock bootstrap`): take each recommended option and list it under "Here's what I assumed".
|
|
24
24
|
5. **Spike doc.** Write `technical-spike.md` to `--spike` (default `docs/engineering/technical-spike.md`); set the plan's `spikeDoc` to that path. Fill every section and give each decision an ADR-style `**Rationale**:` line.
|
|
25
25
|
6. **Plan JSON** in `$TMPDIR` (shape: header of `bootstrap-validate.mjs` and `bootstrap-create.mjs`):
|
|
26
|
-
- Milestones
|
|
26
|
+
- Milestones as dependency layers, foundation first: each depends only on earlier ones (#4659).
|
|
27
|
+
- A thin deployable slice (CI deploy workflow, staging env, seed) in milestone 1 or 2, never last.
|
|
27
28
|
- A `SPIKE` parent issue.
|
|
28
29
|
- Sub-issues from `issue-body.md` with planned paths, `[type:*]` tags, `priority` and `component`.
|
|
29
30
|
- `"ui": true` issues carry the full design spec. `"hero": true` issues route to `/design`.
|
|
30
|
-
- One `needs-human-issue.md` issue per credential, account or external input. Code issues reference it as `#{{KEY}}
|
|
31
|
+
- One `needs-human-issue.md` issue per credential, account or external input, with `gates:` (#4660). Code issues reference it as `#{{KEY}}`, never depend on it, and name the mock that keeps dev and CI green.
|
|
31
32
|
- Sub-issues point at the spike via `**Parent**` only, never `Depends on`.
|
|
32
33
|
- `guardrails` and `domainRules` from the interview.
|
|
33
|
-
7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Never create from an invalid plan.
|
|
34
|
-
8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched).
|
|
35
|
-
9. **Hand off.** Print the script's hand-off verbatim:
|
|
34
|
+
7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Heed `deploy-last` warnings. Never create from an invalid plan.
|
|
35
|
+
8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched). It reuses existing issues, so on an error re-run it to resume.
|
|
36
|
+
9. **Hand off.** Print the script's hand-off verbatim: created (links), needs a human (links), and the `/orchestrate` command per milestone, in order.
|
|
37
|
+
10. Later: `npx forgedock plan validate --live` (#4659).
|
package/commands/deploy-info.md
CHANGED
|
@@ -325,6 +325,7 @@ else
|
|
|
325
325
|
# Step 3: Count commits queued (commits in staging not yet in main)
|
|
326
326
|
QUEUED_COMMITS=$(git rev-list --count origin/{DEFAULT_BRANCH}..origin/{STAGING_BRANCH} 2>/dev/null || echo "?")
|
|
327
327
|
fi
|
|
328
|
+
INTEGRATION_RED=$(gh issue list {GH_FLAG} --label integration-red --json number,title --jq '.[]|"#\(.number) \(.title)"') # forge#4667
|
|
328
329
|
```
|
|
329
330
|
|
|
330
331
|
Train state is included in the Phase 5 output summary.
|
|
@@ -339,10 +340,12 @@ Train state is included in the Phase 5 output summary.
|
|
|
339
340
|
**Commits**: {N} | **PRs**: {N} | **Issues closed**: {N} | **Files changed**: {N}
|
|
340
341
|
|
|
341
342
|
### Deploy Train State
|
|
343
|
+
**Gate**: `node ${FORGEDOCK_HOME:-${CLAUDE_PLUGIN_ROOT}}/scripts/readiness.mjs --target {preview|staging|prod}`; NOT DEPLOYABLE (open prerequisites) means never call the target deployable (#4660).
|
|
342
344
|
**Train PR**: #{TRAIN_PR_NUMBER} (SHA: {TRAIN_PR_SHA}, opened: {TRAIN_PR_CREATED})
|
|
343
345
|
**Status**: {TRAIN_STATUS}
|
|
344
346
|
{If HELD: "**Held by**: {count} open finding(s) — fix and push to unblock:\n{HELD_BY}"}
|
|
345
347
|
{If CLEAR: "**Ready to merge**: no open findings on the train PR"}
|
|
348
|
+
{If INTEGRATION_RED: "**Ship-gate risk**: red integration run:\n{INTEGRATION_RED}"}
|
|
346
349
|
{If NO_TRAIN: "No open staging→main PR — run /review-pr-staging to open one"}
|
|
347
350
|
**Queued commits**: {QUEUED_COMMITS} (commits in staging not yet in main)
|
|
348
351
|
|
package/commands/issue.md
CHANGED
|
@@ -85,6 +85,8 @@ Duplicates: the script's token-overlap dedup is authoritative. Before drafting,
|
|
|
85
85
|
|
|
86
86
|
### 3. Draft
|
|
87
87
|
|
|
88
|
+
**External prerequisite** (credential, account, sign-off): label `needs-human:external` and put `gates: preview|staging-deploy|prod-deploy|feature-flag:<name>` and `owner:` in the body (#4660).
|
|
89
|
+
|
|
88
90
|
**Title**: `{prefix}({scope}): {specific description}`, max 80 characters, no vague verbs ("improve", "update", "handle") without specifics.
|
|
89
91
|
|
|
90
92
|
**Body** (pipeline template; `## Problem`, `## Affected Files` and `## Acceptance Criteria` are mandatory, the rest optional; in greenfield mode Affected Files may list *planned* paths inside the spike's declared repo layout):
|
package/commands/milestone.md
CHANGED
|
@@ -96,6 +96,8 @@ Parse `$ARGUMENTS` to determine which action to take:
|
|
|
96
96
|
| `drift <slug>` | → **Milestone Drift** |
|
|
97
97
|
| `close <slug>` | → **Close Milestone** |
|
|
98
98
|
|
|
99
|
+
Unattended multi-milestone runs use the CLI, not this spec: `forgedock milestone train <slug...> [--parallel-milestones N]` (resume with `--resume <id>`, add a milestone with `--insert <id> <slug>`). It keeps one state file, reuses the lifecycle above and never edits `forge.yaml`. See `devdocs/decisions/4658-milestone-train.md`.
|
|
100
|
+
|
|
99
101
|
---
|
|
100
102
|
|
|
101
103
|
## Action: Create Milestone
|
|
@@ -356,6 +358,10 @@ else
|
|
|
356
358
|
fi
|
|
357
359
|
```
|
|
358
360
|
|
|
361
|
+
### Step 6C: Re-validate dependency order
|
|
362
|
+
|
|
363
|
+
After a milestone is added, the train order changes or an issue is decomposed, run `npx forgedock plan validate --live` (#4659). Fix every `invalid` edge (a dependency in a later milestone) before continuing; `held` edges are reported only.
|
|
364
|
+
|
|
359
365
|
### Step 7: Report
|
|
360
366
|
|
|
361
367
|
```
|
|
@@ -444,37 +450,28 @@ git fetch origin milestone/{slug}
|
|
|
444
450
|
OPEN_COUNT=$(gh issue list --milestone "{TITLE}" --state open --json number --jq '. | length')
|
|
445
451
|
```
|
|
446
452
|
|
|
447
|
-
If there are open issues,
|
|
453
|
+
If there are open issues, this is a hard gate (interactive and unattended; unattended runs fail instead of asking). Every open issue must be **carried over** with a target milestone *and* a class (`engineering|external|post-deploy|wont-do`), or closed first. The class comes from labels (`carry:<class>`, `needs-human:external`, `awaiting-promotion|awaiting-precondition|awaiting-measurement`, `wont-do`; else `engineering`); the target is a `carry-to:<milestone title>` label or the user's answer. Run the check with `preflightCarryOver` (`bin/lean/lifecycle.mjs`):
|
|
454
|
+
|
|
448
455
|
```
|
|
449
|
-
⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues:
|
|
450
|
-
{list them}
|
|
456
|
+
⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues without a carry-over target:
|
|
457
|
+
{list them with their class}
|
|
451
458
|
|
|
452
|
-
Ship
|
|
459
|
+
Name a target milestone for each (or close it). Ship is blocked until every open issue has one.
|
|
453
460
|
```
|
|
454
461
|
|
|
455
|
-
|
|
462
|
+
An issue that is `needs-human:external`, flag/legal-gated (`flag-gated`, `legal-gated`) or depends on an issue that is not on the target base is **never** demoted to fast lane: it is carried to its target milestone.
|
|
456
463
|
|
|
457
464
|
### Step 2: Sync milestone branch with staging
|
|
458
465
|
|
|
459
|
-
Before shipping,
|
|
466
|
+
Before shipping, bring the milestone branch up to date with `staging` (picks up fast-lane fixes that accumulated since it was cut). `forgedock milestone-sync` does it deterministically (#4663): a milestone already `shipped:*`/`deployed:default`/`closed` is refused, a branch already containing staging is a no-op, append-only registry conflicts are resolved by the lane base-merge resolver (`lean.base_merge_*`), and a protected branch (ruleset or branch protection, or a push the rules reject) is synced through the single `sync/milestone-{slug}` PR with auto-merge armed (merged directly when CLEAN). A `ship` sync is never debounced.
|
|
460
467
|
|
|
461
468
|
```bash
|
|
462
469
|
cd {REPO_PATH}
|
|
463
|
-
|
|
464
|
-
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
465
|
-
SYNC_WT=$(mktemp -d)
|
|
466
|
-
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
467
|
-
if git -C "$SYNC_WT" merge origin/{STAGING_BRANCH} --no-edit; then
|
|
468
|
-
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
469
|
-
git worktree remove --force "$SYNC_WT"
|
|
470
|
-
else
|
|
471
|
-
# Conflicts: report to user and STOP — do not auto-resolve
|
|
472
|
-
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
473
|
-
git worktree remove --force "$SYNC_WT"
|
|
474
|
-
echo "STOP: merge conflict syncing milestone/{slug} with {STAGING_BRANCH}"; exit 1
|
|
475
|
-
fi
|
|
470
|
+
forgedock milestone-sync {slug} --reason ship --base {STAGING_BRANCH} --repo {owner}/{repo} || { echo "STOP: milestone sync failed or conflicts"; exit 1; }
|
|
476
471
|
```
|
|
477
472
|
|
|
473
|
+
If the sync went through a PR (`pr-created`/`pr-updated`), wait for it to merge before Step 2.5.
|
|
474
|
+
|
|
478
475
|
### Step 2.5: Pre-Merge Hunk-Loss Audit (MANDATORY before creating PR)
|
|
479
476
|
|
|
480
477
|
**WHY THIS EXISTS**: When a long-lived milestone branch is merged into staging, git hunks that exist in staging but were NOT touched by the milestone can be silently dropped. This happened in a 20h window that produced 13 regression-fix PRs (27% of total pipeline throughput) restoring lost code: postgres tuning params, completion_refund sweep, credit_ledger columns, browser flags, pool guards, Redis config, crawl TTL refresh, challenge_strategy guard. Each regression required investigation → new issue → new PR → review → merge — consuming nearly a third of pipeline capacity on damage repair instead of forward progress.
|
|
@@ -730,24 +727,28 @@ Run /milestone ship {slug} again when the milestone is ready for another shippin
|
|
|
730
727
|
```bash
|
|
731
728
|
gh pr merge {PR_NUMBER} --merge
|
|
732
729
|
|
|
733
|
-
# Step 6A:
|
|
734
|
-
#
|
|
735
|
-
|
|
736
|
-
|
|
730
|
+
# Step 6A: Carry over remaining open issues (forge#4657). Never clear the milestone: every open issue moves to its
|
|
731
|
+
# target milestone (Step 1 gate) and keeps its class label. Only an issue whose preflightCarryOver entry has demote=true
|
|
732
|
+
# and whose target is "fast lane" may lose its milestone; external, gated and off-base-dependent issues are never demoted.
|
|
733
|
+
OPEN_ISSUES=$(gh api "repos/{owner}/{repo}/issues?milestone={MS_NUMBER}&state=open&per_page=100" --jq '.[].number')
|
|
737
734
|
OPEN_COUNT=$(echo "$OPEN_ISSUES" | grep -c '[0-9]' 2>/dev/null || echo 0)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
echo "
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
735
|
+
for ISSUE_NUM in $OPEN_ISSUES; do
|
|
736
|
+
gh issue edit "$ISSUE_NUM" --milestone "{TARGET_TITLE_OF_ISSUE}" --add-label "carry:{CLASS_OF_ISSUE}" # <!-- allowlist:check-command-side-effects -->
|
|
737
|
+
echo " Carried #$ISSUE_NUM -> {TARGET_TITLE_OF_ISSUE} ({CLASS_OF_ISSUE})"
|
|
738
|
+
done
|
|
739
|
+
|
|
740
|
+
# Step 6B: Record the lifecycle. The merge is on staging, not yet on the default branch: write
|
|
741
|
+
# a FORGE:LIFECYCLE record ({"v":1,"state":"shipped:staging","sha":"<merge commit>"}) in the milestone description and
|
|
742
|
+
# close the milestone only when forge.yaml `milestones.close_on` (default `default`) is reached. With close_on=default the
|
|
743
|
+
# milestone stays open until the staging -> default merge is observed (deployed:default), then it closes.
|
|
744
|
+
MERGE_SHA=$(gh pr view {PR_NUMBER} --json mergeCommit --jq '.mergeCommit.oid')
|
|
745
|
+
DESC=$(gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --jq '.description // ""')
|
|
746
|
+
TAG="FORGE:LIFECYCLE"
|
|
747
|
+
BLOCK="<!-- $TAG {\"v\":1,\"state\":\"shipped:staging\",\"sha\":\"$MERGE_SHA\"} -->"
|
|
748
|
+
gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field description="$(printf '%s\n\n%s' "$(printf '%s' "$DESC" | sed "/$TAG/d")" "$BLOCK")"
|
|
749
|
+
# close_on=staging only (and only when no issue is left open on the milestone):
|
|
750
|
+
# gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field state="closed"
|
|
751
|
+
# Orchestrate-driven runners get the same transition from bin/lean/lifecycle.mjs advanceMilestone at batch end.
|
|
751
752
|
|
|
752
753
|
# Step 6C: Delete the milestone branch
|
|
753
754
|
git push origin --delete milestone/{slug}
|
|
@@ -758,8 +759,8 @@ Report:
|
|
|
758
759
|
```
|
|
759
760
|
## Shipped: {MILESTONE_TITLE}
|
|
760
761
|
|
|
761
|
-
Merged to staging. Milestone branch deleted.
|
|
762
|
-
{If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s)
|
|
762
|
+
Merged to staging (lifecycle: shipped:staging). Milestone branch deleted. Milestone closes at `milestones.close_on` ({close_on}).
|
|
763
|
+
{If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s) carried over: #{list} -> {target} ({class}). None were demoted to fast lane."}
|
|
763
764
|
{If OPEN_COUNT == 0: "No open issues remaining — clean close."}
|
|
764
765
|
|
|
765
766
|
To deploy: merge `staging → main` via GitHub web UI when ready.
|
|
@@ -769,27 +770,15 @@ To deploy: merge `staging → main` via GitHub web UI when ready.
|
|
|
769
770
|
|
|
770
771
|
## Action: Sync Milestone Branch
|
|
771
772
|
|
|
772
|
-
Syncs the milestone branch with the latest default branch to pick up fast-lane fixes.
|
|
773
|
+
Syncs the milestone branch with the latest default branch to pick up fast-lane fixes. Sync **on demand only**: before ship, before a batch, or when a conflict is expected. Never on every base move, and never a milestone whose lifecycle is `shipped:*` (the command refuses it; a shipped milestone is also never the "active" one to sync, `isSyncable` in `bin/lean/milestonesync.mjs`).
|
|
773
774
|
|
|
774
775
|
```bash
|
|
775
776
|
cd {REPO_PATH}
|
|
776
777
|
DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
|
|
777
|
-
|
|
778
|
-
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
779
|
-
SYNC_WT=$(mktemp -d)
|
|
780
|
-
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
781
|
-
if git -C "$SYNC_WT" merge origin/$DEFAULT_BRANCH --no-edit; then
|
|
782
|
-
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
783
|
-
git worktree remove --force "$SYNC_WT"
|
|
784
|
-
else
|
|
785
|
-
# If conflicts, report to user and STOP
|
|
786
|
-
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
787
|
-
git worktree remove --force "$SYNC_WT"
|
|
788
|
-
echo "STOP: merge conflict syncing milestone/{slug} with $DEFAULT_BRANCH"; exit 1
|
|
789
|
-
fi
|
|
778
|
+
forgedock milestone-sync {slug} --reason manual --base "$DEFAULT_BRANCH" --repo {owner}/{repo}
|
|
790
779
|
```
|
|
791
780
|
|
|
792
|
-
|
|
781
|
+
Protected branches get ONE sync PR (`sync/milestone-{slug}`) whose head is force-pushed in place; a base that moved again inside `milestones.sync_debounce` (default 10m) reports `debounced` and leaves the PR (and its CI) alone. Report the printed status.
|
|
793
782
|
|
|
794
783
|
---
|
|
795
784
|
|
|
@@ -828,5 +817,5 @@ This automatically makes the issue a feature-lane issue — next time `/work-on`
|
|
|
828
817
|
|
|
829
818
|
- **Milestone already exists**: Check first with `gh api repos/{owner}/{repo}/milestones --jq '.[] | select(.title == "{TITLE}")'`
|
|
830
819
|
- **Branch already exists**: Reuse it, don't recreate
|
|
831
|
-
- **Merge conflicts on sync/ship**:
|
|
820
|
+
- **Merge conflicts on sync/ship**: append-only registry conflicts matching `lean.base_merge_*` are resolved deterministically by `milestone-sync`; any other conflict is reported with its files and the branch is left untouched. Do NOT hand-resolve.
|
|
832
821
|
- **No issues in milestone**: Warn user — empty milestones are valid but unusual
|
|
@@ -1581,6 +1581,8 @@ The merged-trail hold on #${N} was released by a human (needs-human cleared or t
|
|
|
1581
1581
|
return 0
|
|
1582
1582
|
}
|
|
1583
1583
|
|
|
1584
|
+
# gh failures are unknown, not state (forge#4662): in every hold/settle/ship read, a failed gh call (HTTP 401, 5xx, timeout) is never
|
|
1585
|
+
# "not closed" or "no PR". Keep the previous classification, back off, and after 5 consecutive failures post one alert and stop that loop.
|
|
1584
1586
|
# classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
|
|
1585
1587
|
# trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
|
|
1586
1588
|
# (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
|