@plainconceptsplatform/workflows 1.11.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,47 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/measure-issue-size/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Measure issue size
3
+ description: >-
4
+ Measure an issue against refine's size limits with the rule in issue-size.cjs, the same rule the
5
+ audit applies before it files anything.
6
+ inputs:
7
+ token:
8
+ description: GitHub token with issues:read.
9
+ required: true
10
+ issue-number:
11
+ description: The issue to measure.
12
+ required: true
13
+ max-body-chars:
14
+ description: Largest title-plus-body size, in UTF-8 bytes, that is not too big.
15
+ required: true
16
+ max-work-units:
17
+ description: Most bullet lines that are not too big.
18
+ required: true
19
+ outputs:
20
+ too-big:
21
+ description: true when either limit is exceeded.
22
+ value: ${{ steps.measure.outputs.too-big }}
23
+ reason:
24
+ description: Which limits were exceeded, empty when none.
25
+ value: ${{ steps.measure.outputs.reason }}
26
+ runs:
27
+ using: composite
28
+ steps:
29
+ - name: Measure the issue
30
+ id: measure
31
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
32
+ env:
33
+ ISSUE_SIZE_LIB: ${{ github.action_path }}/../issue-size.cjs
34
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
35
+ MAX_BODY_CHARS: ${{ inputs.max-body-chars }}
36
+ MAX_WORK_UNITS: ${{ inputs.max-work-units }}
37
+ with:
38
+ github-token: ${{ inputs.token }}
39
+ script: |
40
+ const { measureIssue, sizeVerdict } = require(process.env.ISSUE_SIZE_LIB);
41
+ const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number: Number(process.env.ISSUE_NUMBER) });
42
+ const limits = { maxBodyChars: Number(process.env.MAX_BODY_CHARS), maxWorkUnits: Number(process.env.MAX_WORK_UNITS) };
43
+ const size = measureIssue(issue.title, issue.body);
44
+ const verdict = sizeVerdict(size, limits);
45
+ core.setOutput('too-big', String(verdict.tooBig));
46
+ core.setOutput('reason', verdict.reason);
47
+ core.notice(`measured ${size.chars} chars, ${size.units} work units (limits: ${limits.maxBodyChars} chars, ${limits.maxWorkUnits} units) -> ${verdict.tooBig}`);
@@ -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