@plainconceptsplatform/workflows 1.12.0 → 1.13.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.
@@ -0,0 +1,54 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-dependabot/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Merge Dependabot pull requests
3
+ description: >-
4
+ Squash-merge the open Dependabot pull requests whose checks are all green, ask Dependabot to
5
+ rebase the ones that are behind or conflicting, and leave the failing ones with a line in the job
6
+ summary. Only pull requests Dependabot opened and every commit of which Dependabot made are
7
+ touched. API calls only: no pull request code is checked out or run, and no model decides.
8
+
9
+ inputs:
10
+ token:
11
+ description: >-
12
+ Token with contents and pull-requests write. The App token makes the merge start the push
13
+ workflows on the default branch; a merge made with GITHUB_TOKEN starts none.
14
+ required: true
15
+ scope:
16
+ description: "Which updates merge: all, minor (minor and patch), patch, or off."
17
+ required: false
18
+ default: all
19
+ merge-without-checks:
20
+ description: "true merges a pull request with no checks at all, once its head is an hour old. false leaves it."
21
+ required: false
22
+ default: 'true'
23
+ max-merges:
24
+ description: How many pull requests one run merges at most.
25
+ required: false
26
+ default: '5'
27
+ skip-labels:
28
+ description: Space-separated labels that keep a pull request out.
29
+ required: false
30
+ default: do-not-merge blocked human-review hold
31
+ post-merge-event:
32
+ description: repository_dispatch event sent after each merge, the one land-pull-request sends. Empty sends none.
33
+ required: false
34
+ default: agentic-post-merge
35
+ dry-run:
36
+ description: Decide and report, and write nothing.
37
+ required: false
38
+ default: 'false'
39
+
40
+ runs:
41
+ using: composite
42
+ steps:
43
+ - name: Merge Dependabot pull requests
44
+ shell: bash
45
+ env:
46
+ GH_TOKEN: ${{ inputs.token }}
47
+ REPO: ${{ github.repository }}
48
+ SCOPE: ${{ inputs.scope }}
49
+ MERGE_WITHOUT_CHECKS: ${{ inputs.merge-without-checks }}
50
+ MAX_MERGES: ${{ inputs.max-merges }}
51
+ SKIP_LABELS: ${{ inputs.skip-labels }}
52
+ POST_MERGE_EVENT: ${{ inputs.post-merge-event }}
53
+ DRY_RUN: ${{ inputs.dry-run }}
54
+ run: node "${GITHUB_ACTION_PATH}/merge-dependabot.mjs"
@@ -0,0 +1,343 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-dependabot/merge-dependabot.mjs. Update with workflows update --force; consumer edits may be overwritten.
2
+ //
3
+ // Squash-merges Dependabot pull requests whose checks are green. API calls only: the pull
4
+ // request's code is never checked out or run, and no model decides anything.
5
+
6
+ import { appendFileSync } from "node:fs";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ export const DEPENDABOT_LOGINS = new Set(["dependabot[bot]", "app/dependabot"]);
10
+ // Dependabot's commits are created through the API: GitHub commits and signs them as web-flow.
11
+ const DEPENDABOT_COMMITTERS = new Set(["web-flow", "dependabot[bot]"]);
12
+ export const SCOPES = ["all", "minor", "patch", "off"];
13
+ export const REBASE_COMMAND = "@dependabot rebase";
14
+ const BUMP_RANK = { patch: 1, minor: 2, major: 3, unknown: 4 };
15
+ const OK_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
16
+ const MAX_EXAMINED = 30;
17
+ const MIN_RATE_REMAINING = 100;
18
+ export const NO_CHECKS_GRACE_MINUTES = 60;
19
+
20
+ export const isDependabot = (login) => DEPENDABOT_LOGINS.has(login ?? "");
21
+
22
+ export function authorshipProblem(pr, commits, repository) {
23
+ if (!isDependabot(pr.user?.login)) return `opened by ${pr.user?.login ?? "unknown"}`;
24
+ if (String(pr.head?.repo?.full_name).toLowerCase() !== repository.toLowerCase()) return "its branch is in another repository";
25
+ if (!String(pr.head?.ref ?? "").startsWith("dependabot/")) return `branch ${pr.head?.ref} is not a Dependabot branch`;
26
+ if (commits.length === 0) return "it has no commits";
27
+ // The commits endpoint stops at 250, so a longer list cannot be proven all Dependabot's.
28
+ if (commits.length >= 250) return "too many commits to verify";
29
+ for (const commit of commits) {
30
+ const sha = String(commit.sha ?? "").slice(0, 7);
31
+ if (!isDependabot(commit.author?.login)) return `commit ${sha} is by ${commit.author?.login ?? "an unknown author"}`;
32
+ if (!DEPENDABOT_COMMITTERS.has(commit.committer?.login ?? "")) return `commit ${sha} was committed by ${commit.committer?.login ?? "an unknown committer"}`;
33
+ if (commit.commit?.verification?.verified !== true) return `commit ${sha} is not verified`;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function labelProblem(pr, skipLabels) {
39
+ const held = (pr.labels ?? []).map((label) => label.name).filter((name) => skipLabels.includes(name));
40
+ return held.length > 0 ? `labelled ${held.join(", ")}` : null;
41
+ }
42
+
43
+ const VERSION = "`?(v?[0-9][^\\s`|]*?)`?";
44
+ const TITLE_UPDATE = new RegExp(`\\bbump (\\S+) from ${VERSION} to ${VERSION}(?:\\s+in\\s|\\s*$)`, "i");
45
+ const BODY_UPDATE = new RegExp(`^(?:Bumps|Updates|Updated) \\[?\`?([^\\]\`\\s]+)\`?\\]?(?:\\([^)]*\\))? from ${VERSION} to ${VERSION}\\.?\\s*$`, "i");
46
+ const TABLE_ROW = new RegExp(`^\\|\\s*\\[?([^\\]|]+?)\\]?(?:\\([^)]*\\))?\\s*\\|\\s*${VERSION}\\s*\\|\\s*${VERSION}\\s*\\|\\s*$`);
47
+
48
+ export function parseUpdates(title, body) {
49
+ const updates = [];
50
+ const titleMatch = TITLE_UPDATE.exec(title ?? "");
51
+ if (titleMatch) updates.push({ name: titleMatch[1], from: titleMatch[2], to: titleMatch[3] });
52
+ // Release notes and changelogs sit inside <details>, and they quote versions of their own.
53
+ const text = String(body ?? "").replace(/\r\n/g, "\n").replace(/<details>[\s\S]*?<\/details>/gi, "");
54
+ let inTable = false;
55
+ for (const line of text.split("\n")) {
56
+ const trimmed = line.trim();
57
+ if (/^\|\s*Package\s*\|\s*From\s*\|\s*To\s*\|$/i.test(trimmed)) { inTable = true; continue; }
58
+ if (!trimmed.startsWith("|")) inTable = false;
59
+ const match = BODY_UPDATE.exec(trimmed) ?? (inTable ? TABLE_ROW.exec(trimmed) : null);
60
+ if (match) updates.push({ name: match[1].trim(), from: match[2], to: match[3] });
61
+ }
62
+ const seen = new Set();
63
+ return updates.filter((update) => {
64
+ const key = `${update.name}\0${update.from}\0${update.to}`;
65
+ return seen.has(key) ? false : (seen.add(key), true);
66
+ });
67
+ }
68
+
69
+ const numeric = (version) => {
70
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
71
+ return match ? [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] : null;
72
+ };
73
+
74
+ export function bumpLevel(from, to) {
75
+ const before = numeric(from);
76
+ const after = numeric(to);
77
+ if (before === null || after === null) return "unknown";
78
+ if (before[0] !== after[0]) return "major";
79
+ if (before[1] !== after[1]) return "minor";
80
+ return "patch";
81
+ }
82
+
83
+ export function highestBump(updates) {
84
+ if (updates.length === 0) return "unknown";
85
+ return updates.map((update) => bumpLevel(update.from, update.to))
86
+ .reduce((highest, level) => (BUMP_RANK[level] > BUMP_RANK[highest] ? level : highest), "patch");
87
+ }
88
+
89
+ export function scopeAllows(scope, bump) {
90
+ if (scope === "all") return true;
91
+ if (scope === "minor") return bump === "patch" || bump === "minor";
92
+ if (scope === "patch") return bump === "patch";
93
+ return false;
94
+ }
95
+
96
+ export function summarizeChecks({ checkRuns = [], statuses = [], required = [] }) {
97
+ const latest = new Map();
98
+ for (const run of checkRuns) {
99
+ const known = latest.get(run.name);
100
+ if (known === undefined || run.id > known.id) latest.set(run.name, run);
101
+ }
102
+ const failing = [];
103
+ const pending = [];
104
+ for (const run of latest.values()) {
105
+ if (run.status !== "completed") pending.push(run.name);
106
+ else if (!OK_CONCLUSIONS.has(run.conclusion)) failing.push(`${run.name} (${run.conclusion})`);
107
+ }
108
+ for (const status of statuses) {
109
+ if (status.state === "pending") pending.push(status.context);
110
+ else if (status.state !== "success") failing.push(`${status.context} (${status.state})`);
111
+ }
112
+ const reported = new Set([...latest.keys(), ...statuses.map((status) => status.context)]);
113
+ for (const context of required) {
114
+ if (!reported.has(context)) pending.push(`${context} (required, not reported)`);
115
+ }
116
+ const state = failing.length > 0 ? "failure" : pending.length > 0 ? "pending" : reported.size === 0 ? "none" : "success";
117
+ return { state, failing, pending };
118
+ }
119
+
120
+ export function rebaseAlreadyRequested(comments, headCommittedAt) {
121
+ const since = Date.parse(headCommittedAt ?? "");
122
+ return comments.some((comment) =>
123
+ String(comment.body ?? "").trim().toLowerCase() === REBASE_COMMAND &&
124
+ (Number.isNaN(since) || Date.parse(comment.created_at) >= since));
125
+ }
126
+
127
+ // One pull request, everything already fetched. The decision is pure so it can be tested.
128
+ export function decide({ pr, commits, defaultBranch, repository, skipLabels, scope, checks, comments, mergeWithoutChecks, now }) {
129
+ const bump = highestBump(parseUpdates(pr.title, pr.body));
130
+ const skip = (reason) => ({ action: "skip", reason, bump });
131
+ if (pr.draft) return skip("draft");
132
+ if (pr.base?.ref !== defaultBranch) return skip(`targets ${pr.base?.ref}, not ${defaultBranch}`);
133
+ const labelled = labelProblem(pr, skipLabels);
134
+ if (labelled) return skip(labelled);
135
+ const authorship = authorshipProblem(pr, commits, repository);
136
+ if (authorship) return skip(authorship);
137
+ if (!scopeAllows(scope, bump)) return skip(`${bump} update, DEPENDABOT_MERGE is ${scope}`);
138
+
139
+ const headCommittedAt = commits.at(-1)?.commit?.committer?.date;
140
+ if (pr.mergeable === false || pr.mergeable_state === "dirty" || pr.mergeable_state === "behind") {
141
+ const why = pr.mergeable_state === "behind" ? "behind its base" : "conflicting";
142
+ if (rebaseAlreadyRequested(comments, headCommittedAt)) return { action: "wait", reason: `${why}; rebase already requested`, bump };
143
+ return { action: "rebase", reason: why, bump };
144
+ }
145
+ if (pr.mergeable !== true) return { action: "wait", reason: "GitHub has not computed mergeability yet", bump };
146
+
147
+ if (checks.state === "failure") return { action: "fail", reason: `failing: ${checks.failing.join(", ")}`, bump };
148
+ if (checks.state === "pending") return { action: "wait", reason: `pending: ${checks.pending.join(", ")}`, bump };
149
+ if (checks.state === "none") {
150
+ if (!mergeWithoutChecks) return skip("no checks, DEPENDABOT_MERGE_WITHOUT_CHECKS is false");
151
+ // CI that has not registered yet looks like no CI at all.
152
+ const ageMinutes = (now - Date.parse(headCommittedAt ?? pr.updated_at)) / 60000;
153
+ if (!(ageMinutes >= NO_CHECKS_GRACE_MINUTES)) return { action: "wait", reason: `no checks yet; merging once the head is ${NO_CHECKS_GRACE_MINUTES} minutes old`, bump };
154
+ return { action: "merge", reason: "no checks configured", bump };
155
+ }
156
+ return { action: "merge", reason: "checks green", bump };
157
+ }
158
+
159
+ export function readConfig(env) {
160
+ const scope = String(env.SCOPE || "all").trim().toLowerCase();
161
+ if (!SCOPES.includes(scope)) throw new Error(`DEPENDABOT_MERGE must be one of ${SCOPES.join(", ")}, not "${env.SCOPE}"`);
162
+ const maxMerges = Number(env.MAX_MERGES || "5");
163
+ if (!Number.isInteger(maxMerges) || maxMerges < 0) throw new Error(`max-merges must be a whole number, not "${env.MAX_MERGES}"`);
164
+ return {
165
+ scope,
166
+ maxMerges,
167
+ mergeWithoutChecks: String(env.MERGE_WITHOUT_CHECKS || "true").trim().toLowerCase() !== "false",
168
+ skipLabels: String(env.SKIP_LABELS ?? "").split(/[\s,]+/).filter(Boolean),
169
+ dryRun: String(env.DRY_RUN || "false").trim().toLowerCase() === "true",
170
+ postMergeEvent: String(env.POST_MERGE_EVENT ?? "").trim(),
171
+ };
172
+ }
173
+
174
+ export function createClient({ token, apiUrl = "https://api.github.com", fetchImpl = fetch }) {
175
+ const state = { remaining: Infinity };
176
+ const request = async (method, path, body) => {
177
+ const response = await fetchImpl(path.startsWith("http") ? path : `${apiUrl}${path}`, {
178
+ method,
179
+ headers: {
180
+ authorization: `Bearer ${token}`,
181
+ accept: "application/vnd.github+json",
182
+ "x-github-api-version": "2022-11-28",
183
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
184
+ },
185
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
186
+ });
187
+ const remaining = Number(response.headers.get("x-ratelimit-remaining"));
188
+ if (!Number.isNaN(remaining)) state.remaining = remaining;
189
+ const text = await response.text();
190
+ let data = null;
191
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
192
+ return { status: response.status, data, link: response.headers.get("link") ?? "" };
193
+ };
194
+ const paginate = async (path, limit = 300) => {
195
+ const items = [];
196
+ let next = path;
197
+ while (next && items.length < limit) {
198
+ const page = await request("GET", next);
199
+ if (page.status >= 400) throw new Error(`GET ${next} answered ${page.status}: ${JSON.stringify(page.data)}`);
200
+ items.push(...(Array.isArray(page.data) ? page.data : page.data?.check_runs ?? []));
201
+ next = /<([^>]+)>;\s*rel="next"/.exec(page.link)?.[1];
202
+ }
203
+ return items;
204
+ };
205
+ return { request, paginate, rateRemaining: () => state.remaining };
206
+ }
207
+
208
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
209
+
210
+ async function readPullRequest(client, repository, number, wait) {
211
+ for (let attempt = 0; ; attempt++) {
212
+ const { data } = await client.request("GET", `/repos/${repository}/pulls/${number}`);
213
+ if (data?.mergeable !== null || attempt >= 3) return data;
214
+ await wait(2000);
215
+ }
216
+ }
217
+
218
+ async function requiredChecks(client, repository, branch) {
219
+ const contexts = new Set();
220
+ const rules = await client.request("GET", `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`);
221
+ for (const rule of Array.isArray(rules.data) ? rules.data : []) {
222
+ if (rule.type !== "required_status_checks") continue;
223
+ for (const check of rule.parameters?.required_status_checks ?? []) contexts.add(check.context);
224
+ }
225
+ const protection = await client.request("GET", `/repos/${repository}/branches/${encodeURIComponent(branch)}`);
226
+ for (const context of protection.data?.protection?.required_status_checks?.contexts ?? []) contexts.add(context);
227
+ return [...contexts];
228
+ }
229
+
230
+ async function readChecks(client, repository, sha, required) {
231
+ const checkRuns = await client.paginate(`/repos/${repository}/commits/${sha}/check-runs?per_page=100`);
232
+ const combined = await client.request("GET", `/repos/${repository}/commits/${sha}/status?per_page=100`);
233
+ return summarizeChecks({ checkRuns, statuses: combined.data?.statuses ?? [], required });
234
+ }
235
+
236
+ async function land(client, repository, pr, config) {
237
+ if (pr.mergeable_state === "blocked") {
238
+ // A ruleset may require an approval; one from this token meets it.
239
+ await client.request("POST", `/repos/${repository}/pulls/${pr.number}/reviews`, {
240
+ commit_id: pr.head.sha, event: "APPROVE", body: "Dependabot update with every check green.",
241
+ });
242
+ }
243
+ const merged = await client.request("PUT", `/repos/${repository}/pulls/${pr.number}/merge`, {
244
+ merge_method: "squash", sha: pr.head.sha,
245
+ });
246
+ if (merged.status === 200) {
247
+ await client.request("DELETE", `/repos/${repository}/git/refs/heads/${pr.head.ref}`);
248
+ if (config.postMergeEvent) {
249
+ // The same payload land-pull-request sends, so a consumer deploying on it needs nothing new.
250
+ await client.request("POST", `/repos/${repository}/dispatches`, {
251
+ event_type: config.postMergeEvent, client_payload: { sha: pr.head.sha, pr: String(pr.number), base: pr.base.ref },
252
+ });
253
+ }
254
+ return { action: "merged", reason: `squash-merged as ${String(merged.data?.sha ?? "").slice(0, 7)}` };
255
+ }
256
+ const message = String(merged.data?.message ?? JSON.stringify(merged.data)).slice(0, 200);
257
+ if (merged.status === 405 && /merge queue/i.test(message)) {
258
+ const enqueued = await client.request("POST", "/graphql", {
259
+ query: "mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}",
260
+ variables: { id: pr.node_id, sha: pr.head.sha },
261
+ });
262
+ if (enqueued.data?.data?.enqueuePullRequest?.mergeQueueEntry) return { action: "enqueued", reason: "added to the merge queue" };
263
+ return { action: "blocked", reason: `the merge queue refused it: ${JSON.stringify(enqueued.data?.errors ?? enqueued.data).slice(0, 200)}` };
264
+ }
265
+ if (merged.status === 409) return { action: "wait", reason: "the head moved while it was judged; next run" };
266
+ if ([403, 405, 422].includes(merged.status)) return { action: "blocked", reason: `GitHub refused the merge: ${message}` };
267
+ return { action: "error", reason: `merge answered ${merged.status}: ${message}` };
268
+ }
269
+
270
+ export async function run({ client, repository, config, now = Date.now(), log = console.log, wait = sleep }) {
271
+ const rows = [];
272
+ if (config.scope === "off") return { rows, off: true };
273
+
274
+ const { data: repo } = await client.request("GET", `/repos/${repository}`);
275
+ const defaultBranch = repo.default_branch;
276
+ const open = await client.paginate(`/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=100`);
277
+ const candidates = open.filter((pr) => isDependabot(pr.user?.login)).slice(0, MAX_EXAMINED);
278
+ const required = candidates.length > 0 ? await requiredChecks(client, repository, defaultBranch) : [];
279
+ let merges = 0;
280
+
281
+ for (const listed of candidates) {
282
+ if (client.rateRemaining() < MIN_RATE_REMAINING) {
283
+ rows.push({ number: listed.number, title: listed.title, bump: "", action: "wait", reason: "API rate limit nearly spent" });
284
+ continue;
285
+ }
286
+ const pr = await readPullRequest(client, repository, listed.number, wait);
287
+ const commits = await client.paginate(`/repos/${repository}/pulls/${pr.number}/commits?per_page=100`, 250);
288
+ const comments = await client.paginate(`/repos/${repository}/issues/${pr.number}/comments?per_page=100`);
289
+ const mergeableNow = pr.mergeable === true && !["dirty", "behind"].includes(pr.mergeable_state);
290
+ const checks = mergeableNow ? await readChecks(client, repository, pr.head.sha, required) : { state: "none", failing: [], pending: [] };
291
+ let outcome = decide({
292
+ pr, commits, defaultBranch, repository, comments, checks, now,
293
+ skipLabels: config.skipLabels, scope: config.scope, mergeWithoutChecks: config.mergeWithoutChecks,
294
+ });
295
+
296
+ if (outcome.action === "merge" && merges >= config.maxMerges) {
297
+ outcome = { ...outcome, action: "wait", reason: `${config.maxMerges} merged this run; next run` };
298
+ } else if (outcome.action === "merge") {
299
+ merges++;
300
+ outcome = config.dryRun ? { ...outcome, action: "would merge" } : { ...outcome, ...await land(client, repository, pr, config) };
301
+ } else if (outcome.action === "rebase") {
302
+ if (!config.dryRun) await client.request("POST", `/repos/${repository}/issues/${pr.number}/comments`, { body: REBASE_COMMAND });
303
+ outcome = { ...outcome, action: config.dryRun ? "would ask rebase" : "asked rebase" };
304
+ }
305
+ log(`#${pr.number} ${outcome.action}: ${outcome.reason}`);
306
+ rows.push({ number: pr.number, title: pr.title, bump: outcome.bump, action: outcome.action, reason: outcome.reason });
307
+ }
308
+ return { rows, off: false };
309
+ }
310
+
311
+ export function renderSummary({ rows, off }, config) {
312
+ const lines = [`## Dependabot merge (${config.dryRun ? "dry run, " : ""}scope: ${config.scope})`, ""];
313
+ if (off) return [...lines, "DEPENDABOT_MERGE is off: nothing was examined.", ""].join("\n");
314
+ if (rows.length === 0) return [...lines, "No open Dependabot pull requests.", ""].join("\n");
315
+ lines.push("| PR | Update | Bump | Result | Why |", "|---|---|---|---|---|");
316
+ const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
317
+ for (const row of rows) lines.push(`| #${row.number} | ${cell(row.title)} | ${cell(row.bump)} | ${cell(row.action)} | ${cell(row.reason)} |`);
318
+ return [...lines, ""].join("\n");
319
+ }
320
+
321
+ async function main() {
322
+ const config = readConfig({
323
+ SCOPE: process.env.SCOPE, MAX_MERGES: process.env.MAX_MERGES, MERGE_WITHOUT_CHECKS: process.env.MERGE_WITHOUT_CHECKS,
324
+ SKIP_LABELS: process.env.SKIP_LABELS, DRY_RUN: process.env.DRY_RUN, POST_MERGE_EVENT: process.env.POST_MERGE_EVENT,
325
+ });
326
+ const client = createClient({ token: process.env.GH_TOKEN, apiUrl: process.env.GITHUB_API_URL || "https://api.github.com" });
327
+ const result = await run({ client, repository: process.env.REPO, config });
328
+ const summary = renderSummary(result, config);
329
+ if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
330
+ else console.log(summary);
331
+ const stuck = result.rows.filter((row) => row.action === "fail" || row.action === "blocked").length;
332
+ if (stuck > 0) console.log(`::notice::${stuck} Dependabot pull request(s) left open with failing checks or a refused merge; see the job summary.`);
333
+ const errors = result.rows.filter((row) => row.action === "error");
334
+ for (const row of errors) console.log(`::error::#${row.number}: ${row.reason}`);
335
+ if (errors.length > 0) process.exitCode = 1;
336
+ }
337
+
338
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
339
+ main().catch((error) => {
340
+ console.log(`::error::${error.message}`);
341
+ process.exitCode = 1;
342
+ });
343
+ }
@@ -1293,6 +1293,45 @@ if [ -f "$HOUSEKEEPING_YML" ]; then
1293
1293
  if [ "$HK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1294
1294
  fi
1295
1295
 
1296
+ # Dependabot merges ride the maintenance run. They merge code nobody on the team wrote, so the
1297
+ # guarantees are asserted: the App token (a GITHUB_TOKEN merge starts no push workflow), the knobs
1298
+ # in the env: block, no checkout or process of the pull request's code, and only verified commits.
1299
+ DEPENDABOT_DIR="${HERE}/../merge-dependabot"
1300
+ if [ -f "${DEPENDABOT_DIR}/action.yml" ] && grep -q '^ housekeeping:$' "$ROUTER_YML"; then
1301
+ DB_OK=1
1302
+ hk_job=$(sed -n '/^ housekeeping:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
1303
+ db_step=$(printf '%s\n' "$hk_job" | sed -n '/uses: \.\/\.github\/actions\/merge-dependabot/,/^ - /p')
1304
+ printf '%s' "$db_step" | grep -q 'token: ${{ steps.app-token.outputs.token }}' ||
1305
+ { DB_OK=0; echo "FAIL: the maintenance run does not merge Dependabot pull requests with the App token" >&2; }
1306
+ for knob in DEPENDABOT_MERGE DEPENDABOT_MERGE_WITHOUT_CHECKS; do
1307
+ if [ -z "$(router_env "$knob")" ] || ! printf '%s' "$db_step" | grep -q "env.${knob}"; then
1308
+ DB_OK=0
1309
+ echo "FAIL: ${knob} is not both declared in the router env: block and read by the Dependabot merge step" >&2
1310
+ fi
1311
+ done
1312
+ if grep -q 'actions/checkout' "${DEPENDABOT_DIR}/action.yml" ||
1313
+ grep -qE 'node:child_process|execFile|spawn\(' "${DEPENDABOT_DIR}/merge-dependabot.mjs"; then
1314
+ DB_OK=0
1315
+ echo "FAIL: merge-dependabot checks out or runs something; it must work through the API alone" >&2
1316
+ fi
1317
+ grep -qF 'verification?.verified !== true' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
1318
+ { DB_OK=0; echo "FAIL: merge-dependabot no longer requires every commit to be verified" >&2; }
1319
+ grep -qF 'merge_method: "squash", sha: pr.head.sha' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
1320
+ { DB_OK=0; echo "FAIL: merge-dependabot must squash-merge the head it judged" >&2; }
1321
+ if [ "$DB_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1322
+ fi
1323
+ # Dependabot's runs get a read-only token, so a CI template's merge-train dispatch fails its pull
1324
+ # request and holds the update forever.
1325
+ for template in "${HERE}/../../templates/ci/"*.yml; do
1326
+ [ -f "$template" ] || continue
1327
+ if ! grep -q 'operation=merge-train' "$template" || grep -q "pull_request.user.login != 'dependabot\[bot\]'" "$template"; then
1328
+ PASS=$((PASS + 1))
1329
+ else
1330
+ FAIL=$((FAIL + 1))
1331
+ echo "FAIL: $(basename "$template") dispatches the merge train from Dependabot's read-only runs" >&2
1332
+ fi
1333
+ done
1334
+
1296
1335
  # The audit chain closes its own reports, and every way it can be wrong is silent: a report
1297
1336
  # closed as completed with nothing implemented, or a report pinned open forever so the next
1298
1337
  # audit never runs. Both happened. Assert the three conditions that decide it.
@@ -2584,6 +2623,63 @@ if worker_installed audit; then
2584
2623
  if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2585
2624
  fi
2586
2625
 
2626
+ # Every audit from 2026-09-21 until 1.12.1 filed one issue over refine's size gate, labelled `refine`,
2627
+ # and was refused. The audit now files a report refine never sees and stories under the gate.
2628
+ if worker_installed audit; then
2629
+ AUDIT_FILE_OK=1
2630
+ AUDIT_WORKER_MD="${WORKFLOWS_DIR}/agent-audit.md"
2631
+ REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
2632
+ af() { AUDIT_FILE_OK=0; echo "FAIL: the audit $1" >&2; }
2633
+ file_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$AUDIT_WORKER_MD")
2634
+ [ -n "$file_block" ] || af "does not file through file-audit-issues, so nothing measures what it files"
2635
+ labels_of() { printf '%s\n' "$file_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
2636
+ report_labels=$(labels_of report-labels)
2637
+ story_labels=$(labels_of story-labels)
2638
+ printf '%s\n' "$report_labels" | grep -qx audit || af "report is not labelled audit, so backpressure and audit-close cannot see it"
2639
+ printf '%s\n' "$report_labels" | grep -qx refine && af "report is labelled refine; refine refuses a report of every finding"
2640
+ printf '%s\n' "$story_labels" | grep -qx refine || af "stories are not labelled refine, so nothing refines them"
2641
+ printf '%s\n' "$story_labels" | grep -qx audit && af "stories are labelled audit; three of them fill the backpressure and audit-close treats each as a report"
2642
+ grep -qE '^ staged: true' "$AUDIT_WORKER_MD" || af "is not staged, so gh-aw files the issues before they are measured"
2643
+ audit_env() { sed -n "s/^ $1: \"\([0-9]*\)\"$/\1/p" "$AUDIT_WORKER_MD" | head -1; }
2644
+ if [ -f "$REFINE_WORKER_MD" ]; then
2645
+ refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
2646
+ refine_units=$(sed -n 's/^ REFINE_MAX_WORK_UNITS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
2647
+ [ "$(audit_env AUDIT_MAX_BODY_CHARS)" -lt "${refine_chars:-0}" ] 2>/dev/null ||
2648
+ af "AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
2649
+ [ "$(audit_env AUDIT_MAX_WORK_UNITS)" -lt "${refine_units:-0}" ] 2>/dev/null ||
2650
+ af "AUDIT_MAX_WORK_UNITS is not below refine's REFINE_MAX_WORK_UNITS (${refine_units:-unset})"
2651
+ grep -qF 'uses: ./.github/actions/measure-issue-size' "$REFINE_WORKER_MD" ||
2652
+ af "and refine measure with different rules; refine's size_guard must use measure-issue-size"
2653
+ fi
2654
+ if [ "$AUDIT_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2655
+ fi
2656
+
2657
+ # The docs audit filed the same single refine-labelled report and hit the same gate; it now files
2658
+ # like the audit: a report refine never sees and stories under the gate, measured before filing.
2659
+ if worker_installed docs-audit; then
2660
+ DOCS_FILE_OK=1
2661
+ DOCS_WORKER_MD="${WORKFLOWS_DIR}/agent-docs-audit.md"
2662
+ dfa() { DOCS_FILE_OK=0; echo "FAIL: the docs audit $1" >&2; }
2663
+ docs_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$DOCS_WORKER_MD")
2664
+ [ -n "$docs_block" ] || dfa "does not file through file-audit-issues, so nothing measures what it files"
2665
+ docs_labels_of() { printf '%s\n' "$docs_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
2666
+ docs_report_labels=$(docs_labels_of report-labels)
2667
+ docs_story_labels=$(docs_labels_of story-labels)
2668
+ printf '%s\n' "$docs_report_labels" | grep -qx docs-audit || dfa "report is not labelled docs-audit, so backpressure and audit-close cannot see it"
2669
+ printf '%s\n' "$docs_report_labels" | grep -qx refine && dfa "report is labelled refine; refine refuses a report of every finding"
2670
+ printf '%s\n' "$docs_story_labels" | grep -qx refine || dfa "stories are not labelled refine, so nothing refines them"
2671
+ printf '%s\n' "$docs_story_labels" | grep -qx docs-audit && dfa "stories are labelled docs-audit; one of them fills the backpressure"
2672
+ grep -qE '^ staged: true' "$DOCS_WORKER_MD" || dfa "is not staged, so gh-aw files the issues before they are measured"
2673
+ grep -q 'add-issue-labels' "$DOCS_WORKER_MD" && dfa "still labels a report gh-aw filed itself"
2674
+ if [ -f "${WORKFLOWS_DIR}/agent-refine.md" ]; then
2675
+ docs_chars=$(sed -n 's/^ DOCS_AUDIT_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$DOCS_WORKER_MD" | head -1)
2676
+ refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "${WORKFLOWS_DIR}/agent-refine.md" | head -1)
2677
+ [ "${docs_chars:-99999999}" -lt "${refine_chars:-0}" ] 2>/dev/null ||
2678
+ dfa "DOCS_AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
2679
+ fi
2680
+ if [ "$DOCS_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2681
+ fi
2682
+
2587
2683
  echo "── Retrying a run that died early ────────────────────────────────────────"
2588
2684
 
2589
2685
  # A run that died before producing anything is worth repeating; one that worked and then failed
@@ -100,7 +100,7 @@ flowchart LR
100
100
  gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
101
101
  gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
102
102
  review("Someone reviews the PR") --> applyReview("apply-review") --> ci
103
- audit("audit, weekly") -->|"files one issue"| refine
103
+ audit("audit, weekly") -->|"files stories"| refine
104
104
 
105
105
  classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
106
106
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
@@ -114,9 +114,7 @@ flowchart LR
114
114
  class merged success
115
115
  ```
116
116
 
117
- Audit creates work rather than consuming it, and files into `refine` rather than straight to
118
- `implement`, so a report of several unrelated findings becomes one properly sized issue per finding
119
- instead of one pull request that has to fix them all.
117
+ Audit creates work rather than consuming it, and files into `refine` rather than straight to `implement`: one story issue per top finding, each under refine's size limit, plus a tracking report refine never sees, instead of one pull request that has to fix them all.
120
118
 
121
119
  ### The merge train
122
120
 
@@ -489,7 +487,7 @@ before anything changes. Applying them one at a time produces contradictory comm
489
487
 
490
488
  ## agent-audit.md
491
489
 
492
- Creates work rather than consuming it. Read-only: it files an issue and changes nothing.
490
+ Creates work rather than consuming it. Read-only: it files a tracking report and one story issue per top finding, and changes nothing else.
493
491
 
494
492
  ```mermaid
495
493
  flowchart TD
@@ -506,8 +504,9 @@ flowchart TD
506
504
  auDedupe{"Already tracked, or<br/>previously rejected?"}
507
505
  auDedupe -.->|"all of them"| auQuiet
508
506
  auDedupe -->|"some are new"| auFile
509
- auFile("One issue: every finding,<br/>top 3 refined into stories") --> auReport
510
- auReport(("Filed<br/>labelled refine, so it gets<br/>sized and split per finding"))
507
+ auFile("Report of every finding,<br/>plus one story per top 3") --> auMeasure
508
+ auMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> auReport
509
+ auReport(("Filed<br/>report: audit, not refine<br/>stories: refine, sub-issues"))
511
510
  auQuiet(("Nothing to file<br/>the right outcome on a<br/>clean codebase"))
512
511
  auIdle(("Idle<br/>backlog already full"))
513
512
 
@@ -517,12 +516,14 @@ flowchart TD
517
516
  classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
518
517
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
519
518
  class auStart start
520
- class auFacts,auRun,auScore,auFile action
519
+ class auFacts,auRun,auScore,auFile,auMeasure action
521
520
  class auBack,auFilter,auDedupe decision
522
521
  class auIdle idle
523
522
  class auReport,auQuiet success
524
523
  ```
525
524
 
525
+ The report never goes to refine: refine's size gate refused every audit filed as one issue of every finding and three stories, from 2026-09-21 until 1.12.1. Each story is filed on its own, labelled `refine`, linked under the report and listed in it, so `audit-close` closes the report once its stories are closed. A story already tracked by an open issue is listed against that issue instead of filed again.
526
+
526
527
  Memory matters here: open issues only cover what is still open, so a finding reported weeks ago and
527
528
  consciously not acted on would come back every single run. The audit records its dispositions and
528
529
  reads them next time.
@@ -549,8 +550,9 @@ flowchart TD
549
550
  daDedupe{"New finding, proven<br/>on both sides?"}
550
551
  daDedupe -.->|"none left"| daQuiet
551
552
  daDedupe -->|"some"| daFile
552
- daFile("One issue with<br/>evidence per finding") --> daReport
553
- daReport(("Filed<br/>labelled docs-audit and refine,<br/>sized and split per finding"))
553
+ daFile("Report of every finding,<br/>plus one story per top 3") --> daMeasure
554
+ daMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> daReport
555
+ daReport(("Filed<br/>report: docs-audit, not refine<br/>stories: refine, sub-issues"))
554
556
  daQuiet(("Nothing to file<br/>the documentation is current"))
555
557
  daIdle(("Idle<br/>no agent, no spend"))
556
558
 
@@ -560,7 +562,7 @@ flowchart TD
560
562
  classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
561
563
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
562
564
  class daStart start
563
- class daFacts,daRun,daFile action
565
+ class daFacts,daRun,daFile,daMeasure action
564
566
  class daBack,daMoved,daDedupe decision
565
567
  class daIdle idle
566
568
  class daReport,daQuiet success
@@ -569,6 +571,8 @@ flowchart TD
569
571
  Reports join the audit chain: `audit-close` closes one when its children are closed, and marks it
570
572
  `stale-audit` when nobody acted on it, which frees the slot.
571
573
 
574
+ It files the way the audit does, through the same `file-audit-issues` step: the report never goes to refine, which refused a single report of every finding once it grew past its size gate. Each top story is filed on its own, labelled `refine`, linked under the report and listed in it.
575
+
572
576
  ---
573
577
 
574
578
  ## agent-release.md
@@ -652,7 +656,8 @@ flowchart TB
652
656
  hkBranch -->|yes| hkDelete("Delete the branch")
653
657
  hkBranch -.->|"open PR, human PR,<br/>or the default branch"| hkDigest
654
658
  hkDelete --> hkDigest
655
- hkDigest("Rewrite one issue:<br/>Needs a human") --> hkEnd
659
+ hkDigest("Rewrite one issue:<br/>Needs a human") --> hkDependabot
660
+ hkDependabot("Squash-merge Dependabot PRs<br/>whose checks are all green") --> hkEnd
656
661
  hkEnd(("One place to look<br/>instead of four repos"))
657
662
 
658
663
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -660,7 +665,7 @@ flowchart TB
660
665
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
661
666
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
662
667
  class hkCron start
663
- class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose action
668
+ class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose,hkDependabot action
664
669
  class hkPark,hkWhy,hkBudget,hkBranch,hkStack,hkStackWait decision
665
670
  class hkEnd success
666
671
  ```
@@ -681,6 +686,8 @@ held for days after its announcement, a root closed by accident with children op
681
686
  root pull request each get one line in the digest. No labels move, so nothing the router listens
682
687
  for is raised by the sweep itself.
683
688
 
689
+ The last step merges Dependabot's updates through the `merge-dependabot` action: only pull requests Dependabot opened with every commit verified and its own, squash-merged once every check is green, a stale one asked once to rebase, a failing one listed in the job summary. It reads nothing of the pull request but the API, and `DEPENDABOT_MERGE` sets its scope. See `docs/dependabot-merge.md` in the package.
690
+
684
691
  ---
685
692
 
686
693
  ## agentics-app-errors
@@ -963,6 +963,9 @@ jobs:
963
963
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
964
964
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
965
965
  # whole run fails to start with zero jobs and no annotation when it does.
966
+ #
967
+ # Not for Dependabot: its runs get a read-only token, so the dispatch would fail its pull request,
968
+ # and the router's maintenance run merges Dependabot updates instead of the train.
966
969
  dispatch-merge-gate:
967
970
  name: Dispatch merge gate
968
971
  needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
@@ -972,6 +975,7 @@ jobs:
972
975
  github.event_name == 'pull_request' &&
973
976
  github.event.pull_request.head.repo.full_name == github.repository &&
974
977
  github.event.pull_request.draft == false &&
978
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
975
979
  (github.event.pull_request.user.login == 'app/github-actions' || endsWith(github.event.pull_request.user.login, '[bot]'))
976
980
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
977
981
  timeout-minutes: 5