@plainconceptsplatform/workflows 1.13.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/loops/actions/merge-dependabot/action.yml +10 -2
- package/loops/actions/merge-dependabot/merge-dependabot.mjs +47 -17
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +5 -0
- package/loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml +3 -1
- package/loops/workflows/work-router.yml +4 -0
- package/package.json +2 -2
|
@@ -9,9 +9,16 @@ description: >-
|
|
|
9
9
|
inputs:
|
|
10
10
|
token:
|
|
11
11
|
description: >-
|
|
12
|
-
Token
|
|
13
|
-
workflows on the default branch; a merge made with
|
|
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.
|
|
14
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 }}
|
|
15
22
|
scope:
|
|
16
23
|
description: "Which updates merge: all, minor (minor and patch), patch, or off."
|
|
17
24
|
required: false
|
|
@@ -44,6 +51,7 @@ runs:
|
|
|
44
51
|
shell: bash
|
|
45
52
|
env:
|
|
46
53
|
GH_TOKEN: ${{ inputs.token }}
|
|
54
|
+
READ_TOKEN: ${{ inputs.read-token }}
|
|
47
55
|
REPO: ${{ github.repository }}
|
|
48
56
|
SCOPE: ${{ inputs.scope }}
|
|
49
57
|
MERGE_WITHOUT_CHECKS: ${{ inputs.merge-without-checks }}
|
|
@@ -19,6 +19,13 @@ export const NO_CHECKS_GRACE_MINUTES = 60;
|
|
|
19
19
|
|
|
20
20
|
export const isDependabot = (login) => DEPENDABOT_LOGINS.has(login ?? "");
|
|
21
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
|
+
|
|
22
29
|
export function authorshipProblem(pr, commits, repository) {
|
|
23
30
|
if (!isDependabot(pr.user?.login)) return `opened by ${pr.user?.login ?? "unknown"}`;
|
|
24
31
|
if (String(pr.head?.repo?.full_name).toLowerCase() !== repository.toLowerCase()) return "its branch is in another repository";
|
|
@@ -196,7 +203,7 @@ export function createClient({ token, apiUrl = "https://api.github.com", fetchIm
|
|
|
196
203
|
let next = path;
|
|
197
204
|
while (next && items.length < limit) {
|
|
198
205
|
const page = await request("GET", next);
|
|
199
|
-
if (page.status >= 400) throw new Error(`GET ${next} answered ${page.status}: ${JSON.stringify(page.data)}`);
|
|
206
|
+
if (page.status >= 400) throw Object.assign(new Error(`GET ${next} answered ${page.status}: ${JSON.stringify(page.data)}`), { status: page.status });
|
|
200
207
|
items.push(...(Array.isArray(page.data) ? page.data : page.data?.check_runs ?? []));
|
|
201
208
|
next = /<([^>]+)>;\s*rel="next"/.exec(page.link)?.[1];
|
|
202
209
|
}
|
|
@@ -207,9 +214,16 @@ export function createClient({ token, apiUrl = "https://api.github.com", fetchIm
|
|
|
207
214
|
|
|
208
215
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
209
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
|
+
|
|
210
223
|
async function readPullRequest(client, repository, number, wait) {
|
|
211
224
|
for (let attempt = 0; ; attempt++) {
|
|
212
|
-
const { data } = await client.request("GET", `/repos/${repository}/pulls/${number}`);
|
|
225
|
+
const { status, data } = await client.request("GET", `/repos/${repository}/pulls/${number}`);
|
|
226
|
+
if (status === 403) throw new ReadDenied("pull requests", "Pull requests");
|
|
213
227
|
if (data?.mergeable !== null || attempt >= 3) return data;
|
|
214
228
|
await wait(2000);
|
|
215
229
|
}
|
|
@@ -228,8 +242,9 @@ async function requiredChecks(client, repository, branch) {
|
|
|
228
242
|
}
|
|
229
243
|
|
|
230
244
|
async function readChecks(client, repository, sha, required) {
|
|
231
|
-
const checkRuns = await client.paginate(`/repos/${repository}/commits/${sha}/check-runs?per_page=100`);
|
|
245
|
+
const checkRuns = await denied("checks", "Checks", () => client.paginate(`/repos/${repository}/commits/${sha}/check-runs?per_page=100`));
|
|
232
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");
|
|
233
248
|
return summarizeChecks({ checkRuns, statuses: combined.data?.statuses ?? [], required });
|
|
234
249
|
}
|
|
235
250
|
|
|
@@ -267,27 +282,39 @@ async function land(client, repository, pr, config) {
|
|
|
267
282
|
return { action: "error", reason: `merge answered ${merged.status}: ${message}` };
|
|
268
283
|
}
|
|
269
284
|
|
|
270
|
-
|
|
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 }) {
|
|
271
288
|
const rows = [];
|
|
289
|
+
const deniedReads = new Set();
|
|
272
290
|
if (config.scope === "off") return { rows, off: true };
|
|
273
291
|
|
|
274
|
-
const { data: repo } = await
|
|
292
|
+
const { data: repo } = await reader.request("GET", `/repos/${repository}`);
|
|
275
293
|
const defaultBranch = repo.default_branch;
|
|
276
|
-
const open = await
|
|
294
|
+
const open = await reader.paginate(`/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=100`);
|
|
277
295
|
const candidates = open.filter((pr) => isDependabot(pr.user?.login)).slice(0, MAX_EXAMINED);
|
|
278
|
-
const required = candidates.length > 0 ? await requiredChecks(
|
|
296
|
+
const required = candidates.length > 0 ? await requiredChecks(reader, repository, defaultBranch) : [];
|
|
279
297
|
let merges = 0;
|
|
280
298
|
|
|
281
299
|
for (const listed of candidates) {
|
|
282
|
-
if (
|
|
300
|
+
if (reader.rateRemaining() < MIN_RATE_REMAINING) {
|
|
283
301
|
rows.push({ number: listed.number, title: listed.title, bump: "", action: "wait", reason: "API rate limit nearly spent" });
|
|
284
302
|
continue;
|
|
285
303
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|
|
291
318
|
let outcome = decide({
|
|
292
319
|
pr, commits, defaultBranch, repository, comments, checks, now,
|
|
293
320
|
skipLabels: config.skipLabels, scope: config.scope, mergeWithoutChecks: config.mergeWithoutChecks,
|
|
@@ -305,12 +332,13 @@ export async function run({ client, repository, config, now = Date.now(), log =
|
|
|
305
332
|
log(`#${pr.number} ${outcome.action}: ${outcome.reason}`);
|
|
306
333
|
rows.push({ number: pr.number, title: pr.title, bump: outcome.bump, action: outcome.action, reason: outcome.reason });
|
|
307
334
|
}
|
|
308
|
-
return { rows, off: false };
|
|
335
|
+
return { rows, off: false, denied: [...deniedReads] };
|
|
309
336
|
}
|
|
310
337
|
|
|
311
|
-
export function renderSummary({ rows, off }, config) {
|
|
338
|
+
export function renderSummary({ rows, off, denied: deniedReads = [] }, config) {
|
|
312
339
|
const lines = [`## Dependabot merge (${config.dryRun ? "dry run, " : ""}scope: ${config.scope})`, ""];
|
|
313
340
|
if (off) return [...lines, "DEPENDABOT_MERGE is off: nothing was examined.", ""].join("\n");
|
|
341
|
+
for (const message of deniedReads) lines.push("> [!WARNING]", `> ${message}.`, "");
|
|
314
342
|
if (rows.length === 0) return [...lines, "No open Dependabot pull requests.", ""].join("\n");
|
|
315
343
|
lines.push("| PR | Update | Bump | Result | Why |", "|---|---|---|---|---|");
|
|
316
344
|
const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
@@ -323,8 +351,10 @@ async function main() {
|
|
|
323
351
|
SCOPE: process.env.SCOPE, MAX_MERGES: process.env.MAX_MERGES, MERGE_WITHOUT_CHECKS: process.env.MERGE_WITHOUT_CHECKS,
|
|
324
352
|
SKIP_LABELS: process.env.SKIP_LABELS, DRY_RUN: process.env.DRY_RUN, POST_MERGE_EVENT: process.env.POST_MERGE_EVENT,
|
|
325
353
|
});
|
|
326
|
-
const
|
|
327
|
-
const
|
|
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 });
|
|
328
358
|
const summary = renderSummary(result, config);
|
|
329
359
|
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
|
|
330
360
|
else console.log(summary);
|
|
@@ -1303,6 +1303,11 @@ if [ -f "${DEPENDABOT_DIR}/action.yml" ] && grep -q '^ housekeeping:$' "$ROUTER
|
|
|
1303
1303
|
db_step=$(printf '%s\n' "$hk_job" | sed -n '/uses: \.\/\.github\/actions\/merge-dependabot/,/^ - /p')
|
|
1304
1304
|
printf '%s' "$db_step" | grep -q 'token: ${{ steps.app-token.outputs.token }}' ||
|
|
1305
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
|
|
1306
1311
|
for knob in DEPENDABOT_MERGE DEPENDABOT_MERGE_WITHOUT_CHECKS; do
|
|
1307
1312
|
if [ -z "$(router_env "$knob")" ] || ! printf '%s' "$db_step" | grep -q "env.${knob}"; then
|
|
1308
1313
|
DB_OK=0
|
|
@@ -46,7 +46,8 @@ jobs:
|
|
|
46
46
|
timeout-minutes: 15
|
|
47
47
|
permissions:
|
|
48
48
|
# Merging, deleting the merged branch, approving where a ruleset asks for it, and commenting
|
|
49
|
-
# `@dependabot rebase`. All of it through the API.
|
|
49
|
+
# `@dependabot rebase`. All of it through the API. checks and statuses: the reads run on
|
|
50
|
+
# this token, since the App installation may hold no Checks permission.
|
|
50
51
|
contents: write
|
|
51
52
|
pull-requests: write
|
|
52
53
|
issues: write
|
|
@@ -72,6 +73,7 @@ jobs:
|
|
|
72
73
|
- uses: ./.github/actions/merge-dependabot
|
|
73
74
|
with:
|
|
74
75
|
token: ${{ steps.app-token.outputs.token || github.token }}
|
|
76
|
+
read-token: ${{ github.token }}
|
|
75
77
|
scope: ${{ vars.DEPENDABOT_MERGE || env.DEPENDABOT_MERGE }}
|
|
76
78
|
merge-without-checks: ${{ env.DEPENDABOT_MERGE_WITHOUT_CHECKS }}
|
|
77
79
|
post-merge-event: ""
|
|
@@ -1835,6 +1835,9 @@ jobs:
|
|
|
1835
1835
|
pull-requests: read
|
|
1836
1836
|
# actions: write cancels workflow runs stuck pending in the fleet queue.
|
|
1837
1837
|
actions: write
|
|
1838
|
+
# The Dependabot merge reads check runs and statuses with this token, not the App's.
|
|
1839
|
+
checks: read
|
|
1840
|
+
statuses: read
|
|
1838
1841
|
env:
|
|
1839
1842
|
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
1840
1843
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
@@ -1867,6 +1870,7 @@ jobs:
|
|
|
1867
1870
|
uses: ./.github/actions/merge-dependabot
|
|
1868
1871
|
with:
|
|
1869
1872
|
token: ${{ steps.app-token.outputs.token }}
|
|
1873
|
+
read-token: ${{ github.token }}
|
|
1870
1874
|
scope: ${{ vars.DEPENDABOT_MERGE || env.DEPENDABOT_MERGE }}
|
|
1871
1875
|
merge-without-checks: ${{ env.DEPENDABOT_MERGE_WITHOUT_CHECKS }}
|
|
1872
1876
|
skip-labels: do-not-merge blocked human-review ${{ env.MERGE_TRAIN_HOLD_LABEL }}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/workflows",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"description": "Install and update Platform GitHub agentic workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -41,6 +41,6 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "^22.0.0",
|
|
43
43
|
"typescript": "^5.8.0",
|
|
44
|
-
"vitest": "^
|
|
44
|
+
"vitest": "^4.1.11"
|
|
45
45
|
}
|
|
46
46
|
}
|