@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.
@@ -9,9 +9,16 @@ description: >-
9
9
  inputs:
10
10
  token:
11
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.
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
- export async function run({ client, repository, config, now = Date.now(), log = console.log, wait = sleep }) {
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 client.request("GET", `/repos/${repository}`);
292
+ const { data: repo } = await reader.request("GET", `/repos/${repository}`);
275
293
  const defaultBranch = repo.default_branch;
276
- const open = await client.paginate(`/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=100`);
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(client, repository, defaultBranch) : [];
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 (client.rateRemaining() < MIN_RATE_REMAINING) {
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
- 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: [] };
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 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 });
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.0",
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": "^3.0.0"
44
+ "vitest": "^4.1.11"
45
45
  }
46
46
  }