@plainconceptsplatform/workflows 1.12.0 → 1.13.1
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/dist/index.js +18 -5
- package/dist/template-bundles.js +18 -4
- package/dist/workflow-catalog.d.ts +3 -1
- package/dist/workflow-catalog.js +3 -1
- package/loops/actions/file-audit-issues/action.yml +193 -0
- package/loops/actions/file-audit-issues/audit-issues.cjs +245 -0
- package/loops/actions/issue-size.cjs +36 -0
- package/loops/actions/measure-issue-size/action.yml +47 -0
- package/loops/actions/merge-dependabot/action.yml +62 -0
- package/loops/actions/merge-dependabot/merge-dependabot.mjs +373 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +101 -0
- package/loops/docs/diagrams.md +20 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +4 -0
- package/loops/templates/ci/app-ci-node-monorepo.yml +5 -1
- package/loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml +80 -0
- package/loops/workflows/agent-audit.md +46 -29
- package/loops/workflows/agent-docs-audit.md +44 -18
- package/loops/workflows/agent-refine.md +17 -30
- package/loops/workflows/work-router.yml +23 -0
- package/package.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
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 for the writes: approve, merge, comment. Contents and pull-requests write. The App
|
|
13
|
+
token makes the merge start the push workflows on the default branch; a merge made with
|
|
14
|
+
GITHUB_TOKEN starts none.
|
|
15
|
+
required: true
|
|
16
|
+
read-token:
|
|
17
|
+
description: >-
|
|
18
|
+
Token for the reads: pull requests, check runs, commit statuses. Needs checks and statuses
|
|
19
|
+
read, which the App may not hold. A read it is refused skips that pull request with a warning.
|
|
20
|
+
required: false
|
|
21
|
+
default: ${{ github.token }}
|
|
22
|
+
scope:
|
|
23
|
+
description: "Which updates merge: all, minor (minor and patch), patch, or off."
|
|
24
|
+
required: false
|
|
25
|
+
default: all
|
|
26
|
+
merge-without-checks:
|
|
27
|
+
description: "true merges a pull request with no checks at all, once its head is an hour old. false leaves it."
|
|
28
|
+
required: false
|
|
29
|
+
default: 'true'
|
|
30
|
+
max-merges:
|
|
31
|
+
description: How many pull requests one run merges at most.
|
|
32
|
+
required: false
|
|
33
|
+
default: '5'
|
|
34
|
+
skip-labels:
|
|
35
|
+
description: Space-separated labels that keep a pull request out.
|
|
36
|
+
required: false
|
|
37
|
+
default: do-not-merge blocked human-review hold
|
|
38
|
+
post-merge-event:
|
|
39
|
+
description: repository_dispatch event sent after each merge, the one land-pull-request sends. Empty sends none.
|
|
40
|
+
required: false
|
|
41
|
+
default: agentic-post-merge
|
|
42
|
+
dry-run:
|
|
43
|
+
description: Decide and report, and write nothing.
|
|
44
|
+
required: false
|
|
45
|
+
default: 'false'
|
|
46
|
+
|
|
47
|
+
runs:
|
|
48
|
+
using: composite
|
|
49
|
+
steps:
|
|
50
|
+
- name: Merge Dependabot pull requests
|
|
51
|
+
shell: bash
|
|
52
|
+
env:
|
|
53
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
54
|
+
READ_TOKEN: ${{ inputs.read-token }}
|
|
55
|
+
REPO: ${{ github.repository }}
|
|
56
|
+
SCOPE: ${{ inputs.scope }}
|
|
57
|
+
MERGE_WITHOUT_CHECKS: ${{ inputs.merge-without-checks }}
|
|
58
|
+
MAX_MERGES: ${{ inputs.max-merges }}
|
|
59
|
+
SKIP_LABELS: ${{ inputs.skip-labels }}
|
|
60
|
+
POST_MERGE_EVENT: ${{ inputs.post-merge-event }}
|
|
61
|
+
DRY_RUN: ${{ inputs.dry-run }}
|
|
62
|
+
run: node "${GITHUB_ACTION_PATH}/merge-dependabot.mjs"
|
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
// A read the token may not make. The pull request is skipped and the run goes on.
|
|
23
|
+
export class ReadDenied extends Error {
|
|
24
|
+
constructor(what, permission) {
|
|
25
|
+
super(`cannot read ${what}: grant ${permission}: read to the App or the workflow token`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function authorshipProblem(pr, commits, repository) {
|
|
30
|
+
if (!isDependabot(pr.user?.login)) return `opened by ${pr.user?.login ?? "unknown"}`;
|
|
31
|
+
if (String(pr.head?.repo?.full_name).toLowerCase() !== repository.toLowerCase()) return "its branch is in another repository";
|
|
32
|
+
if (!String(pr.head?.ref ?? "").startsWith("dependabot/")) return `branch ${pr.head?.ref} is not a Dependabot branch`;
|
|
33
|
+
if (commits.length === 0) return "it has no commits";
|
|
34
|
+
// The commits endpoint stops at 250, so a longer list cannot be proven all Dependabot's.
|
|
35
|
+
if (commits.length >= 250) return "too many commits to verify";
|
|
36
|
+
for (const commit of commits) {
|
|
37
|
+
const sha = String(commit.sha ?? "").slice(0, 7);
|
|
38
|
+
if (!isDependabot(commit.author?.login)) return `commit ${sha} is by ${commit.author?.login ?? "an unknown author"}`;
|
|
39
|
+
if (!DEPENDABOT_COMMITTERS.has(commit.committer?.login ?? "")) return `commit ${sha} was committed by ${commit.committer?.login ?? "an unknown committer"}`;
|
|
40
|
+
if (commit.commit?.verification?.verified !== true) return `commit ${sha} is not verified`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function labelProblem(pr, skipLabels) {
|
|
46
|
+
const held = (pr.labels ?? []).map((label) => label.name).filter((name) => skipLabels.includes(name));
|
|
47
|
+
return held.length > 0 ? `labelled ${held.join(", ")}` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VERSION = "`?(v?[0-9][^\\s`|]*?)`?";
|
|
51
|
+
const TITLE_UPDATE = new RegExp(`\\bbump (\\S+) from ${VERSION} to ${VERSION}(?:\\s+in\\s|\\s*$)`, "i");
|
|
52
|
+
const BODY_UPDATE = new RegExp(`^(?:Bumps|Updates|Updated) \\[?\`?([^\\]\`\\s]+)\`?\\]?(?:\\([^)]*\\))? from ${VERSION} to ${VERSION}\\.?\\s*$`, "i");
|
|
53
|
+
const TABLE_ROW = new RegExp(`^\\|\\s*\\[?([^\\]|]+?)\\]?(?:\\([^)]*\\))?\\s*\\|\\s*${VERSION}\\s*\\|\\s*${VERSION}\\s*\\|\\s*$`);
|
|
54
|
+
|
|
55
|
+
export function parseUpdates(title, body) {
|
|
56
|
+
const updates = [];
|
|
57
|
+
const titleMatch = TITLE_UPDATE.exec(title ?? "");
|
|
58
|
+
if (titleMatch) updates.push({ name: titleMatch[1], from: titleMatch[2], to: titleMatch[3] });
|
|
59
|
+
// Release notes and changelogs sit inside <details>, and they quote versions of their own.
|
|
60
|
+
const text = String(body ?? "").replace(/\r\n/g, "\n").replace(/<details>[\s\S]*?<\/details>/gi, "");
|
|
61
|
+
let inTable = false;
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (/^\|\s*Package\s*\|\s*From\s*\|\s*To\s*\|$/i.test(trimmed)) { inTable = true; continue; }
|
|
65
|
+
if (!trimmed.startsWith("|")) inTable = false;
|
|
66
|
+
const match = BODY_UPDATE.exec(trimmed) ?? (inTable ? TABLE_ROW.exec(trimmed) : null);
|
|
67
|
+
if (match) updates.push({ name: match[1].trim(), from: match[2], to: match[3] });
|
|
68
|
+
}
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
return updates.filter((update) => {
|
|
71
|
+
const key = `${update.name}\0${update.from}\0${update.to}`;
|
|
72
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const numeric = (version) => {
|
|
77
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
|
|
78
|
+
return match ? [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] : null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function bumpLevel(from, to) {
|
|
82
|
+
const before = numeric(from);
|
|
83
|
+
const after = numeric(to);
|
|
84
|
+
if (before === null || after === null) return "unknown";
|
|
85
|
+
if (before[0] !== after[0]) return "major";
|
|
86
|
+
if (before[1] !== after[1]) return "minor";
|
|
87
|
+
return "patch";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function highestBump(updates) {
|
|
91
|
+
if (updates.length === 0) return "unknown";
|
|
92
|
+
return updates.map((update) => bumpLevel(update.from, update.to))
|
|
93
|
+
.reduce((highest, level) => (BUMP_RANK[level] > BUMP_RANK[highest] ? level : highest), "patch");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function scopeAllows(scope, bump) {
|
|
97
|
+
if (scope === "all") return true;
|
|
98
|
+
if (scope === "minor") return bump === "patch" || bump === "minor";
|
|
99
|
+
if (scope === "patch") return bump === "patch";
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function summarizeChecks({ checkRuns = [], statuses = [], required = [] }) {
|
|
104
|
+
const latest = new Map();
|
|
105
|
+
for (const run of checkRuns) {
|
|
106
|
+
const known = latest.get(run.name);
|
|
107
|
+
if (known === undefined || run.id > known.id) latest.set(run.name, run);
|
|
108
|
+
}
|
|
109
|
+
const failing = [];
|
|
110
|
+
const pending = [];
|
|
111
|
+
for (const run of latest.values()) {
|
|
112
|
+
if (run.status !== "completed") pending.push(run.name);
|
|
113
|
+
else if (!OK_CONCLUSIONS.has(run.conclusion)) failing.push(`${run.name} (${run.conclusion})`);
|
|
114
|
+
}
|
|
115
|
+
for (const status of statuses) {
|
|
116
|
+
if (status.state === "pending") pending.push(status.context);
|
|
117
|
+
else if (status.state !== "success") failing.push(`${status.context} (${status.state})`);
|
|
118
|
+
}
|
|
119
|
+
const reported = new Set([...latest.keys(), ...statuses.map((status) => status.context)]);
|
|
120
|
+
for (const context of required) {
|
|
121
|
+
if (!reported.has(context)) pending.push(`${context} (required, not reported)`);
|
|
122
|
+
}
|
|
123
|
+
const state = failing.length > 0 ? "failure" : pending.length > 0 ? "pending" : reported.size === 0 ? "none" : "success";
|
|
124
|
+
return { state, failing, pending };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function rebaseAlreadyRequested(comments, headCommittedAt) {
|
|
128
|
+
const since = Date.parse(headCommittedAt ?? "");
|
|
129
|
+
return comments.some((comment) =>
|
|
130
|
+
String(comment.body ?? "").trim().toLowerCase() === REBASE_COMMAND &&
|
|
131
|
+
(Number.isNaN(since) || Date.parse(comment.created_at) >= since));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// One pull request, everything already fetched. The decision is pure so it can be tested.
|
|
135
|
+
export function decide({ pr, commits, defaultBranch, repository, skipLabels, scope, checks, comments, mergeWithoutChecks, now }) {
|
|
136
|
+
const bump = highestBump(parseUpdates(pr.title, pr.body));
|
|
137
|
+
const skip = (reason) => ({ action: "skip", reason, bump });
|
|
138
|
+
if (pr.draft) return skip("draft");
|
|
139
|
+
if (pr.base?.ref !== defaultBranch) return skip(`targets ${pr.base?.ref}, not ${defaultBranch}`);
|
|
140
|
+
const labelled = labelProblem(pr, skipLabels);
|
|
141
|
+
if (labelled) return skip(labelled);
|
|
142
|
+
const authorship = authorshipProblem(pr, commits, repository);
|
|
143
|
+
if (authorship) return skip(authorship);
|
|
144
|
+
if (!scopeAllows(scope, bump)) return skip(`${bump} update, DEPENDABOT_MERGE is ${scope}`);
|
|
145
|
+
|
|
146
|
+
const headCommittedAt = commits.at(-1)?.commit?.committer?.date;
|
|
147
|
+
if (pr.mergeable === false || pr.mergeable_state === "dirty" || pr.mergeable_state === "behind") {
|
|
148
|
+
const why = pr.mergeable_state === "behind" ? "behind its base" : "conflicting";
|
|
149
|
+
if (rebaseAlreadyRequested(comments, headCommittedAt)) return { action: "wait", reason: `${why}; rebase already requested`, bump };
|
|
150
|
+
return { action: "rebase", reason: why, bump };
|
|
151
|
+
}
|
|
152
|
+
if (pr.mergeable !== true) return { action: "wait", reason: "GitHub has not computed mergeability yet", bump };
|
|
153
|
+
|
|
154
|
+
if (checks.state === "failure") return { action: "fail", reason: `failing: ${checks.failing.join(", ")}`, bump };
|
|
155
|
+
if (checks.state === "pending") return { action: "wait", reason: `pending: ${checks.pending.join(", ")}`, bump };
|
|
156
|
+
if (checks.state === "none") {
|
|
157
|
+
if (!mergeWithoutChecks) return skip("no checks, DEPENDABOT_MERGE_WITHOUT_CHECKS is false");
|
|
158
|
+
// CI that has not registered yet looks like no CI at all.
|
|
159
|
+
const ageMinutes = (now - Date.parse(headCommittedAt ?? pr.updated_at)) / 60000;
|
|
160
|
+
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 };
|
|
161
|
+
return { action: "merge", reason: "no checks configured", bump };
|
|
162
|
+
}
|
|
163
|
+
return { action: "merge", reason: "checks green", bump };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function readConfig(env) {
|
|
167
|
+
const scope = String(env.SCOPE || "all").trim().toLowerCase();
|
|
168
|
+
if (!SCOPES.includes(scope)) throw new Error(`DEPENDABOT_MERGE must be one of ${SCOPES.join(", ")}, not "${env.SCOPE}"`);
|
|
169
|
+
const maxMerges = Number(env.MAX_MERGES || "5");
|
|
170
|
+
if (!Number.isInteger(maxMerges) || maxMerges < 0) throw new Error(`max-merges must be a whole number, not "${env.MAX_MERGES}"`);
|
|
171
|
+
return {
|
|
172
|
+
scope,
|
|
173
|
+
maxMerges,
|
|
174
|
+
mergeWithoutChecks: String(env.MERGE_WITHOUT_CHECKS || "true").trim().toLowerCase() !== "false",
|
|
175
|
+
skipLabels: String(env.SKIP_LABELS ?? "").split(/[\s,]+/).filter(Boolean),
|
|
176
|
+
dryRun: String(env.DRY_RUN || "false").trim().toLowerCase() === "true",
|
|
177
|
+
postMergeEvent: String(env.POST_MERGE_EVENT ?? "").trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createClient({ token, apiUrl = "https://api.github.com", fetchImpl = fetch }) {
|
|
182
|
+
const state = { remaining: Infinity };
|
|
183
|
+
const request = async (method, path, body) => {
|
|
184
|
+
const response = await fetchImpl(path.startsWith("http") ? path : `${apiUrl}${path}`, {
|
|
185
|
+
method,
|
|
186
|
+
headers: {
|
|
187
|
+
authorization: `Bearer ${token}`,
|
|
188
|
+
accept: "application/vnd.github+json",
|
|
189
|
+
"x-github-api-version": "2022-11-28",
|
|
190
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
191
|
+
},
|
|
192
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
193
|
+
});
|
|
194
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
|
195
|
+
if (!Number.isNaN(remaining)) state.remaining = remaining;
|
|
196
|
+
const text = await response.text();
|
|
197
|
+
let data = null;
|
|
198
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
199
|
+
return { status: response.status, data, link: response.headers.get("link") ?? "" };
|
|
200
|
+
};
|
|
201
|
+
const paginate = async (path, limit = 300) => {
|
|
202
|
+
const items = [];
|
|
203
|
+
let next = path;
|
|
204
|
+
while (next && items.length < limit) {
|
|
205
|
+
const page = await request("GET", next);
|
|
206
|
+
if (page.status >= 400) throw Object.assign(new Error(`GET ${next} answered ${page.status}: ${JSON.stringify(page.data)}`), { status: page.status });
|
|
207
|
+
items.push(...(Array.isArray(page.data) ? page.data : page.data?.check_runs ?? []));
|
|
208
|
+
next = /<([^>]+)>;\s*rel="next"/.exec(page.link)?.[1];
|
|
209
|
+
}
|
|
210
|
+
return items;
|
|
211
|
+
};
|
|
212
|
+
return { request, paginate, rateRemaining: () => state.remaining };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
216
|
+
|
|
217
|
+
const denied = async (what, permission, read) => {
|
|
218
|
+
try { return await read(); } catch (error) {
|
|
219
|
+
throw error.status === 403 ? new ReadDenied(what, permission) : error;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
async function readPullRequest(client, repository, number, wait) {
|
|
224
|
+
for (let attempt = 0; ; attempt++) {
|
|
225
|
+
const { status, data } = await client.request("GET", `/repos/${repository}/pulls/${number}`);
|
|
226
|
+
if (status === 403) throw new ReadDenied("pull requests", "Pull requests");
|
|
227
|
+
if (data?.mergeable !== null || attempt >= 3) return data;
|
|
228
|
+
await wait(2000);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function requiredChecks(client, repository, branch) {
|
|
233
|
+
const contexts = new Set();
|
|
234
|
+
const rules = await client.request("GET", `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`);
|
|
235
|
+
for (const rule of Array.isArray(rules.data) ? rules.data : []) {
|
|
236
|
+
if (rule.type !== "required_status_checks") continue;
|
|
237
|
+
for (const check of rule.parameters?.required_status_checks ?? []) contexts.add(check.context);
|
|
238
|
+
}
|
|
239
|
+
const protection = await client.request("GET", `/repos/${repository}/branches/${encodeURIComponent(branch)}`);
|
|
240
|
+
for (const context of protection.data?.protection?.required_status_checks?.contexts ?? []) contexts.add(context);
|
|
241
|
+
return [...contexts];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function readChecks(client, repository, sha, required) {
|
|
245
|
+
const checkRuns = await denied("checks", "Checks", () => client.paginate(`/repos/${repository}/commits/${sha}/check-runs?per_page=100`));
|
|
246
|
+
const combined = await client.request("GET", `/repos/${repository}/commits/${sha}/status?per_page=100`);
|
|
247
|
+
if (combined.status === 403) throw new ReadDenied("commit statuses", "Commit statuses");
|
|
248
|
+
return summarizeChecks({ checkRuns, statuses: combined.data?.statuses ?? [], required });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function land(client, repository, pr, config) {
|
|
252
|
+
if (pr.mergeable_state === "blocked") {
|
|
253
|
+
// A ruleset may require an approval; one from this token meets it.
|
|
254
|
+
await client.request("POST", `/repos/${repository}/pulls/${pr.number}/reviews`, {
|
|
255
|
+
commit_id: pr.head.sha, event: "APPROVE", body: "Dependabot update with every check green.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const merged = await client.request("PUT", `/repos/${repository}/pulls/${pr.number}/merge`, {
|
|
259
|
+
merge_method: "squash", sha: pr.head.sha,
|
|
260
|
+
});
|
|
261
|
+
if (merged.status === 200) {
|
|
262
|
+
await client.request("DELETE", `/repos/${repository}/git/refs/heads/${pr.head.ref}`);
|
|
263
|
+
if (config.postMergeEvent) {
|
|
264
|
+
// The same payload land-pull-request sends, so a consumer deploying on it needs nothing new.
|
|
265
|
+
await client.request("POST", `/repos/${repository}/dispatches`, {
|
|
266
|
+
event_type: config.postMergeEvent, client_payload: { sha: pr.head.sha, pr: String(pr.number), base: pr.base.ref },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return { action: "merged", reason: `squash-merged as ${String(merged.data?.sha ?? "").slice(0, 7)}` };
|
|
270
|
+
}
|
|
271
|
+
const message = String(merged.data?.message ?? JSON.stringify(merged.data)).slice(0, 200);
|
|
272
|
+
if (merged.status === 405 && /merge queue/i.test(message)) {
|
|
273
|
+
const enqueued = await client.request("POST", "/graphql", {
|
|
274
|
+
query: "mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}",
|
|
275
|
+
variables: { id: pr.node_id, sha: pr.head.sha },
|
|
276
|
+
});
|
|
277
|
+
if (enqueued.data?.data?.enqueuePullRequest?.mergeQueueEntry) return { action: "enqueued", reason: "added to the merge queue" };
|
|
278
|
+
return { action: "blocked", reason: `the merge queue refused it: ${JSON.stringify(enqueued.data?.errors ?? enqueued.data).slice(0, 200)}` };
|
|
279
|
+
}
|
|
280
|
+
if (merged.status === 409) return { action: "wait", reason: "the head moved while it was judged; next run" };
|
|
281
|
+
if ([403, 405, 422].includes(merged.status)) return { action: "blocked", reason: `GitHub refused the merge: ${message}` };
|
|
282
|
+
return { action: "error", reason: `merge answered ${merged.status}: ${message}` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// `client` writes (approve, merge, comment) and `reader` reads. The App token writes, so its merge
|
|
286
|
+
// starts the push workflows; the workflow token reads, as the App may hold no Checks permission.
|
|
287
|
+
export async function run({ client, reader = client, repository, config, now = Date.now(), log = console.log, wait = sleep }) {
|
|
288
|
+
const rows = [];
|
|
289
|
+
const deniedReads = new Set();
|
|
290
|
+
if (config.scope === "off") return { rows, off: true };
|
|
291
|
+
|
|
292
|
+
const { data: repo } = await reader.request("GET", `/repos/${repository}`);
|
|
293
|
+
const defaultBranch = repo.default_branch;
|
|
294
|
+
const open = await reader.paginate(`/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=100`);
|
|
295
|
+
const candidates = open.filter((pr) => isDependabot(pr.user?.login)).slice(0, MAX_EXAMINED);
|
|
296
|
+
const required = candidates.length > 0 ? await requiredChecks(reader, repository, defaultBranch) : [];
|
|
297
|
+
let merges = 0;
|
|
298
|
+
|
|
299
|
+
for (const listed of candidates) {
|
|
300
|
+
if (reader.rateRemaining() < MIN_RATE_REMAINING) {
|
|
301
|
+
rows.push({ number: listed.number, title: listed.title, bump: "", action: "wait", reason: "API rate limit nearly spent" });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
let pr, commits, comments, checks;
|
|
305
|
+
try {
|
|
306
|
+
pr = await readPullRequest(reader, repository, listed.number, wait);
|
|
307
|
+
commits = await denied("pull request commits", "Pull requests", () => reader.paginate(`/repos/${repository}/pulls/${pr.number}/commits?per_page=100`, 250));
|
|
308
|
+
comments = await denied("issue comments", "Issues", () => reader.paginate(`/repos/${repository}/issues/${pr.number}/comments?per_page=100`));
|
|
309
|
+
const mergeableNow = pr.mergeable === true && !["dirty", "behind"].includes(pr.mergeable_state);
|
|
310
|
+
checks = mergeableNow ? await readChecks(reader, repository, pr.head.sha, required) : { state: "none", failing: [], pending: [] };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!(error instanceof ReadDenied)) throw error;
|
|
313
|
+
deniedReads.add(error.message);
|
|
314
|
+
log(`::warning::#${listed.number} skipped: ${error.message}`);
|
|
315
|
+
rows.push({ number: listed.number, title: listed.title, bump: "", action: "skip", reason: error.message });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
let outcome = decide({
|
|
319
|
+
pr, commits, defaultBranch, repository, comments, checks, now,
|
|
320
|
+
skipLabels: config.skipLabels, scope: config.scope, mergeWithoutChecks: config.mergeWithoutChecks,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (outcome.action === "merge" && merges >= config.maxMerges) {
|
|
324
|
+
outcome = { ...outcome, action: "wait", reason: `${config.maxMerges} merged this run; next run` };
|
|
325
|
+
} else if (outcome.action === "merge") {
|
|
326
|
+
merges++;
|
|
327
|
+
outcome = config.dryRun ? { ...outcome, action: "would merge" } : { ...outcome, ...await land(client, repository, pr, config) };
|
|
328
|
+
} else if (outcome.action === "rebase") {
|
|
329
|
+
if (!config.dryRun) await client.request("POST", `/repos/${repository}/issues/${pr.number}/comments`, { body: REBASE_COMMAND });
|
|
330
|
+
outcome = { ...outcome, action: config.dryRun ? "would ask rebase" : "asked rebase" };
|
|
331
|
+
}
|
|
332
|
+
log(`#${pr.number} ${outcome.action}: ${outcome.reason}`);
|
|
333
|
+
rows.push({ number: pr.number, title: pr.title, bump: outcome.bump, action: outcome.action, reason: outcome.reason });
|
|
334
|
+
}
|
|
335
|
+
return { rows, off: false, denied: [...deniedReads] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function renderSummary({ rows, off, denied: deniedReads = [] }, config) {
|
|
339
|
+
const lines = [`## Dependabot merge (${config.dryRun ? "dry run, " : ""}scope: ${config.scope})`, ""];
|
|
340
|
+
if (off) return [...lines, "DEPENDABOT_MERGE is off: nothing was examined.", ""].join("\n");
|
|
341
|
+
for (const message of deniedReads) lines.push("> [!WARNING]", `> ${message}.`, "");
|
|
342
|
+
if (rows.length === 0) return [...lines, "No open Dependabot pull requests.", ""].join("\n");
|
|
343
|
+
lines.push("| PR | Update | Bump | Result | Why |", "|---|---|---|---|---|");
|
|
344
|
+
const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
345
|
+
for (const row of rows) lines.push(`| #${row.number} | ${cell(row.title)} | ${cell(row.bump)} | ${cell(row.action)} | ${cell(row.reason)} |`);
|
|
346
|
+
return [...lines, ""].join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function main() {
|
|
350
|
+
const config = readConfig({
|
|
351
|
+
SCOPE: process.env.SCOPE, MAX_MERGES: process.env.MAX_MERGES, MERGE_WITHOUT_CHECKS: process.env.MERGE_WITHOUT_CHECKS,
|
|
352
|
+
SKIP_LABELS: process.env.SKIP_LABELS, DRY_RUN: process.env.DRY_RUN, POST_MERGE_EVENT: process.env.POST_MERGE_EVENT,
|
|
353
|
+
});
|
|
354
|
+
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
355
|
+
const client = createClient({ token: process.env.GH_TOKEN, apiUrl });
|
|
356
|
+
const reader = process.env.READ_TOKEN ? createClient({ token: process.env.READ_TOKEN, apiUrl }) : client;
|
|
357
|
+
const result = await run({ client, reader, repository: process.env.REPO, config });
|
|
358
|
+
const summary = renderSummary(result, config);
|
|
359
|
+
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
|
|
360
|
+
else console.log(summary);
|
|
361
|
+
const stuck = result.rows.filter((row) => row.action === "fail" || row.action === "blocked").length;
|
|
362
|
+
if (stuck > 0) console.log(`::notice::${stuck} Dependabot pull request(s) left open with failing checks or a refused merge; see the job summary.`);
|
|
363
|
+
const errors = result.rows.filter((row) => row.action === "error");
|
|
364
|
+
for (const row of errors) console.log(`::error::#${row.number}: ${row.reason}`);
|
|
365
|
+
if (errors.length > 0) process.exitCode = 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
369
|
+
main().catch((error) => {
|
|
370
|
+
console.log(`::error::${error.message}`);
|
|
371
|
+
process.exitCode = 1;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -1293,6 +1293,50 @@ 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
|
+
# The App installation may hold no Checks permission: the reads run on the job token.
|
|
1307
|
+
if ! printf '%s' "$db_step" | grep -q 'read-token: ${{ github.token }}' ||
|
|
1308
|
+
! printf '%s\n' "$hk_job" | grep -q '^ checks: read$'; then
|
|
1309
|
+
DB_OK=0; echo "FAIL: the Dependabot merge must read checks with GITHUB_TOKEN granted checks: read" >&2
|
|
1310
|
+
fi
|
|
1311
|
+
for knob in DEPENDABOT_MERGE DEPENDABOT_MERGE_WITHOUT_CHECKS; do
|
|
1312
|
+
if [ -z "$(router_env "$knob")" ] || ! printf '%s' "$db_step" | grep -q "env.${knob}"; then
|
|
1313
|
+
DB_OK=0
|
|
1314
|
+
echo "FAIL: ${knob} is not both declared in the router env: block and read by the Dependabot merge step" >&2
|
|
1315
|
+
fi
|
|
1316
|
+
done
|
|
1317
|
+
if grep -q 'actions/checkout' "${DEPENDABOT_DIR}/action.yml" ||
|
|
1318
|
+
grep -qE 'node:child_process|execFile|spawn\(' "${DEPENDABOT_DIR}/merge-dependabot.mjs"; then
|
|
1319
|
+
DB_OK=0
|
|
1320
|
+
echo "FAIL: merge-dependabot checks out or runs something; it must work through the API alone" >&2
|
|
1321
|
+
fi
|
|
1322
|
+
grep -qF 'verification?.verified !== true' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
|
|
1323
|
+
{ DB_OK=0; echo "FAIL: merge-dependabot no longer requires every commit to be verified" >&2; }
|
|
1324
|
+
grep -qF 'merge_method: "squash", sha: pr.head.sha' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
|
|
1325
|
+
{ DB_OK=0; echo "FAIL: merge-dependabot must squash-merge the head it judged" >&2; }
|
|
1326
|
+
if [ "$DB_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1327
|
+
fi
|
|
1328
|
+
# Dependabot's runs get a read-only token, so a CI template's merge-train dispatch fails its pull
|
|
1329
|
+
# request and holds the update forever.
|
|
1330
|
+
for template in "${HERE}/../../templates/ci/"*.yml; do
|
|
1331
|
+
[ -f "$template" ] || continue
|
|
1332
|
+
if ! grep -q 'operation=merge-train' "$template" || grep -q "pull_request.user.login != 'dependabot\[bot\]'" "$template"; then
|
|
1333
|
+
PASS=$((PASS + 1))
|
|
1334
|
+
else
|
|
1335
|
+
FAIL=$((FAIL + 1))
|
|
1336
|
+
echo "FAIL: $(basename "$template") dispatches the merge train from Dependabot's read-only runs" >&2
|
|
1337
|
+
fi
|
|
1338
|
+
done
|
|
1339
|
+
|
|
1296
1340
|
# The audit chain closes its own reports, and every way it can be wrong is silent: a report
|
|
1297
1341
|
# closed as completed with nothing implemented, or a report pinned open forever so the next
|
|
1298
1342
|
# audit never runs. Both happened. Assert the three conditions that decide it.
|
|
@@ -2584,6 +2628,63 @@ if worker_installed audit; then
|
|
|
2584
2628
|
if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2585
2629
|
fi
|
|
2586
2630
|
|
|
2631
|
+
# Every audit from 2026-09-21 until 1.12.1 filed one issue over refine's size gate, labelled `refine`,
|
|
2632
|
+
# and was refused. The audit now files a report refine never sees and stories under the gate.
|
|
2633
|
+
if worker_installed audit; then
|
|
2634
|
+
AUDIT_FILE_OK=1
|
|
2635
|
+
AUDIT_WORKER_MD="${WORKFLOWS_DIR}/agent-audit.md"
|
|
2636
|
+
REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
|
|
2637
|
+
af() { AUDIT_FILE_OK=0; echo "FAIL: the audit $1" >&2; }
|
|
2638
|
+
file_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$AUDIT_WORKER_MD")
|
|
2639
|
+
[ -n "$file_block" ] || af "does not file through file-audit-issues, so nothing measures what it files"
|
|
2640
|
+
labels_of() { printf '%s\n' "$file_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
|
|
2641
|
+
report_labels=$(labels_of report-labels)
|
|
2642
|
+
story_labels=$(labels_of story-labels)
|
|
2643
|
+
printf '%s\n' "$report_labels" | grep -qx audit || af "report is not labelled audit, so backpressure and audit-close cannot see it"
|
|
2644
|
+
printf '%s\n' "$report_labels" | grep -qx refine && af "report is labelled refine; refine refuses a report of every finding"
|
|
2645
|
+
printf '%s\n' "$story_labels" | grep -qx refine || af "stories are not labelled refine, so nothing refines them"
|
|
2646
|
+
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"
|
|
2647
|
+
grep -qE '^ staged: true' "$AUDIT_WORKER_MD" || af "is not staged, so gh-aw files the issues before they are measured"
|
|
2648
|
+
audit_env() { sed -n "s/^ $1: \"\([0-9]*\)\"$/\1/p" "$AUDIT_WORKER_MD" | head -1; }
|
|
2649
|
+
if [ -f "$REFINE_WORKER_MD" ]; then
|
|
2650
|
+
refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
|
|
2651
|
+
refine_units=$(sed -n 's/^ REFINE_MAX_WORK_UNITS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
|
|
2652
|
+
[ "$(audit_env AUDIT_MAX_BODY_CHARS)" -lt "${refine_chars:-0}" ] 2>/dev/null ||
|
|
2653
|
+
af "AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
|
|
2654
|
+
[ "$(audit_env AUDIT_MAX_WORK_UNITS)" -lt "${refine_units:-0}" ] 2>/dev/null ||
|
|
2655
|
+
af "AUDIT_MAX_WORK_UNITS is not below refine's REFINE_MAX_WORK_UNITS (${refine_units:-unset})"
|
|
2656
|
+
grep -qF 'uses: ./.github/actions/measure-issue-size' "$REFINE_WORKER_MD" ||
|
|
2657
|
+
af "and refine measure with different rules; refine's size_guard must use measure-issue-size"
|
|
2658
|
+
fi
|
|
2659
|
+
if [ "$AUDIT_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2660
|
+
fi
|
|
2661
|
+
|
|
2662
|
+
# The docs audit filed the same single refine-labelled report and hit the same gate; it now files
|
|
2663
|
+
# like the audit: a report refine never sees and stories under the gate, measured before filing.
|
|
2664
|
+
if worker_installed docs-audit; then
|
|
2665
|
+
DOCS_FILE_OK=1
|
|
2666
|
+
DOCS_WORKER_MD="${WORKFLOWS_DIR}/agent-docs-audit.md"
|
|
2667
|
+
dfa() { DOCS_FILE_OK=0; echo "FAIL: the docs audit $1" >&2; }
|
|
2668
|
+
docs_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$DOCS_WORKER_MD")
|
|
2669
|
+
[ -n "$docs_block" ] || dfa "does not file through file-audit-issues, so nothing measures what it files"
|
|
2670
|
+
docs_labels_of() { printf '%s\n' "$docs_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
|
|
2671
|
+
docs_report_labels=$(docs_labels_of report-labels)
|
|
2672
|
+
docs_story_labels=$(docs_labels_of story-labels)
|
|
2673
|
+
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"
|
|
2674
|
+
printf '%s\n' "$docs_report_labels" | grep -qx refine && dfa "report is labelled refine; refine refuses a report of every finding"
|
|
2675
|
+
printf '%s\n' "$docs_story_labels" | grep -qx refine || dfa "stories are not labelled refine, so nothing refines them"
|
|
2676
|
+
printf '%s\n' "$docs_story_labels" | grep -qx docs-audit && dfa "stories are labelled docs-audit; one of them fills the backpressure"
|
|
2677
|
+
grep -qE '^ staged: true' "$DOCS_WORKER_MD" || dfa "is not staged, so gh-aw files the issues before they are measured"
|
|
2678
|
+
grep -q 'add-issue-labels' "$DOCS_WORKER_MD" && dfa "still labels a report gh-aw filed itself"
|
|
2679
|
+
if [ -f "${WORKFLOWS_DIR}/agent-refine.md" ]; then
|
|
2680
|
+
docs_chars=$(sed -n 's/^ DOCS_AUDIT_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$DOCS_WORKER_MD" | head -1)
|
|
2681
|
+
refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "${WORKFLOWS_DIR}/agent-refine.md" | head -1)
|
|
2682
|
+
[ "${docs_chars:-99999999}" -lt "${refine_chars:-0}" ] 2>/dev/null ||
|
|
2683
|
+
dfa "DOCS_AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
|
|
2684
|
+
fi
|
|
2685
|
+
if [ "$DOCS_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2686
|
+
fi
|
|
2687
|
+
|
|
2587
2688
|
echo "── Retrying a run that died early ────────────────────────────────────────"
|
|
2588
2689
|
|
|
2589
2690
|
# A run that died before producing anything is worth repeating; one that worked and then failed
|