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
package/scripts/hotspots.sh
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# yq (optional) only to read forge.yaml. Run from the repo root.
|
|
7
7
|
#
|
|
8
8
|
# Usage: hotspots.sh [--base <ref>] [--] <path>...
|
|
9
|
-
# Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[]
|
|
9
|
+
# Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[] (lockfiles are always excluded)
|
|
10
10
|
# (also when forge.yaml or yq is missing); maintenance.generated_paths (forge#4519): shell globs of generated,
|
|
11
11
|
# bot-owned files, default [].
|
|
12
12
|
# Output (stdout, one JSON object):
|
|
@@ -57,7 +57,28 @@ if [ -n "$BASE" ]; then
|
|
|
57
57
|
MB=$(git merge-base "$BASE" HEAD) || die "no merge base for $BASE and HEAD"
|
|
58
58
|
fi
|
|
59
59
|
HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null && HAS_HEAD=1
|
|
60
|
-
|
|
60
|
+
# #4685: lockfiles are never hotspots (one dependency adds hundreds of lines); configured globs come on top
|
|
61
|
+
LOCKFILE_GLOBS='package-lock.json
|
|
62
|
+
*/package-lock.json
|
|
63
|
+
yarn.lock
|
|
64
|
+
*/yarn.lock
|
|
65
|
+
pnpm-lock.yaml
|
|
66
|
+
*/pnpm-lock.yaml
|
|
67
|
+
bun.lockb
|
|
68
|
+
*/bun.lockb
|
|
69
|
+
Cargo.lock
|
|
70
|
+
*/Cargo.lock
|
|
71
|
+
poetry.lock
|
|
72
|
+
*/poetry.lock
|
|
73
|
+
uv.lock
|
|
74
|
+
*/uv.lock
|
|
75
|
+
go.sum
|
|
76
|
+
*/go.sum
|
|
77
|
+
Gemfile.lock
|
|
78
|
+
*/Gemfile.lock
|
|
79
|
+
composer.lock
|
|
80
|
+
*/composer.lock'
|
|
81
|
+
GLOBS=$(printf '%s\n%s' "$LOCKFILE_GLOBS" "$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')")
|
|
61
82
|
GEN_GLOBS=$(printf '%s' "$GEN_CFG" | jq -r '.[]')
|
|
62
83
|
# 0 when $1 matches a newline-separated list of shell patterns in $2 (`*` crosses `/`)
|
|
63
84
|
glob_hit() {
|
package/scripts/hotspots.test.sh
CHANGED
|
@@ -116,5 +116,14 @@ if grep -q '"rev-list"' "$TMP/trace.json"; then ok; else bad "git trace not capt
|
|
|
116
116
|
if grep -qE '"(cat-file|show)"' "$TMP/trace.json"; then bad "blob read without --base"; else ok; fi
|
|
117
117
|
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
118
118
|
|
|
119
|
+
# ---- #4685: lockfiles are excluded by default, with no forge.yaml; ordinary files are not ----
|
|
120
|
+
new_repo
|
|
121
|
+
lines web.lock 10; mkdir -p web; lines web/package-lock.json 10; lines app.py 10; commit base
|
|
122
|
+
git checkout -q -b feat2
|
|
123
|
+
lines more 900; cat more >> web/package-lock.json; cat more >> app.py; commit grow
|
|
124
|
+
OUT="$(bash "$S" --base main -- web/package-lock.json app.py)"
|
|
125
|
+
eq "lockfile excluded" "$(q '.files[0] | [.excluded,.growth]')" '[true,900]'
|
|
126
|
+
eq "ordinary file not excluded" "$(q '.files[1].excluded')" 'false'
|
|
127
|
+
|
|
119
128
|
echo "hotspots.test.sh: $PASS passed, $FAILN failed"
|
|
120
129
|
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/plan-validate-live.mjs — bootstrap-validate's dependency-order rules on LIVE GitHub state (#4659)
|
|
4
|
+
*
|
|
5
|
+
* `forgedock plan validate --live` and the parallel-start gate in `forgedock orchestrate`. Deterministic, no LLM.
|
|
6
|
+
* Milestone (train) order: due_on ascending (nulls last), then milestone number; GitHub has no explicit order field.
|
|
7
|
+
* Each cross-milestone `Depends on` edge is `ok` (dep in an earlier milestone and closed), `held` (earlier, not
|
|
8
|
+
* shipped) or `invalid` (dep in a later milestone). Edges are read with the engine's parseDependsOn.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parseDependsOn } from "../bin/engine/phases.mjs";
|
|
13
|
+
|
|
14
|
+
/** Milestones in train order. Input: [{ title, number, due_on }]. */
|
|
15
|
+
export function orderMilestones(milestones) {
|
|
16
|
+
const due = (m) => (m?.due_on ? Date.parse(m.due_on) : Infinity);
|
|
17
|
+
return [...milestones].sort((a, b) => (due(a) - due(b) || 0) || (a.number ?? 0) - (b.number ?? 0)).map((m) => m.title);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ order: string[], issues: Array<{ number: number, milestone: string|null, body?: string, state?: string }> }} live
|
|
22
|
+
* @returns {{ ok: boolean, edges: Array<{ from: number, to: number, fromMilestone: string, toMilestone: string, state: "ok"|"held"|"invalid" }> }}
|
|
23
|
+
*/
|
|
24
|
+
export function classifyEdges({ order, issues }) {
|
|
25
|
+
const index = new Map(order.map((t, i) => [t, i]));
|
|
26
|
+
const byNumber = new Map(issues.map((it) => [it.number, it]));
|
|
27
|
+
const edges = [];
|
|
28
|
+
for (const it of issues) {
|
|
29
|
+
if (!it.milestone || !index.has(it.milestone)) continue;
|
|
30
|
+
for (const n of parseDependsOn(it.body, it.number)) {
|
|
31
|
+
const dep = byNumber.get(n);
|
|
32
|
+
if (!dep?.milestone || dep.milestone === it.milestone || !index.has(dep.milestone)) continue;
|
|
33
|
+
const state = index.get(dep.milestone) > index.get(it.milestone) ? "invalid"
|
|
34
|
+
: String(dep.state).toUpperCase() === "CLOSED" ? "ok" : "held";
|
|
35
|
+
edges.push({ from: it.number, to: n, fromMilestone: it.milestone, toMilestone: dep.milestone, state });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: !edges.some((e) => e.state === "invalid"), edges };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatLiveReport({ edges }) {
|
|
42
|
+
if (!edges.length) return "plan validate --live: no cross-milestone edges";
|
|
43
|
+
const lines = edges.map((e) => `${e.state}: #${e.from} (${e.fromMilestone}) depends on #${e.to} (${e.toMilestone})`);
|
|
44
|
+
const n = (s) => edges.filter((e) => e.state === s).length;
|
|
45
|
+
return `${lines.join("\n")}\nplan validate --live: ${n("ok")} ok, ${n("held")} held, ${n("invalid")} invalid`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parse = (s) => { try { return JSON.parse(String(s)); } catch { return null; } };
|
|
49
|
+
|
|
50
|
+
/** Read milestones and all issues of `repo` through `gh` (injectable: async (args) => stdout). */
|
|
51
|
+
export async function readLive({ gh, repo }) {
|
|
52
|
+
const r = repo ? ["-R", repo] : [];
|
|
53
|
+
const ms = parse(await gh(["api", `repos/${repo || "{owner}/{repo}"}/milestones?state=all&per_page=100`, "--paginate", "--slurp"]));
|
|
54
|
+
const issues = parse(await gh(["issue", "list", ...r, "--state", "all", "--limit", "1000", "--json", "number,body,state,milestone"]));
|
|
55
|
+
if (!Array.isArray(ms) || !Array.isArray(issues)) throw new Error("could not read milestones/issues from GitHub");
|
|
56
|
+
return { order: orderMilestones(ms.flat()), issues: issues.map((i) => ({ ...i, milestone: i.milestone?.title ?? null })) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
|
|
61
|
+
* A dep is merged when it is CLOSED by a PR that is merged with that base. Unreadable issues are skipped (fail open:
|
|
62
|
+
* the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
|
|
63
|
+
* @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string }>>}
|
|
64
|
+
*/
|
|
65
|
+
export async function unmetDependencies({ issues, baseOf, gh, repo }) {
|
|
66
|
+
const r = repo ? ["-R", repo] : [];
|
|
67
|
+
const view = async (n, fields) => parse(await gh(["issue", "view", String(n), ...r, "--json", fields]));
|
|
68
|
+
const inBatch = new Set(issues);
|
|
69
|
+
const unmet = [];
|
|
70
|
+
for (const n of issues) {
|
|
71
|
+
let rec;
|
|
72
|
+
try { rec = await view(n, "body,milestone"); } catch { rec = null; }
|
|
73
|
+
if (!rec || !rec.milestone) continue;
|
|
74
|
+
const base = await baseOf(n);
|
|
75
|
+
if (!base) continue;
|
|
76
|
+
for (const dep of parseDependsOn(rec.body, n).filter((d) => !inBatch.has(d))) {
|
|
77
|
+
let why = null;
|
|
78
|
+
try {
|
|
79
|
+
const d = await view(dep, "state,closedByPullRequestsReferences");
|
|
80
|
+
if (String(d?.state).toUpperCase() !== "CLOSED") why = "not closed";
|
|
81
|
+
else {
|
|
82
|
+
let merged = false;
|
|
83
|
+
for (const pr of d.closedByPullRequestsReferences ?? []) {
|
|
84
|
+
const p = parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt"]));
|
|
85
|
+
if (p?.mergedAt && p.baseRefName === base) { merged = true; break; }
|
|
86
|
+
}
|
|
87
|
+
if (!merged) why = `closed but not merged into ${base}`;
|
|
88
|
+
}
|
|
89
|
+
} catch { why = "unreadable"; }
|
|
90
|
+
if (why) unmet.push({ issue: n, dep, base, why });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return unmet;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ghExec = async (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 100 * 1024 * 1024, timeout: 60000 });
|
|
97
|
+
|
|
98
|
+
/** CLI body: `plan validate --live [--repo owner/repo] [--json]`. Returns the exit code (1 when any edge is invalid). */
|
|
99
|
+
export async function runLive(argv, { gh = ghExec, out = (t) => process.stdout.write(`${t}\n`), err = (t) => process.stderr.write(`${t}\n`) } = {}) {
|
|
100
|
+
if (argv[0] !== "validate" || !argv.includes("--live")) { err("usage: forgedock plan validate --live [--repo owner/repo] [--json]"); return 2; }
|
|
101
|
+
const i = argv.indexOf("--repo");
|
|
102
|
+
const repo = i >= 0 ? argv[i + 1] : "";
|
|
103
|
+
let report;
|
|
104
|
+
try { report = classifyEdges(await readLive({ gh, repo })); } catch (e) { err(`plan validate --live: ${e.message}`); return 2; }
|
|
105
|
+
out(argv.includes("--json") ? JSON.stringify(report) : formatLiveReport(report));
|
|
106
|
+
return report.ok ? 0 : 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
110
|
+
process.exitCode = await runLive(process.argv.slice(2));
|
|
111
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// readiness.mjs — preview/deploy gate readiness from external prerequisites (forge#4660).
|
|
3
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
//
|
|
6
|
+
// Usage: readiness.mjs [--repo|-R owner/name] [--target preview|staging|prod] [--digest] [--issues file.json|-]
|
|
7
|
+
//
|
|
8
|
+
// A prerequisite is an open issue labelled `needs-human:external` whose body has a `gates:` line
|
|
9
|
+
// (`preview|staging-deploy|prod-deploy|feature-flag:<name>`, comma separated) and an `owner:` line.
|
|
10
|
+
// A code issue that carries its own `gates:` line names a milestone the gate still needs.
|
|
11
|
+
// Default output is the Readiness report; --digest prints the ordered human actions; --target prints
|
|
12
|
+
// DEPLOYABLE or NOT DEPLOYABLE for that target and exits 1 when not deployable.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
export const EXTERNAL_LABEL = "needs-human:external";
|
|
18
|
+
const GATE_RE = /^(?:preview|staging-deploy|prod-deploy|feature-flag:[A-Za-z0-9._-]+)$/;
|
|
19
|
+
/** Deploy target -> the gate whose open prerequisites make it not deployable. */
|
|
20
|
+
export const TARGET_GATE = { preview: "preview", staging: "staging-deploy", prod: "prod-deploy" };
|
|
21
|
+
|
|
22
|
+
const labelNames = (i) => (Array.isArray(i?.labels) ? i.labels.map((l) => (typeof l === "string" ? l : l?.name)) : []);
|
|
23
|
+
|
|
24
|
+
/** Valid gate names from the issue body's `gates:` line(s); invalid entries are dropped. */
|
|
25
|
+
export function parseGates(body) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const m of String(body ?? "").matchAll(/^[ \t]*gates:[ \t]*(.*)$/gim)) {
|
|
28
|
+
for (const g of m[1].split(",").map((s) => s.trim().replace(/^`|`$/g, ""))) if (GATE_RE.test(g) && !out.includes(g)) out.push(g);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `owner:` value, or null. */
|
|
34
|
+
export function parseOwner(body) {
|
|
35
|
+
const m = /^[ \t]*owner:[ \t]*(\S.*?)[ \t]*$/im.exec(String(body ?? ""));
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const deps = (body) => {
|
|
40
|
+
const m = /^[ \t]*depends on[ \t]*:?[ \t]*(.*)$/gim;
|
|
41
|
+
const out = new Set();
|
|
42
|
+
for (const x of String(body ?? "").matchAll(m)) for (const n of x[1].matchAll(/#(\d+)/g)) out.add(Number(n[1]));
|
|
43
|
+
return [...out];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `issues`: open issues { number, title, body, labels, milestone?, createdAt? }; `now`: ms.
|
|
48
|
+
* Returns { gates: { <gate>: { red, blocksMerge, prerequisites:[{number,title,owner,ageDays}], milestones:[title] } }, digest:[...] }.
|
|
49
|
+
*/
|
|
50
|
+
export function readiness(issues, now = Date.now()) {
|
|
51
|
+
const list = Array.isArray(issues) ? issues : [];
|
|
52
|
+
const prereqs = list.filter((i) => labelNames(i).includes(EXTERNAL_LABEL));
|
|
53
|
+
const gates = {};
|
|
54
|
+
const slot = (g) => (gates[g] ??= { red: true, blocksMerge: false, prerequisites: [], milestones: [] });
|
|
55
|
+
const entry = (i) => {
|
|
56
|
+
const t = Date.parse(i.createdAt);
|
|
57
|
+
return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null };
|
|
58
|
+
};
|
|
59
|
+
for (const i of prereqs) for (const g of parseGates(i.body)) slot(g).prerequisites.push(entry(i));
|
|
60
|
+
for (const i of list) {
|
|
61
|
+
if (labelNames(i).includes(EXTERNAL_LABEL)) continue;
|
|
62
|
+
const ms = typeof i.milestone === "string" ? i.milestone : i.milestone?.title;
|
|
63
|
+
if (!ms) continue;
|
|
64
|
+
for (const g of parseGates(i.body)) if (gates[g] && !gates[g].milestones.includes(ms)) gates[g].milestones.push(ms);
|
|
65
|
+
}
|
|
66
|
+
return { gates, digest: digest(prereqs, now) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Open prerequisites in dependency order (a prerequisite follows the open ones it depends on); `after` lists those. */
|
|
70
|
+
export function digest(prereqs, now = Date.now()) {
|
|
71
|
+
const byNum = new Map(prereqs.map((i) => [i.number, i]));
|
|
72
|
+
const after = (i) => deps(i.body).filter((n) => n !== i.number && byNum.has(n));
|
|
73
|
+
const done = new Set(), order = [], visiting = new Set();
|
|
74
|
+
const visit = (i) => {
|
|
75
|
+
if (done.has(i.number) || visiting.has(i.number)) return; // a cycle is cut, never loops
|
|
76
|
+
visiting.add(i.number);
|
|
77
|
+
for (const n of after(i)) visit(byNum.get(n));
|
|
78
|
+
visiting.delete(i.number);
|
|
79
|
+
done.add(i.number);
|
|
80
|
+
order.push(i);
|
|
81
|
+
};
|
|
82
|
+
[...prereqs].sort((a, b) => a.number - b.number).forEach(visit);
|
|
83
|
+
return order.map((i) => {
|
|
84
|
+
const t = Date.parse(i.createdAt);
|
|
85
|
+
return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), gates: parseGates(i.body),
|
|
86
|
+
ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null, after: after(i), unblocked: after(i).length === 0 };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `{ deployable, openPrerequisites }` for a target (preview|staging|prod). A feature-flag gate never affects it. */
|
|
91
|
+
export function deployable(report, target) {
|
|
92
|
+
const g = report.gates[TARGET_GATE[target]];
|
|
93
|
+
const open = g ? g.prerequisites : [];
|
|
94
|
+
return { deployable: open.length === 0, openPrerequisites: open };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const who = (p) => `#${p.number} ${p.title.slice(0, 60)} (owner: ${p.owner ?? "unassigned"}, age: ${p.ageDays ?? "?"}d)`;
|
|
98
|
+
|
|
99
|
+
export function formatReadinessReport(report) {
|
|
100
|
+
const names = Object.keys(report.gates).sort();
|
|
101
|
+
if (!names.length) return "Readiness: no open external prerequisites";
|
|
102
|
+
const lines = ["Readiness (open external prerequisites per gate):"];
|
|
103
|
+
for (const g of names) {
|
|
104
|
+
const x = report.gates[g];
|
|
105
|
+
const flag = g.startsWith("feature-flag:") ? " (flag-gated features may merge; gate stays red)" : "";
|
|
106
|
+
lines.push(` ${g}: RED${flag}`, ...x.prerequisites.map((p) => ` - ${who(p)}`));
|
|
107
|
+
lines.push(` code milestones needed: ${x.milestones.length ? x.milestones.join(", ") : "none recorded"}`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatReadinessDigest(report) {
|
|
113
|
+
if (!report.digest.length) return "Digest: no open human actions";
|
|
114
|
+
return ["Digest (human actions, dependency order):", ...report.digest.map((d, i) =>
|
|
115
|
+
` ${i + 1}. #${d.number} ${d.title.slice(0, 60)} [${d.gates.join(",") || "no gates"}] owner: ${d.owner ?? "unassigned"}${d.unblocked ? " - unblocked" : ` - after ${d.after.map((n) => `#${n}`).join(", ")}`}`)].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function main(argv) {
|
|
119
|
+
const opt = (f) => { const k = argv.indexOf(f); return k >= 0 ? argv[k + 1] : undefined; };
|
|
120
|
+
const src = opt("--issues");
|
|
121
|
+
let issues;
|
|
122
|
+
if (src) issues = JSON.parse(readFileSync(src === "-" ? 0 : src, "utf8"));
|
|
123
|
+
else {
|
|
124
|
+
const repo = opt("--repo") ?? opt("-R");
|
|
125
|
+
const r = spawnSync("gh", ["issue", "list", ...(repo ? ["-R", repo] : []), "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,createdAt"], { encoding: "utf8" });
|
|
126
|
+
if (r.status !== 0) { console.error("readiness: gh issue list failed"); return 2; }
|
|
127
|
+
issues = JSON.parse(r.stdout || "[]");
|
|
128
|
+
}
|
|
129
|
+
const report = readiness(issues);
|
|
130
|
+
const target = opt("--target");
|
|
131
|
+
if (target) {
|
|
132
|
+
if (!TARGET_GATE[target]) { console.error("readiness: --target must be preview|staging|prod"); return 2; }
|
|
133
|
+
const d = deployable(report, target);
|
|
134
|
+
console.log(d.deployable ? `DEPLOYABLE ${target}` : [`NOT DEPLOYABLE ${target}: ${TARGET_GATE[target]} gate has ${d.openPrerequisites.length} open prerequisite(s)`, ...d.openPrerequisites.map((p) => ` - ${who(p)}`)].join("\n"));
|
|
135
|
+
return d.deployable ? 0 : 1;
|
|
136
|
+
}
|
|
137
|
+
console.log(argv.includes("--digest") ? formatReadinessDigest(report) : formatReadinessReport(report));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
|
|
@@ -232,6 +232,21 @@ dom_f API_DESIGN "router|routes"
|
|
|
232
232
|
# --- Phase 2: read-only checks + the trusted full-build config gate ---
|
|
233
233
|
P2_FINDINGS=""
|
|
234
234
|
p2() { P2_FINDINGS="${P2_FINDINGS}${1}"$'\n'; }
|
|
235
|
+
# --- review.context staleness (#4666): advisory, never blocks. Unreadable config/checker/gh = unknown, not stale ---
|
|
236
|
+
CTX_GENERATED_AT=""; CTX_STALE="false"; CTX_ADVISORY="false"
|
|
237
|
+
_rc=""; if type forge_root >/dev/null 2>&1; then _rc="$(forge_root 2>/dev/null)/bin/review-context.mjs"; fi
|
|
238
|
+
if [ "$CONFIG_READABLE" = "true" ] && [ -f "$_rc" ] && command -v node >/dev/null 2>&1; then
|
|
239
|
+
_rcj=$(node "$_rc" check --config "$CONFIG_FILE" --root "${TOP:-$(dirname "$CONFIG_FILE")}" --repo "$REPO" --json 2>/dev/null || true)
|
|
240
|
+
if printf '%s' "$_rcj" | jq -e . >/dev/null 2>&1; then
|
|
241
|
+
CTX_GENERATED_AT=$(printf '%s' "$_rcj" | jq -r '.generated_at // ""')
|
|
242
|
+
[ "$(printf '%s' "$_rcj" | jq -r '.stale')" = "true" ] && CTX_STALE="true"
|
|
243
|
+
[ "$(printf '%s' "$_rcj" | jq -r '.advisory')" = "true" ] && CTX_ADVISORY="true"
|
|
244
|
+
while IFS= read -r _l; do [ -n "$_l" ] && p2 "REVIEW_CONTEXT_STALE: $_l"; done <<EOFRC
|
|
245
|
+
$(printf '%s' "$_rcj" | jq -r '.reasons[]?.detail')
|
|
246
|
+
EOFRC
|
|
247
|
+
[ "$CTX_ADVISORY" = "true" ] && p2 "REVIEW_CONTEXT_STALE: context generated ${CTX_GENERATED_AT}, $(printf '%s' "$_rcj" | jq -r '.merged_since') PRs merged since (limit $(printf '%s' "$_rcj" | jq -r '.max_merged_prs')): advisory"
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
235
250
|
HAS_TS=$(printf '%s\n' "$FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
|
|
236
251
|
HAS_PY=$(printf '%s\n' "$FILES" | grep -E '\.py$' | head -1)
|
|
237
252
|
TS_BUILD=$(cfg .verification.commands.typescript.build); PY_FORMAT=$(cfg .verification.commands.python.format)
|
|
@@ -664,6 +679,7 @@ CTX=$(jq -n \
|
|
|
664
679
|
--argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
|
|
665
680
|
--argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
|
|
666
681
|
--arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
|
|
682
|
+
--arg cgen "$CTX_GENERATED_AT" --arg cstale "$CTX_STALE" --arg cadv "$CTX_ADVISORY" \
|
|
667
683
|
--arg rdepth "$REVIEW_DEPTH" --arg rweight "$REVIEW_WEIGHT" --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
|
|
668
684
|
--arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --slurpfile dfiles "$TMP_DIR/dfiles.json" \
|
|
669
685
|
--arg pname "$PROJECT_NAME" --rawfile pctx "$TMP_DIR/pctx.txt" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
|
|
@@ -684,7 +700,7 @@ CTX=$(jq -n \
|
|
|
684
700
|
contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
|
|
685
701
|
roster: {tier: $rtier, review_depth: $rdepth, review_weight: $rweight, reason: $rreason, agents: $agents},
|
|
686
702
|
delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles[0]},
|
|
687
|
-
config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
703
|
+
config: {project_name: $pname, project_context: $pctx, context_generated_at: $cgen, context_stale: ($cstale == "true"), context_advisory: ($cadv == "true"), scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
688
704
|
domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
|
|
689
705
|
templates: {source: $tsrc, base: $tbase, monolithic_catalog: $tmono},
|
|
690
706
|
index_slices: {auth: $i_auth, billing: $i_billing, database: $i_db, api: $i_api, frontend: $i_fe, infrastructure: $i_infra},
|
|
@@ -488,5 +488,22 @@ eq "oversized slices JSON is over 128 KiB" '.slices | tojson | length > 131072'
|
|
|
488
488
|
eq "oversized security slice keeps every path" '.slices.security.paths | length' 1100
|
|
489
489
|
eq "oversized full_files keeps every path" '.full_files | length' 1100
|
|
490
490
|
|
|
491
|
+
# --- 13. stale review.context (#4666): REVIEW_CONTEXT_STALE finding + config flags; a fresh context emits neither ---
|
|
492
|
+
printf '{"name":"app","dependencies":{"react":"18"}}\n' > "$REPO_DIR/package.json"
|
|
493
|
+
STALE_CFG="$WORK/stale.yaml"
|
|
494
|
+
printf 'project:\n name: Demo\nreview:\n tech_stack: "Next.js 15, React"\n context: "Repo is docs-only; no code yet."\n context_generated_at: "2026-01-01"\n' > "$STALE_CFG"
|
|
495
|
+
setup feat/x staging "services/api/auth.py" "+x = 1"
|
|
496
|
+
CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
|
|
497
|
+
rc_is "stale context does not block" 0
|
|
498
|
+
eq "stale context flagged in config" '.config.context_stale' true
|
|
499
|
+
eq "stale context carries its generation date" '.config.context_generated_at' 2026-01-01
|
|
500
|
+
eq "stale context emits REVIEW_CONTEXT_STALE (docs-only)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("docs-only"))] | length' 1
|
|
501
|
+
eq "stale context emits REVIEW_CONTEXT_STALE (tech_stack)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("Next.js"))] | length' 1
|
|
502
|
+
printf 'project:\n name: Demo\nreview:\n tech_stack: "React"\n context: "A React app."\n' > "$STALE_CFG"
|
|
503
|
+
CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
|
|
504
|
+
eq "fresh context is not stale" '.config.context_stale' false
|
|
505
|
+
eq "fresh context emits no REVIEW_CONTEXT_STALE" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:"))] | length' 0
|
|
506
|
+
rm -f "$REPO_DIR/package.json"
|
|
507
|
+
|
|
491
508
|
echo "review-prepare tests: $pass passed, $fail failed"
|
|
492
509
|
[ "$fail" -eq 0 ]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/settle-scope.mjs — scope freeze and convergence for a milestone's settle (#4665). Pure, no I/O, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Settle (run the batch, re-check buildable, repeat) freezes the milestone's issue set when it begins. Issues filed
|
|
5
|
+
* later are classified by source: decomposition children of a frozen issue stay in the milestone (the parent's
|
|
6
|
+
* contract) and join the measured set; deferred follow-ups go to the next milestone or the backlog; review findings
|
|
7
|
+
* and anything else are counted as growth but are not part of the measured set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
11
|
+
const FINDING_RE = /<!--\s*FORGE:REVIEW_FINDING\b|\breview[- ]finding\b/i;
|
|
12
|
+
|
|
13
|
+
export const SOURCES = ["decomposition-child", "deferred-followup", "review-finding", "other"];
|
|
14
|
+
|
|
15
|
+
/** The issue numbers open or closed in the milestone when settle begins. @param {Array<{number:number}|number>} issues */
|
|
16
|
+
export function freezeScope(issues) {
|
|
17
|
+
return new Set((issues ?? []).map((i) => (typeof i === "number" ? i : i?.number)).filter(Number.isSafeInteger));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ issue: { number: number, body?: string, parent?: number|null }, measured: Set<number>, decomposedParents?: Set<number> }} a
|
|
22
|
+
* `measured` is the frozen set plus the children already admitted; `decomposedParents` the issues that were decomposed.
|
|
23
|
+
* `issue.parent` is the decomposed parent when known (e.g. from the engine's decompose record).
|
|
24
|
+
* @returns {{ source: string, target: "milestone"|"next-milestone-or-backlog"|"unchanged" }}
|
|
25
|
+
*/
|
|
26
|
+
export function classifyNew({ issue, measured, decomposedParents = new Set() }) {
|
|
27
|
+
const body = String(issue?.body ?? "");
|
|
28
|
+
const parent = Number.isSafeInteger(issue?.parent) ? issue.parent : null;
|
|
29
|
+
if (parent !== null && measured.has(parent) && (decomposedParents.size === 0 || decomposedParents.has(parent))) {
|
|
30
|
+
return { source: "decomposition-child", target: "milestone" };
|
|
31
|
+
}
|
|
32
|
+
if (DEFERRED_FROM_RE.test(body)) return { source: "deferred-followup", target: "next-milestone-or-backlog" };
|
|
33
|
+
if (FINDING_RE.test(body)) return { source: "review-finding", target: "unchanged" };
|
|
34
|
+
return { source: "other", target: "unchanged" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convergence on the frozen set plus the (transitive) children of decomposed issues in it.
|
|
39
|
+
* @param {{ frozen: Set<number>, current: Array<{ number: number, state?: string, body?: string, parent?: number|null }>, decomposedParents?: Set<number> }} a
|
|
40
|
+
* @returns {{ measured: number[], open: number[], grew: number, bySource: Record<string, number[]>, converged: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
export function convergence({ frozen, current, decomposedParents = new Set() }) {
|
|
43
|
+
const measured = new Set(frozen);
|
|
44
|
+
const bySource = Object.fromEntries(SOURCES.map((s) => [s, []]));
|
|
45
|
+
const fresh = (current ?? []).filter((i) => !frozen.has(i.number));
|
|
46
|
+
// Children can be filed under children: admit until no more join.
|
|
47
|
+
let changed = true;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
while (changed) {
|
|
50
|
+
changed = false;
|
|
51
|
+
for (const it of fresh) {
|
|
52
|
+
if (seen.has(it.number)) continue;
|
|
53
|
+
const c = classifyNew({ issue: it, measured, decomposedParents });
|
|
54
|
+
if (c.source !== "decomposition-child") continue;
|
|
55
|
+
seen.add(it.number); measured.add(it.number); bySource[c.source].push(it.number); changed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const it of fresh) {
|
|
59
|
+
if (seen.has(it.number)) continue;
|
|
60
|
+
bySource[classifyNew({ issue: it, measured, decomposedParents }).source].push(it.number);
|
|
61
|
+
}
|
|
62
|
+
const open = (current ?? []).filter((i) => measured.has(i.number) && String(i.state ?? "OPEN").toUpperCase() !== "CLOSED").map((i) => i.number);
|
|
63
|
+
const grew = SOURCES.reduce((n, s) => n + bySource[s].length, 0);
|
|
64
|
+
return { measured: [...measured], open, grew, bySource, converged: open.length === 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The STOP report text: how much the set grew and where the new issues came from. */
|
|
68
|
+
export function formatStopReport({ frozen, result, passes = null, limit = null }) {
|
|
69
|
+
const parts = SOURCES.filter((s) => result.bySource[s].length).map((s) => `${s} ${result.bySource[s].length} (${result.bySource[s].map((n) => `#${n}`).join(", ")})`);
|
|
70
|
+
const pass = passes !== null && limit !== null ? ` after ${passes}/${limit} passes` : "";
|
|
71
|
+
const open = result.open.map((n) => `#${n}`).join(", ") || "none";
|
|
72
|
+
return `settle STOP${pass}: ${result.open.length} open in the measured set (${open}); frozen ${frozen.size}, set grew by ${result.grew}`
|
|
73
|
+
+ (parts.length ? `: ${parts.join("; ")}` : "");
|
|
74
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Refresh the ForgeDock GitHub App installation token (1h lifetime)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
Type=oneshot
|
|
6
|
+
# Edit the path to your ForgeDock checkout (or the persisted ~/.forge copy), then see docs/CONFIG.md "Bot token refresh".
|
|
7
|
+
ExecStart=%h/.forge/scripts/refresh-bot-token.sh
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Refresh the ForgeDock bot token every 45 minutes
|
|
3
|
+
|
|
4
|
+
[Timer]
|
|
5
|
+
OnBootSec=2min
|
|
6
|
+
OnUnitActiveSec=45min
|
|
7
|
+
# Run a missed refresh right after the host wakes or the user manager restarts.
|
|
8
|
+
Persistent=true
|
|
9
|
+
|
|
10
|
+
[Install]
|
|
11
|
+
WantedBy=timers.target
|
package/scripts/wait-ci-green.sh
CHANGED
|
@@ -71,11 +71,13 @@ jitter() { awk -v s="$1" -v p="$JITTER_PCT" -v r="$RANDOM" 'BEGIN{ v=s*(1-p/100+
|
|
|
71
71
|
# classify_gh <text> -> prints one class, or nothing when the text is unrecognised:
|
|
72
72
|
# limited real rate-limit evidence (x-ratelimit-remaining: 0, HTTP 429, "API rate limit exceeded" or GraphQL "API rate limit already exceeded", secondary rate limit,
|
|
73
73
|
# abuse detection, too many requests): a wait, bounded by the whole window
|
|
74
|
-
#
|
|
74
|
+
# unauth a bare 401 (expired token, forge#4662): the refresh timer may fix it, so a small bounded retry, then an explicit auth error
|
|
75
|
+
# auth a bare 403 (missing scope): permanent, never waited on
|
|
75
76
|
# transient 5xx / gateway / network failures: a small bounded retry (FORGE_CI_TRANSIENT_RETRIES), never the long wait
|
|
76
77
|
classify_gh() {
|
|
77
78
|
if printf '%s' "$1" | grep -Eqi 'api rate limit (already )?exceeded|secondary (rate )?limit|abuse detection|x-ratelimit-remaining: *0|HTTP 429|status 429|too many requests'; then echo limited
|
|
78
|
-
elif printf '%s' "$1" | grep -Eqi 'HTTP
|
|
79
|
+
elif printf '%s' "$1" | grep -Eqi 'HTTP 401|status 401|bad credentials|requires authentication'; then echo unauth
|
|
80
|
+
elif printf '%s' "$1" | grep -Eqi 'HTTP 403|status 403'; then echo auth
|
|
79
81
|
elif printf '%s' "$1" | grep -Eqi 'HTTP 5[0-9][0-9]|status 5[0-9][0-9]|bad gateway|service unavailable|gateway time-?out|timed? ?out|i/o timeout|connection (reset|refused)|could not resolve host|unexpected EOF|network is unreachable|TLS handshake'; then echo transient
|
|
80
82
|
fi
|
|
81
83
|
}
|
|
@@ -94,6 +96,10 @@ while :; do
|
|
|
94
96
|
START_SHA=""; _hc="$(classify_gh "$(cat "$_herr" 2>/dev/null)")"
|
|
95
97
|
case "$_hc" in
|
|
96
98
|
auth) emit ERROR; echo "cannot read PR #$PR head in $REPO (HTTP 401/403 without rate-limit evidence)"; exit 2 ;;
|
|
99
|
+
unauth)
|
|
100
|
+
_hfail=$((_hfail + 1))
|
|
101
|
+
if [ "$_hfail" -gt 2 ]; then emit ERROR; echo "cannot read PR #$PR head in $REPO: HTTP 401 (bot token expired or revoked?) ${_hfail} times in a row; run scripts/refresh-bot-token.sh"; exit 2; fi
|
|
102
|
+
sleep "$(jitter "$INTERVAL")" ;;
|
|
97
103
|
limited)
|
|
98
104
|
_hlim=$((_hlim + 1))
|
|
99
105
|
if [ $(( $(date +%s) - _hstart )) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window reading the PR head; retry later"; exit 4; fi
|
|
@@ -117,8 +123,13 @@ while :; do
|
|
|
117
123
|
NC_OUT="$(gh pr checks "$PR" -R "$REPO" 2>&1 || true)"
|
|
118
124
|
if printf '%s' "$NC_OUT" | grep -qi 'no checks reported'; then JSON='[]'
|
|
119
125
|
elif GH_CLASS="$(classify_gh "$NC_OUT")"; [ "$GH_CLASS" = auth ]; then
|
|
120
|
-
# A bare
|
|
126
|
+
# A bare 403 is an auth fault that never clears: fail fast instead of waiting out the window (forge#4037).
|
|
121
127
|
emit ERROR; echo "gh pr checks refused (HTTP 401/403 without rate-limit evidence); fix the token or its scopes"; exit 2
|
|
128
|
+
elif [ "$GH_CLASS" = unauth ]; then
|
|
129
|
+
# forge#4662: an expired token is unknown state, not "no checks": back off, then fail with an explicit auth message.
|
|
130
|
+
transient=$((transient + 1))
|
|
131
|
+
if [ "$transient" -gt "$TRANSIENT_MAX" ]; then emit ERROR; echo "gh pr checks refused (HTTP 401) ${transient} times in a row; the bot token is expired or revoked, run scripts/refresh-bot-token.sh"; exit 2; fi
|
|
132
|
+
sleep "$(jitter "$INTERVAL")"; continue
|
|
122
133
|
elif [ "$GH_CLASS" = transient ]; then
|
|
123
134
|
# Network timeout / 5xx: a small bounded retry, never the long rate-limit wait.
|
|
124
135
|
transient=$((transient + 1))
|
|
@@ -74,7 +74,9 @@ expect "rate limited for the whole window is RATE_LIMITED rc 4" "4|CI_GATE: RATE
|
|
|
74
74
|
expect "more than 5 rate-limit hits are not an ERROR" "0|CI_GATE: PASS" "$(run "$(mk rlmany RATE RATE RATE RATE RATE RATE RATE "$P")" --timeout 60)"
|
|
75
75
|
# forge#4037: only real rate-limit evidence is RATE_LIMITED; a bare 401/403 fails fast; timeouts/5xx are a small bounded retry.
|
|
76
76
|
expect "bare 403 is a permanent error rc 2" "2|CI_GATE: ERROR" "$(run "$(mk a403 A403 "$P")" --timeout 60)"
|
|
77
|
-
|
|
77
|
+
# forge#4662: a 401 is a refreshable token, so it is retried a bounded number of times before it is an error.
|
|
78
|
+
expect "401 then pass retries (token refreshed meanwhile)" "0|CI_GATE: PASS" "$(run "$(mk a401 A401 "$P")" --timeout 60)"
|
|
79
|
+
expect "persistent 401 is rc 2 after the bounded retries" "2|CI_GATE: ERROR" "$(run "$(mk a401all A401)" --timeout 60)"
|
|
78
80
|
expect "HTTP 429 is RATE_LIMITED rc 4" "4|CI_GATE: RATE_LIMITED" "$(run "$(mk r429 R429)" --timeout 2)"
|
|
79
81
|
expect "x-ratelimit-remaining: 0 on a 403 is RATE_LIMITED rc 4" "4|CI_GATE: RATE_LIMITED" "$(run "$(mk rhdr RHDR)" --timeout 2)"
|
|
80
82
|
expect "network timeout then pass retries" "0|CI_GATE: PASS" "$(run "$(mk tmo TMO "$P")")"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
2
2
|
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
3
3
|
<!-- /bootstrap needs-human issue: one per credential, account or external input (OAuth client, cloud or DNS account,
|
|
4
|
-
payment keys, API keys, legal review). Label it needs-human and set "needsHuman": true in the plan. Code issues
|
|
4
|
+
payment keys, API keys, legal review). Label it needs-human and needs-human:external (the script adds the latter), give it a `gates:` line (preview|staging-deploy|prod-deploy|feature-flag:<name>) and an `owner:` line, and set "needsHuman": true in the plan. Code issues
|
|
5
5
|
reference it (#{{KEY}}) but never list it under Dependencies; their mocks keep dev and CI green. Copy from "## Problem" down. -->
|
|
6
6
|
## Problem
|
|
7
7
|
|
|
8
|
+
gates: preview
|
|
9
|
+
owner: maintainer
|
|
10
|
+
|
|
8
11
|
{which credential, account or external input is needed, and which issues use it}
|
|
9
12
|
|
|
10
13
|
### Setup steps
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Guard test template for repos with a drizzle-style migration journal (#4661). Copy it next to your tests and set JOURNAL.
|
|
2
|
+
// Newest-only migrators (drizzle `migrate()`) skip an entry whose `when` is older than one already applied. CI migrates a
|
|
3
|
+
// fresh database, so it never notices; parallel milestone branches interleave stamps and hit this on persistent databases.
|
|
4
|
+
// Run in CI with the base ref fetched: BASE_REF=origin/main node --test migration-journal-guard.test.mjs
|
|
5
|
+
//
|
|
6
|
+
// Optional upgrade test (repo-specific, not provided): migrate a database to the base's head, apply the branch's
|
|
7
|
+
// migrations, then assert that every migration the branch added is recorded as applied.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const JOURNAL = process.env.JOURNAL ?? "drizzle/meta/_journal.json";
|
|
14
|
+
const BASE_REF = process.env.BASE_REF ?? "origin/main";
|
|
15
|
+
const entries = JSON.parse(readFileSync(JOURNAL, "utf8")).entries;
|
|
16
|
+
|
|
17
|
+
test("journal `when` strictly increases", () => {
|
|
18
|
+
for (let i = 1; i < entries.length; i++) assert.ok(entries[i].when > entries[i - 1].when, `${entries[i].tag} when ${entries[i].when} is not after ${entries[i - 1].tag}`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("no new entry is older than the base max `when`", (t) => {
|
|
22
|
+
let base;
|
|
23
|
+
try { base = JSON.parse(execFileSync("git", ["show", `${BASE_REF}:${JOURNAL}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).entries; } catch { return t.skip(`no ${JOURNAL} at ${BASE_REF}`); }
|
|
24
|
+
const known = new Set(base.map((e) => e.tag)), max = Math.max(...base.map((e) => e.when));
|
|
25
|
+
for (const e of entries.filter((x) => !known.has(x.tag))) assert.ok(e.when > max, `${e.tag} when ${e.when} is not above the base max ${max}: re-stamp it with the generator`);
|
|
26
|
+
});
|