@plainconceptsplatform/workflows 0.6.1 → 0.16.2

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.
Files changed (58) hide show
  1. package/README.md +107 -88
  2. package/dist/catalog-installation.d.ts +39 -2
  3. package/dist/catalog-installation.js +172 -109
  4. package/dist/index.js +128 -73
  5. package/dist/package-baseline.d.ts +25 -0
  6. package/dist/package-baseline.js +138 -0
  7. package/dist/route-processing.d.ts +0 -2
  8. package/dist/route-processing.js +22 -91
  9. package/dist/stack-defaults.js +18 -12
  10. package/dist/tui.js +27 -43
  11. package/dist/worker-env.d.ts +46 -0
  12. package/dist/worker-env.js +179 -0
  13. package/dist/workflow-catalog.d.ts +4 -2
  14. package/dist/workflow-catalog.js +5 -3
  15. package/loops/actions/add-issue-labels/action.yml +20 -0
  16. package/loops/actions/audit-close/action.yml +180 -128
  17. package/loops/actions/classify-route/classify-route.sh +8 -2
  18. package/loops/actions/housekeeping/action.yml +251 -0
  19. package/loops/actions/merge-agent-pr/action.yml +13 -0
  20. package/loops/actions/report-workflow-errors/action.yml +414 -0
  21. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  22. package/loops/actions/validate-triage-output/action.yml +1 -1
  23. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  24. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  25. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +911 -170
  26. package/loops/templates/agentics/agentics-error-report.yml +98 -0
  27. package/loops/templates/opencode/opencode.ci.json +1 -1
  28. package/loops/workflows/agent-apply-review.md +438 -469
  29. package/loops/workflows/agent-audit.md +200 -213
  30. package/loops/workflows/agent-implement.md +591 -640
  31. package/loops/workflows/agent-merge-gate.md +826 -844
  32. package/loops/workflows/agent-refine.md +590 -633
  33. package/loops/workflows/agent-release.md +244 -258
  34. package/loops/workflows/agent-triage.md +476 -447
  35. package/loops/workflows/authorize-bot-work.yml +26 -6
  36. package/loops/workflows/shared/platform-defaults.md +18 -1
  37. package/loops/workflows/work-router.yml +1185 -1038
  38. package/package.json +9 -8
  39. package/dist/action-validation.test.d.ts +0 -1
  40. package/dist/action-validation.test.js +0 -87
  41. package/dist/catalog-installation.test.d.ts +0 -1
  42. package/dist/catalog-installation.test.js +0 -485
  43. package/dist/catalog-listing.test.d.ts +0 -1
  44. package/dist/catalog-listing.test.js +0 -150
  45. package/dist/index.test.d.ts +0 -1
  46. package/dist/index.test.js +0 -273
  47. package/dist/repository-inspection.test.d.ts +0 -1
  48. package/dist/repository-inspection.test.js +0 -77
  49. package/dist/route-processing.test.d.ts +0 -1
  50. package/dist/route-processing.test.js +0 -283
  51. package/dist/stack-defaults.test.d.ts +0 -1
  52. package/dist/stack-defaults.test.js +0 -266
  53. package/dist/tui.test.d.ts +0 -1
  54. package/dist/tui.test.js +0 -249
  55. package/dist/workflow-catalog.test.d.ts +0 -1
  56. package/dist/workflow-catalog.test.js +0 -29
  57. package/loops/actions/stale-recovery/action.yml +0 -288
  58. package/loops/actions/update-changelog/action.yml +0 -113
@@ -1,1038 +1,1185 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/work-router.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
- name: "All Work Router"
3
-
4
- # Owns every repository automation trigger. Classifies each event into exactly one route,
5
- # then calls at most one worker or runs one deterministic job. No model runs here.
6
-
7
- # GitHub titles an issue-triggered run with the issue title, so every run on one issue reads
8
- # the same. Name it after the event instead. This is evaluated before any job, so it can
9
- # describe the trigger but not the route the classifier picks.
10
- run-name: >-
11
- ${{ github.event_name == 'issues'
12
- && github.event.action == 'opened'
13
- && format('Issue opened: {0} (#{1}) by {2}', github.event.issue.title, github.event.issue.number, github.actor)
14
- || github.event_name == 'issues'
15
- && github.event.label.name == 'bot-working'
16
- && contains(github.event.issue.labels.*.name, 'implement')
17
- && format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
18
- || github.event_name == 'issues'
19
- && github.event.label.name == 'bot-working'
20
- && contains(github.event.issue.labels.*.name, 'refine')
21
- && format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
22
- || github.event_name == 'issues'
23
- && github.event.label.name == 'bot-working'
24
- && contains(github.event.issue.labels.*.name, 'direct')
25
- && format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
26
- || github.event_name == 'issues'
27
- && github.event.label.name == 'bot-working'
28
- && contains(github.event.issue.labels.*.name, 'triage')
29
- && format('Working (Triage): {0} (#{1})', github.event.issue.title, github.event.issue.number)
30
- || github.event_name == 'issues'
31
- && github.event.label.name == 'bot-working'
32
- && format('Working: {0} (#{1})', github.event.issue.title, github.event.issue.number)
33
- || github.event_name == 'issues'
34
- && github.event.label.name == 'implement'
35
- && endsWith(github.actor, '[bot]')
36
- && format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
37
- || github.event_name == 'issues'
38
- && github.event.label.name == 'refine'
39
- && endsWith(github.actor, '[bot]')
40
- && format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
41
- || github.event_name == 'issues'
42
- && github.event.label.name == 'direct'
43
- && endsWith(github.actor, '[bot]')
44
- && format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
45
- || github.event_name == 'issues'
46
- && format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
47
- || github.event_name == 'issue_comment'
48
- && format('comment on #{0} by {1}', github.event.issue.number, github.actor)
49
- || github.event_name == 'pull_request_review'
50
- && format('review on PR #{0} by {1}', github.event.pull_request.number, github.actor)
51
- || github.event_name == 'pull_request_review_comment'
52
- && format('review comment on PR #{0} by {1}', github.event.pull_request.number, github.actor)
53
- || github.event_name == 'pull_request_target'
54
- && format('PR #{0} {1} by {2}', github.event.pull_request.number, github.event.action, github.actor)
55
- || github.event_name == 'workflow_run'
56
- && format('CI {0} on {1}', github.event.workflow_run.conclusion, github.event.workflow_run.head_branch)
57
- || github.event_name == 'workflow_dispatch'
58
- && inputs.operation == 'triage'
59
- && format('Working (Triage): #{0}', inputs.issue-number)
60
- || github.event_name == 'workflow_dispatch'
61
- && inputs.operation == 'refine'
62
- && format('Working (Refine): #{0}', inputs.issue-number)
63
- || github.event_name == 'workflow_dispatch'
64
- && inputs.operation == 'implement'
65
- && format('Working (Implement): #{0}', inputs.issue-number)
66
- || github.event_name == 'workflow_dispatch'
67
- && inputs.operation == 'merge-gate'
68
- && format('Working (Merge Gate): PR #{0}', inputs.pr-number)
69
- || github.event_name == 'workflow_dispatch'
70
- && inputs.operation == 'batch'
71
- && format('Working (Batch): #{0}', inputs.issue-number)
72
- || github.event_name == 'workflow_dispatch'
73
- && format('dispatch: {0}', inputs.operation)
74
- || github.event_name == 'schedule'
75
- && format('schedule {0}', github.event.schedule)
76
- || github.event_name }}
77
-
78
- on:
79
- issues:
80
- types: [opened, labeled]
81
- issue_comment:
82
- types: [created]
83
- pull_request_review_comment:
84
- types: [created]
85
- pull_request_review:
86
- types: [submitted]
87
-
88
- pull_request_target:
89
- # opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
90
- # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
91
- # moved and previously-mergeable sibling bot PRs may now conflict.
92
- types: [opened, synchronize, reopened, labeled, closed]
93
-
94
- # CI completion on a pull request. The classifier admits only conclusion == failure with an
95
- # attached pull request; a green run reaches the gate through the consumer CI's
96
- # dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
97
- # the gate on every passing PR.
98
- #
99
- # Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
100
- # whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
101
- # follow have the bot as their actor, and no workflow_run arrives for any of them (four
102
- # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
103
- # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
104
- # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
105
- # branch with secrets, but the gate's subject job rejects any PR that is not open and
106
- # bot-authored, so a fork PR's failing CI cannot start privileged work.
107
- workflow_run:
108
- workflows: ["App: CI"]
109
- types: [completed]
110
-
111
- # Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
112
- schedule:
113
- # Slot 0. Every consumer installs this same file, and the agent fleet is one shared,
114
- # two-VM scale set, so a second repository left on slot 0 audits at the same minute as
115
- # the first. Pick a free slot per repository: see "Scheduling across repositories" in the
116
- # package README, and change AUDIT_CRON in classify-route.sh to match, or the run fires
117
- # and classifies to no route.
118
- - cron: "17 1 * * 1"
119
- - cron: "43 3 * * *"
120
- - cron: "0 6 * * *"
121
- - cron: "17 * * * *"
122
-
123
- workflow_dispatch:
124
- inputs:
125
- operation:
126
- description: "Operation to run"
127
- required: true
128
- type: choice
129
- options:
130
- - refine
131
- - implement
132
- - triage
133
- - apply-review
134
- - merge-gate
135
- - audit
136
- - audit-close
137
- - cleanup-artifacts
138
- - reconcile-bot-pr-runs
139
- - validate
140
- - release
141
- issue-number:
142
- description: "Issue number (required for refine / implement / direct)"
143
- required: false
144
- type: string
145
- pr-number:
146
- description: "Pull request number (required for apply-review / merge-gate)"
147
- required: false
148
- type: string
149
- mode:
150
- description: "Refine mode: first or rerefine"
151
- required: false
152
- type: choice
153
- options:
154
- - first
155
- - rerefine
156
- ci-conclusion:
157
- description: "CI conclusion for merge-gate dispatch"
158
- required: false
159
- type: choice
160
- options:
161
- - success
162
- - failure
163
- - action_required
164
- ci-run-id:
165
- description: "CI workflow run ID for merge-gate dispatch"
166
- required: false
167
- type: string
168
- attempts_so_far:
169
- description: "Failed merge-gate attempts already made against this CI verdict (filled by the router, not by humans)"
170
- required: false
171
- type: string
172
- default: '0'
173
- trigger-kind:
174
- description: "Audit trigger: scheduled or manual"
175
- required: false
176
- type: choice
177
- options:
178
- - manual
179
- - scheduled
180
- version-bump:
181
- description: "Release version bump: patch, minor, major, or auto"
182
- required: false
183
- type: choice
184
- options:
185
- - auto
186
- - patch
187
- - minor
188
- - major
189
-
190
- permissions:
191
- contents: read
192
-
193
- jobs:
194
- # Public repositories must check repository permission before an event can start a model.
195
- authorize:
196
- runs-on: ubuntu-latest
197
- timeout-minutes: 5
198
- permissions:
199
- contents: read
200
- outputs:
201
- trusted: ${{ steps.check.outputs.trusted }}
202
- is_outside_collaborator: ${{ steps.check.outputs.is_outside_collaborator }}
203
- steps:
204
- - name: Check the actor can start an agent
205
- id: check
206
- env:
207
- GH_TOKEN: ${{ github.token }}
208
- ACTOR: ${{ github.actor }}
209
- EVENT: ${{ github.event_name }}
210
- run: |
211
- set -euo pipefail
212
-
213
- # Events with no human actor. `schedule` and `workflow_run` are the fleet triggering
214
- # itself; `workflow_dispatch` is already restricted by GitHub to write and above.
215
- case "$EVENT" in
216
- schedule | workflow_run | workflow_dispatch)
217
- echo "trusted=true" >> "$GITHUB_OUTPUT"
218
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
219
- echo "::notice::$EVENT has no untrusted actor."
220
- exit 0
221
- ;;
222
- esac
223
-
224
- # Platform bot owns Safe Outputs. Its label changes must be able to
225
- # start the follow-on worker, even though GitHub does not report it
226
- # as a repository collaborator.
227
- if [ "$ACTOR" = "github-actions[bot]" ] || [ "$ACTOR" = "platform-devbox[bot]" ]; then
228
- echo "trusted=true" >> "$GITHUB_OUTPUT"
229
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
230
- exit 0
231
- fi
232
-
233
- permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
234
- --jq '.permission' 2>/dev/null || echo "none")
235
-
236
- # Check if the actor is an organization member. Outside collaborators can have
237
- # write/maintain permission on a repo without being org members. Triage is for
238
- # outside collaborators only — a non-member with write access is still outside.
239
- org=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
240
- is_member=false
241
- if gh api "orgs/$org/members/$ACTOR" >/dev/null 2>&1; then
242
- is_member=true
243
- fi
244
-
245
- case "$permission" in
246
- admin | maintain | write)
247
- if [ "$is_member" = "true" ]; then
248
- echo "trusted=true" >> "$GITHUB_OUTPUT"
249
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
250
- echo "::notice::$ACTOR has $permission permission and is an org member."
251
- else
252
- echo "trusted=true" >> "$GITHUB_OUTPUT"
253
- echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
254
- echo "::notice::$ACTOR has $permission permission but is NOT an org member (outside collaborator). Triage route eligible."
255
- fi
256
- ;;
257
- read)
258
- echo "trusted=false" >> "$GITHUB_OUTPUT"
259
- echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
260
- echo "::notice::$ACTOR is an outside collaborator (read permission). Triage route eligible."
261
- ;;
262
- *)
263
- echo "trusted=false" >> "$GITHUB_OUTPUT"
264
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
265
- echo "::notice::$ACTOR has '$permission' permission on this repository. No agent will run."
266
- ;;
267
- esac
268
-
269
- classify:
270
- runs-on: ubuntu-latest
271
- timeout-minutes: 5
272
- permissions:
273
- contents: read
274
- pull-requests: read
275
- outputs:
276
- route: ${{ steps.route.outputs.route }}
277
- issue-number: ${{ steps.route.outputs.issue-number }}
278
- pr-number: ${{ steps.route.outputs.pr-number }}
279
- linked-issue: ${{ steps.route.outputs.linked-issue }}
280
- ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
281
- ci-run-id: ${{ steps.route.outputs.ci-run-id }}
282
- merge-gate-attempts: ${{ steps.route.outputs.merge-gate-attempts }}
283
- implement-attempts: ${{ steps.route.outputs.implement-attempts }}
284
- refine-mode: ${{ steps.route.outputs.refine-mode }}
285
- triage-mode: ${{ steps.route.outputs.triage-mode }}
286
- trigger-kind: ${{ steps.route.outputs.trigger-kind }}
287
- steps:
288
- - name: Checkout workflow actions
289
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
290
- with:
291
- persist-credentials: false
292
- - name: Classify event into a single route
293
- id: route
294
- uses: ./.github/actions/classify-route
295
- with:
296
- token: ${{ github.token }}
297
-
298
- call-refine:
299
- needs: [classify, authorize]
300
- if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
301
- uses: ./.github/workflows/agent-refine.lock.yml
302
- concurrency:
303
- group: refine-${{ needs.classify.outputs.issue-number }}
304
- cancel-in-progress: false
305
- # write-all, not a narrower map: every worker's agent job declares `permissions: read-all`,
306
- # which requests read on every scope. A caller granting less cannot satisfy it, and GitHub
307
- # rejects the call with a startup failure before any job is created. The real boundary is the
308
- # worker's own read-all plus safe-outputs, not this grant.
309
- permissions: write-all
310
- with:
311
- issue-number: ${{ needs.classify.outputs.issue-number }}
312
- mode: ${{ needs.classify.outputs.refine-mode }}
313
- secrets:
314
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
315
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
316
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
317
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
318
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
319
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
320
-
321
- # Checks whether an open bot PR already exists for the issue. If it does, implement is
322
- # skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
323
- # PRs when retries are triggered after a merge-gate failure.
324
- check-implement-pr:
325
- needs: [classify, authorize]
326
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
327
- runs-on: ubuntu-latest
328
- timeout-minutes: 3
329
- permissions:
330
- contents: read
331
- pull-requests: read
332
- outputs:
333
- has-open-pr: ${{ steps.check.outputs.has-open-pr }}
334
- steps:
335
- - name: Check for existing open bot PR
336
- id: check
337
- env:
338
- GH_TOKEN: ${{ github.token }}
339
- REPO: ${{ github.repository }}
340
- ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
341
- run: |
342
- set -euo pipefail
343
- # A refine-to-implement transition emits two label events (the implement label,
344
- # then the worker re-adding bot-working) and each one classifies to implement.
345
- # The older router run wins: a younger run that finds an older live implement
346
- # route for the same issue skips instead of queueing a duplicate worker. Only
347
- # bot-actor titles are counted, so a human's "waiting for bot-working" run can
348
- # never block the real one.
349
- for st in in_progress queued; do
350
- dupes=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$st&per_page=100" --jq "[.workflow_runs[]
351
- | select(.id < $GITHUB_RUN_ID)
352
- | select(
353
- ((.display_title | startswith(\"Working (Implement)\")) and (.display_title | contains(\"(#${ISSUE_NUMBER})\")))
354
- or
355
- ((.display_title | contains(\"implement label on #${ISSUE_NUMBER} by\")) and (.display_title | endswith(\"[bot]\")))
356
- )
357
- ] | length" 2>/dev/null || echo 0)
358
- if [ "${dupes:-0}" -gt 0 ]; then
359
- echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
360
- echo "::notice::An older router run is already implementing #$ISSUE_NUMBER; skipping the duplicate."
361
- exit 0
362
- fi
363
- done
364
-
365
- # Marker-first: implement stamps <!-- implement-pr: N --> on the issue when its
366
- # pull request is created, so the duplicate check is an exact lookup. Only an
367
- # OPEN marker PR blocks; a merged or closed one means a re-implement is legitimate.
368
- marker_pr=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' \
369
- | grep -oE '<!-- implement-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
370
- if [ -n "$marker_pr" ]; then
371
- state=$(gh pr view "$marker_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo GONE)
372
- if [ "$state" = "OPEN" ]; then
373
- echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
374
- echo "::notice::Issue #$ISSUE_NUMBER is marked as implemented by open PR #$marker_pr. Skipping duplicate implement."
375
- exit 0
376
- fi
377
- echo "Marker PR #$marker_pr is $state; a fresh implement is allowed."
378
- fi
379
-
380
- existing=$(gh pr list --repo "$REPO" --state open --json number,author,body \
381
- --jq "[.[]
382
- | select(.author.login | startswith(\"app/\") or endswith(\"[bot]\"))
383
- | (.body | ascii_downcase) as \$body
384
- | select(\$body | contains(\"close #${ISSUE_NUMBER}\") or contains(\"closes #${ISSUE_NUMBER}\") or contains(\"closed #${ISSUE_NUMBER}\") or contains(\"fix #${ISSUE_NUMBER}\") or contains(\"fixes #${ISSUE_NUMBER}\") or contains(\"fixed #${ISSUE_NUMBER}\") or contains(\"resolve #${ISSUE_NUMBER}\") or contains(\"resolves #${ISSUE_NUMBER}\") or contains(\"resolved #${ISSUE_NUMBER}\"))
385
- ] | length")
386
- if [ "$existing" -gt 0 ]; then
387
- echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
388
- echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
389
- else
390
- echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
391
- fi
392
-
393
- call-implement:
394
- needs: [classify, authorize, check-implement-pr]
395
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
396
- uses: ./.github/workflows/agent-implement.lock.yml
397
- concurrency:
398
- group: implement-global
399
- cancel-in-progress: false
400
- # FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
401
- # was pending, so labelling six issues at once implements one and drops the rest.
402
- queue: max
403
- permissions: write-all
404
- with:
405
- issue-number: ${{ needs.classify.outputs.issue-number }}
406
- attempts_so_far: ${{ needs.classify.outputs.implement-attempts }}
407
- secrets:
408
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
409
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
410
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
411
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
412
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
413
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
414
-
415
- dispatch-triage:
416
- needs: [classify, authorize]
417
- if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
418
- runs-on: ubuntu-latest
419
- timeout-minutes: 5
420
- permissions:
421
- actions: write
422
- contents: read
423
- steps:
424
- - name: Create bot token
425
- id: app-token
426
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
427
- with:
428
- client-id: ${{ secrets.BOT_APP_ID }}
429
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
430
- - name: Dispatch trusted triage run
431
- env:
432
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
433
- REPO: ${{ github.repository }}
434
- REF: ${{ github.event.repository.default_branch }}
435
- ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
436
- MODE: ${{ needs.classify.outputs.triage-mode }}
437
- run: |
438
- set -euo pipefail
439
- gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
440
- -f operation=triage \
441
- -f issue-number="$ISSUE_NUMBER" \
442
- -f mode="$MODE"
443
-
444
- call-triage:
445
- needs: [classify, authorize]
446
- # Triage on issue open is for outside collaborators only (handled by dispatch-triage).
447
- # A trusted actor who opens an issue should not start triage — they can add refine/implement/direct
448
- # labels or manually dispatch triage. All other triage routes (labeled, comment, dispatch) are trusted.
449
- if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true' && !(github.event_name == 'issues' && github.event.action == 'opened')
450
- uses: ./.github/workflows/agent-triage.lock.yml
451
- concurrency:
452
- group: triage-${{ needs.classify.outputs.issue-number }}
453
- cancel-in-progress: false
454
- permissions: write-all
455
- with:
456
- issue-number: ${{ needs.classify.outputs.issue-number }}
457
- mode: ${{ needs.classify.outputs.triage-mode }}
458
- secrets:
459
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
460
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
461
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
462
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
463
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
464
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
465
-
466
- call-apply-review:
467
- needs: [classify, authorize]
468
- if: needs.classify.outputs.route == 'apply-review' && needs.authorize.outputs.trusted == 'true'
469
- uses: ./.github/workflows/agent-apply-review.lock.yml
470
- concurrency:
471
- group: pr-feedback-${{ needs.classify.outputs.pr-number }}
472
- cancel-in-progress: false
473
- permissions: write-all
474
- with:
475
- pr-number: ${{ needs.classify.outputs.pr-number }}
476
- linked-issue: ${{ needs.classify.outputs.linked-issue }}
477
- secrets:
478
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
479
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
480
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
481
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
482
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
483
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
484
-
485
- # The merge belt is one lock for the whole repository, not one group per pull request.
486
- # A batch of six overnight pull requests means every merge moves the default branch
487
- # under the other five; gates running at once would each rebase onto a base another
488
- # gate is about to invalidate. Serialising the belt means each gate sees a main that
489
- # only changes between gates, every merge re-arms the siblings through
490
- # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
491
- call-merge-gate:
492
- needs: classify
493
- if: needs.classify.outputs.route == 'merge-gate'
494
- uses: ./.github/workflows/agent-merge-gate.lock.yml
495
- concurrency:
496
- group: merge-belt
497
- cancel-in-progress: false
498
- permissions: write-all
499
- with:
500
- pr-number: ${{ needs.classify.outputs.pr-number }}
501
- linked-issue: ${{ needs.classify.outputs.linked-issue }}
502
- ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
503
- ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
504
- attempts_so_far: ${{ needs.classify.outputs.merge-gate-attempts }}
505
- secrets:
506
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
507
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
508
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
509
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
510
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
511
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
512
-
513
- call-audit:
514
- needs: classify
515
- if: needs.classify.outputs.route == 'audit'
516
- uses: ./.github/workflows/agent-audit.lock.yml
517
- concurrency:
518
- group: audit
519
- cancel-in-progress: false
520
- permissions: write-all
521
- with:
522
- trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
523
- secrets:
524
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
525
- CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
526
- COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
527
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
528
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
529
- AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
530
-
531
- call-release:
532
- needs: [classify, authorize]
533
- if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
534
- uses: ./.github/workflows/agent-release.lock.yml
535
- permissions: write-all
536
- with:
537
- version-bump: ${{ github.event.inputs.version-bump || 'auto' }}
538
- secrets:
539
- OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
540
-
541
- bot-approve:
542
- needs: classify
543
- if: >
544
- needs.classify.outputs.route == 'bot-approve' &&
545
- (github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
546
- runs-on: ubuntu-latest
547
- timeout-minutes: 5
548
- permissions:
549
- actions: write
550
- steps:
551
- - name: Approve pending workflow runs
552
- env:
553
- GH_TOKEN: ${{ github.token }}
554
- REPO: ${{ github.repository }}
555
- BRANCH: ${{ github.head_ref }}
556
- run: |
557
- set -euo pipefail
558
-
559
- mapfile -t run_ids < <(
560
- gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
561
- --jq '.workflow_runs[].id'
562
- )
563
-
564
- if [ "${#run_ids[@]}" -eq 0 ]; then
565
- echo "No runs awaiting approval on branch $BRANCH"
566
- exit 0
567
- fi
568
-
569
- for run_id in "${run_ids[@]}"; do
570
- echo "Approving run $run_id"
571
- gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
572
- done
573
-
574
- # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
575
- # mergeability API, which the pure classifier cannot read, so this is an event-gated
576
- # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
577
- # the same pull_request_target event). It NEVER checks out or runs pull request code — it
578
- # reads mergeability through the API and dispatches the Merge Gate — so it is safe on
579
- # pull_request_target. Two entry points:
580
- # opened / reopened / synchronize → check the pull request that changed. Gated to this
581
- # repository's own branches so a fork PR never starts this App-token job.
582
- # closed & merged → the target branch moved, so re-check every open bot
583
- # pull request that may have just become conflicted (the "main changed after another PR
584
- # merged" case). A merge into the default branch is an inherently trusted internal event.
585
- # It dispatches only for bot-authored, open, non-draft pull requests whose head is this
586
- # repository AND whose CI has already concluded (dispatching while CI is still pending would
587
- # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
588
- # covered by the CI-completion route instead). The gate's subject job re-validates ownership
589
- # before any agent runs.
590
- detect-pr-conflicts:
591
- needs: classify
592
- if: >
593
- github.event_name == 'pull_request_target' &&
594
- (
595
- (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
596
- (
597
- (github.event.action == 'opened' ||
598
- github.event.action == 'reopened' ||
599
- github.event.action == 'synchronize') &&
600
- github.event.pull_request.head.repo.full_name == github.repository
601
- )
602
- )
603
- runs-on: ubuntu-latest
604
- timeout-minutes: 10
605
- permissions:
606
- contents: read
607
- pull-requests: read
608
- actions: write
609
- steps:
610
- - name: Checkout for App token action
611
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
612
- with:
613
- persist-credentials: false
614
- - name: Generate App token
615
- id: app-token
616
- uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
617
- with:
618
- app-id: ${{ secrets.BOT_APP_ID }}
619
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
620
- - name: Dispatch Merge Gate for conflicting bot pull requests
621
- env:
622
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
623
- REPO: ${{ github.repository }}
624
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
625
- ACTION: ${{ github.event.action }}
626
- EVENT_PR: ${{ github.event.pull_request.number }}
627
- run: |
628
- set -euo pipefail
629
-
630
- # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft,
631
- # bot-authored pull request on this repository whose CI has concluded — the
632
- # conflict case is one arm of that, because a conflicting PR never gets a fresh
633
- # CI run of its own; a merge conflict with red or concluded CI is the same
634
- # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
635
- # to pick up when its checks finish (bot-approve / reconcile).
636
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
637
- # opened recently answers UNKNOWN through the API and null through REST, while it works
638
- # the answer out in the background. Reading once therefore reports "not conflicting"
639
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
640
- # settles.
641
- mergeable_state() {
642
- local pr="$1" state=UNKNOWN
643
- for _ in 1 2 3 4 5 6; do
644
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
645
- [ "$state" = "UNKNOWN" ] || break
646
- sleep 5
647
- done
648
- echo "$state"
649
- }
650
-
651
- # A gate already queued or running for this pull request will read the same CI verdict;
652
- # a second dispatch queues behind merge-belt and the single-slot queue cancels it. Same
653
- # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
654
- # title run-name gives a gate dispatch.
655
- gate_is_live() {
656
- local pr="$1" status live
657
- for status in queued in_progress waiting pending; do
658
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
659
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
660
- [ "${live:-0}" -eq 0 ] || return 0
661
- done
662
- return 1
663
- }
664
-
665
- maybe_dispatch() {
666
- pr="$1"
667
- [ -n "$pr" ] || return 0
668
-
669
- meta=$(gh api "repos/$REPO/pulls/$pr" \
670
- --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
671
- 2>/dev/null || echo '')
672
- [ -n "$meta" ] || return 0
673
- if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
674
- || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
675
- || [ "$(jq -r '.bot' <<<"$meta")" != "Bot" ] \
676
- || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
677
- return 0
678
- fi
679
-
680
- # Only dispatch when CI has already concluded. A conflict while CI is still
681
- # running is handled by the CI-completion route; dispatching now would make the
682
- # gate read a 'pending' conclusion and prematurely ask for human review.
683
- head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
684
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
685
- --jq '[.workflow_runs[]
686
- | select(.name == "App: CI" and .status == "completed"
687
- and (.conclusion == "success" or .conclusion == "failure"))]
688
- | sort_by(.created_at) | last // empty')
689
- if [ -z "$ci" ]; then
690
- # A conflicting pull request has no merge ref for GitHub to build, so a
691
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
692
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
693
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
694
- # back to the branch's last verdict so the gate runs; it stays on the remediation
695
- # path and cannot merge on a stale conclusion.
696
- head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
697
- mergeable_now=$(mergeable_state "$pr")
698
- if [ "$mergeable_now" != "CONFLICTING" ]; then
699
- echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
700
- return 0
701
- fi
702
- ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
703
- --jq '[.workflow_runs[]
704
- | select(.name == "App: CI" and .status == "completed"
705
- and (.conclusion == "success" or .conclusion == "failure"))]
706
- | sort_by(.created_at) | last // empty')
707
- if [ -z "$ci" ]; then
708
- echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
709
- return 0
710
- fi
711
- echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
712
- fi
713
- ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
714
- ci_finished=$(jq -r '.updated_at' <<<"$ci")
715
- ci_run_id=$(jq -r '.id' <<<"$ci")
716
-
717
- # GitHub computes mergeability asynchronously; poll until it is known (not null).
718
- mergeable=""
719
- for _ in 1 2 3 4 5 6; do
720
- mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
721
- [ "$mergeable" = "null" ] || break
722
- sleep 5
723
- done
724
-
725
- if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
726
- # Mergeable and green: the reconcile belt and the PR's own checks finish it.
727
- return 0
728
- fi
729
-
730
- # A terminal verdict newer than this CI run means a gate already decided;
731
- # failed attempts (the attempt marker) deliberately do not count.
732
- verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
733
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
734
- linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
735
- if [ -n "$linked_issue" ]; then
736
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
737
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
738
- if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
739
- verdict="$issue_verdict"
740
- fi
741
- fi
742
- if [ -n "$verdict" ] && [ "$verdict" \> "$ci_finished" ]; then
743
- echo "PR #$pr already has a gate verdict newer than CI; skipping."
744
- return 0
745
- fi
746
-
747
- attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
748
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
749
- if [ -n "$linked_issue" ]; then
750
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
751
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
752
- attempts=$((attempts + issue_attempts))
753
- fi
754
- if [ "$attempts" -ge 6 ]; then
755
- echo "PR #$pr already consumed $attempts gate attempts on this CI verdict, parking for review."
756
- return 0
757
- fi
758
- if gate_is_live "$pr"; then
759
- echo "PR #$pr: a merge-gate run is already live; not dispatching another."
760
- return 0
761
- fi
762
-
763
- echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of 6)."
764
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
765
- --method POST \
766
- -f ref="$DEFAULT_BRANCH" \
767
- -f "inputs[operation]=merge-gate" \
768
- -f "inputs[pr-number]=$pr" \
769
- -f "inputs[ci-conclusion]=$ci_conclusion" \
770
- -f "inputs[ci-run-id]=$ci_run_id" \
771
- -f "inputs[attempts_so_far]=$attempts"
772
- }
773
-
774
- if [ "$ACTION" = "closed" ]; then
775
- # The target branch just moved. Any previously-mergeable bot PR may now conflict.
776
- # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
777
- gh api "repos/$REPO/pulls?state=open&per_page=100" \
778
- --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
779
- while IFS= read -r pr; do
780
- maybe_dispatch "$pr"
781
- done
782
- else
783
- maybe_dispatch "$EVENT_PR"
784
- fi
785
-
786
- reconcile-bot-pr-runs:
787
- needs: classify
788
- if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
789
- runs-on: ubuntu-latest
790
- timeout-minutes: 5
791
- permissions:
792
- contents: read
793
- actions: write
794
- pull-requests: read
795
- # the stale reservation sweep clears bot-working from parked issues
796
- issues: write
797
- env:
798
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
799
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
800
- steps:
801
- - name: Checkout for App token action
802
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
803
- with:
804
- persist-credentials: false
805
- - name: Generate App token
806
- id: app-token
807
- uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
808
- with:
809
- app-id: ${{ secrets.BOT_APP_ID }}
810
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
811
- # Runs only the default-branch workflow and never checks out pull-request code.
812
- # External users cannot impersonate github-actions[bot].
813
- - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
814
- env:
815
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
816
- REPO: ${{ github.repository }}
817
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
818
- run: |
819
- set -euo pipefail
820
-
821
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
822
- # opened recently answers UNKNOWN through the API and null through REST, while it works
823
- # the answer out in the background. Reading once therefore reports "not conflicting"
824
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
825
- # settles.
826
- mergeable_state() {
827
- local pr="$1" state=UNKNOWN
828
- for _ in 1 2 3 4 5 6; do
829
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
830
- [ "$state" = "UNKNOWN" ] || break
831
- sleep 5
832
- done
833
- echo "$state"
834
- }
835
-
836
- # A gate already queued or running for this pull request will read the same CI verdict;
837
- # a second dispatch queues behind merge-belt and the single-slot queue cancels it.
838
- gate_is_live() {
839
- local pr="$1" status live
840
- for status in queued in_progress waiting pending; do
841
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
842
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
843
- [ "${live:-0}" -eq 0 ] || return 0
844
- done
845
- return 1
846
- }
847
-
848
- # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
849
- # because authorize-bot-work is keyed per issue, while implement-global lets exactly
850
- # one run at a time. A queued run that is cancelled therefore leaves its issue
851
- # carrying bot-working with no run and no pull request: parked, invisible, and not
852
- # re-triggerable, because classify refuses an issue that is already reserved.
853
- gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
854
- --json number --jq '.[].number' |
855
- while IFS= read -r issue; do
856
- [ -n "$issue" ] || continue
857
-
858
- live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
859
- --jq "[.workflow_runs[]
860
- | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
861
- | select(.display_title | test(\"#${issue}\\b\"))] | length")
862
- [ "${live:-0}" -eq 0 ] || continue
863
-
864
- has_pr=$(gh pr list --repo "$REPO" --state open --json body \
865
- --jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${issue}\\b|fix(es|ed)? #${issue}\\b|resolves? #${issue}\\b\"))] | length")
866
- [ "${has_pr:-0}" -eq 0 ] || continue
867
-
868
- echo "Clearing a stale bot-working reservation on #$issue: no run, no pull request."
869
- gh issue edit "$issue" --repo "$REPO" --remove-label bot-working
870
- done
871
-
872
- gh api "repos/$REPO/pulls?state=open&per_page=100" |
873
- jq -r --arg repo "$REPO" '
874
- .[]
875
- | select(
876
- (.user.login == "github-actions[bot]" or .user.login == "app/github-actions" or .user.login == "platform-devbox[bot]")
877
- and .draft == false
878
- and .head.repo.full_name == $repo
879
- )
880
- | [.number, .head.ref, .head.sha] | @tsv
881
- ' |
882
- while IFS=$'\t' read -r pr_number branch head_sha; do
883
- gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
884
- --jq '.workflow_runs[]
885
- | select(
886
- .event == "pull_request"
887
- and .name == "App: CI"
888
- )
889
- | .id' |
890
- while IFS= read -r run_id; do
891
- echo "Approving trusted bot run $run_id"
892
- gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
893
- done
894
-
895
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
896
- --jq '[.workflow_runs[]
897
- | select(
898
- .name == "App: CI"
899
- and .event == "pull_request"
900
- and .status == "completed"
901
- and (.conclusion == "success" or .conclusion == "failure")
902
- )
903
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
904
- if [ -z "$ci" ]; then
905
- # A conflicting pull request has no merge ref for GitHub to build, so a
906
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
907
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
908
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
909
- # back to the branch's last verdict so the gate runs; it stays on the remediation
910
- # path and cannot merge on a stale conclusion.
911
- mergeable_now=$(mergeable_state "$pr_number")
912
- if [ "$mergeable_now" = "CONFLICTING" ]; then
913
- ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
914
- --jq '[.workflow_runs[]
915
- | select(
916
- .name == "App: CI"
917
- and .event == "pull_request"
918
- and .status == "completed"
919
- and (.conclusion == "success" or .conclusion == "failure")
920
- )
921
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
922
- fi
923
- if [ -z "$ci" ]; then
924
- echo "::warning::PR #$pr_number head $head_sha has no completed App: CI run; the belt cannot gate it until CI runs."
925
- continue
926
- fi
927
- echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
928
- fi
929
-
930
- IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
931
-
932
- # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
933
- # line; progress comments and failed attempts deliberately carry neither
934
- # matching pair, so a crashed or OOM-killed gate never masks a PR from
935
- # later sweeps. A recorded verdict only counts when it is NEWER than the
936
- # latest CI run; the old existence check made the gate one-shot per PR.
937
- # Attempts are failed gates (the attempt marker) since this CI verdict.
938
- latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
939
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
940
- linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
941
- if [ -n "$linked_issue" ]; then
942
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
943
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
944
- if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
945
- latest_verdict="$issue_verdict"
946
- fi
947
- fi
948
- if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$ci_finished" ]; then
949
- echo "Merge gate verdict ($latest_verdict) is newer than CI ($ci_finished) for PR #$pr_number, skipping"
950
- continue
951
- fi
952
-
953
- attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
954
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
955
- if [ -n "$linked_issue" ]; then
956
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
957
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
958
- attempts=$((attempts + issue_attempts))
959
- fi
960
- if [ "$attempts" -ge 6 ]; then
961
- echo "PR #$pr_number already consumed $attempts gate attempts on this CI verdict, parking for review"
962
- continue
963
- fi
964
- if gate_is_live "$pr_number"; then
965
- echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
966
- continue
967
- fi
968
-
969
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of 6)"
970
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
971
- --method POST \
972
- -f ref="$DEFAULT_BRANCH" \
973
- -f "inputs[operation]=merge-gate" \
974
- -f "inputs[pr-number]=$pr_number" \
975
- -f "inputs[ci-conclusion]=$ci_conclusion" \
976
- -f "inputs[ci-run-id]=$ci_run_id" \
977
- -f "inputs[attempts_so_far]=$attempts"
978
- done
979
-
980
- audit-close:
981
- needs: classify
982
- if: needs.classify.outputs.route == 'audit-close'
983
- runs-on: ubuntu-latest
984
- timeout-minutes: 15
985
- concurrency:
986
- group: audit-close
987
- cancel-in-progress: false
988
- permissions:
989
- contents: read
990
- issues: write
991
- # It reads the open pull requests before closing a report, so a report whose
992
- # implement workers are still in review is left alone. Without this the list
993
- # call answers 403 and the nightly job fails having closed nothing.
994
- pull-requests: read
995
- steps:
996
- - name: Checkout workflow actions
997
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
998
- with:
999
- persist-credentials: false
1000
- - uses: ./.github/actions/audit-close
1001
- with:
1002
- token: ${{ github.token }}
1003
-
1004
- cleanup-artifacts:
1005
- needs: classify
1006
- if: needs.classify.outputs.route == 'cleanup-artifacts' && !github.event.repository.fork
1007
- runs-on: ubuntu-latest
1008
- timeout-minutes: 15
1009
- concurrency:
1010
- group: cleanup-artifacts
1011
- cancel-in-progress: false
1012
- permissions:
1013
- contents: read
1014
- actions: write
1015
- steps:
1016
- - name: Checkout workflow actions
1017
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1018
- with:
1019
- persist-credentials: false
1020
- - uses: ./.github/actions/cleanup-artifacts
1021
- with:
1022
- token: ${{ github.token }}
1023
- artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
1024
-
1025
- validate:
1026
- needs: classify
1027
- if: needs.classify.outputs.route == 'validate'
1028
- runs-on: ubuntu-latest
1029
- timeout-minutes: 10
1030
- permissions:
1031
- contents: read
1032
- steps:
1033
- - name: Checkout workflow actions
1034
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1035
- with:
1036
- persist-credentials: false
1037
- - uses: ./.github/actions/verify-route-matrix
1038
- - uses: ./.github/actions/verify-composite-actions
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/work-router.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: "All Work Router"
3
+
4
+ # Owns every repository automation trigger. Classifies each event into exactly one route,
5
+ # then calls at most one worker or runs one deterministic job. No model runs here.
6
+
7
+ # GitHub titles an issue-triggered run with the issue title, so every run on one issue reads
8
+ # the same. Name it after the event instead. This is evaluated before any job, so it can
9
+ # describe the trigger but not the route the classifier picks.
10
+ run-name: >-
11
+ ${{ github.event_name == 'issues'
12
+ && github.event.action == 'opened'
13
+ && format('Issue opened: {0} (#{1}) by {2}', github.event.issue.title, github.event.issue.number, github.actor)
14
+ || github.event_name == 'issues'
15
+ && github.event.label.name == 'bot-working'
16
+ && contains(github.event.issue.labels.*.name, 'implement')
17
+ && format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
18
+ || github.event_name == 'issues'
19
+ && github.event.label.name == 'bot-working'
20
+ && contains(github.event.issue.labels.*.name, 'refine')
21
+ && format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
22
+ || github.event_name == 'issues'
23
+ && github.event.label.name == 'bot-working'
24
+ && contains(github.event.issue.labels.*.name, 'direct')
25
+ && format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
26
+ || github.event_name == 'issues'
27
+ && github.event.label.name == 'bot-working'
28
+ && contains(github.event.issue.labels.*.name, 'triage')
29
+ && format('Working (Triage): {0} (#{1})', github.event.issue.title, github.event.issue.number)
30
+ || github.event_name == 'issues'
31
+ && github.event.label.name == 'bot-working'
32
+ && format('Working: {0} (#{1})', github.event.issue.title, github.event.issue.number)
33
+ || github.event_name == 'issues'
34
+ && github.event.label.name == 'implement'
35
+ && endsWith(github.actor, '[bot]')
36
+ && format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
37
+ || github.event_name == 'issues'
38
+ && github.event.label.name == 'refine'
39
+ && endsWith(github.actor, '[bot]')
40
+ && format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
41
+ || github.event_name == 'issues'
42
+ && github.event.label.name == 'direct'
43
+ && endsWith(github.actor, '[bot]')
44
+ && format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
45
+ || github.event_name == 'issues'
46
+ && format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
47
+ || github.event_name == 'issue_comment'
48
+ && format('comment on #{0} by {1}', github.event.issue.number, github.actor)
49
+ || github.event_name == 'pull_request_review'
50
+ && format('review on PR #{0} by {1}', github.event.pull_request.number, github.actor)
51
+ || github.event_name == 'pull_request_review_comment'
52
+ && format('review comment on PR #{0} by {1}', github.event.pull_request.number, github.actor)
53
+ || github.event_name == 'pull_request_target'
54
+ && format('PR #{0} {1} by {2}', github.event.pull_request.number, github.event.action, github.actor)
55
+ || github.event_name == 'workflow_run'
56
+ && format('CI {0} on {1}', github.event.workflow_run.conclusion, github.event.workflow_run.head_branch)
57
+ || github.event_name == 'workflow_dispatch'
58
+ && inputs.operation == 'triage'
59
+ && format('Working (Triage): #{0}', inputs.issue-number)
60
+ || github.event_name == 'workflow_dispatch'
61
+ && inputs.operation == 'refine'
62
+ && format('Working (Refine): #{0}', inputs.issue-number)
63
+ || github.event_name == 'workflow_dispatch'
64
+ && inputs.operation == 'implement'
65
+ && format('Working (Implement): #{0}', inputs.issue-number)
66
+ || github.event_name == 'workflow_dispatch'
67
+ && inputs.operation == 'merge-gate'
68
+ && format('Working (Merge Gate): PR #{0}', inputs.pr-number)
69
+ || github.event_name == 'workflow_dispatch'
70
+ && inputs.operation == 'batch'
71
+ && format('Working (Batch): #{0}', inputs.issue-number)
72
+ || github.event_name == 'workflow_dispatch'
73
+ && format('dispatch: {0}', inputs.operation)
74
+ || github.event_name == 'schedule'
75
+ && format('schedule {0}', github.event.schedule)
76
+ || github.event_name }}
77
+
78
+ # The two values a repository is expected to change, and the only part of this file that is
79
+ # not the package's. `workflows update` keeps whatever is here and puts the package's router
80
+ # around it, so change them here and nowhere else.
81
+ #
82
+ # Neither can be read where it is also needed: GitHub evaluates no expression in a
83
+ # `workflow_run.workflows:` list or in a `cron:`. The installer therefore mirrors both values
84
+ # into those two literal lines below, and the route matrix asserts the copies still agree.
85
+ # Everything that runs inside a job reads them from here, including the classifier, which
86
+ # takes AUDIT_CRON from this environment.
87
+ env:
88
+ # The CI workflow the merge belt reads its verdict from. A repository whose CI has another
89
+ # name gets no gate at all: the belt logs "no completed CI run" and the pull request waits
90
+ # forever, which is a silent failure rather than a red run.
91
+ CI_WORKFLOW_NAME: "App: CI"
92
+ # Slot 0. Every consumer installs this same router, and the agent fleet is one shared,
93
+ # two-VM scale set, so a second repository left on slot 0 audits at the same minute as the
94
+ # first and the two of them consume the whole fleet. Pick a free slot per repository: see
95
+ # "Scheduling across repositories" in the package README.
96
+ AUDIT_CRON: "17 1 * * 1"
97
+ # The bot identities whose label changes and pull requests this router trusts. Space
98
+ # separated, and every login the same account can present: GitHub reports an App as
99
+ # `name[bot]` on some endpoints and `app/name` on others, so both spellings belong here.
100
+ # Adding a third bot used to mean finding four hand-written comparisons across this file.
101
+ TRUSTED_BOTS: "github-actions[bot] app/github-actions platform-devbox[bot]"
102
+ # How long an issue may sit parked with `stalled` before the janitor retries it, and how many
103
+ # times it tries before leaving the issue to a person. Only machine failures are retried; a
104
+ # verdict the agent reached on purpose is reported and never re-run, because re-running a
105
+ # decision reproduces it.
106
+ HOUSEKEEPING_RETRY_AFTER_HOURS: "6"
107
+ HOUSEKEEPING_MAX_RETRIES: "3"
108
+ # A bot pull request older than this with no merge-gate verdict at all is reported, never
109
+ # closed: the janitor deletes branches and labels, never someone's work.
110
+ HOUSEKEEPING_STALE_PR_DAYS: "3"
111
+ # The single issue that lists what genuinely needs a person. Set it empty to turn the digest
112
+ # off and keep the rest of the janitor.
113
+ HOUSEKEEPING_DIGEST_TITLE: "Needs a human"
114
+ # How long an audit report may sit open before it is labelled `stale-audit`. The audit's own
115
+ # backpressure ignores a stale report, so three abandoned reports can no longer disable the
116
+ # weekly audit for good; the digest lists them instead. Two missed weekly cycles.
117
+ AUDIT_STALE_AFTER_DAYS: "14"
118
+ # How many failed merge-gate attempts one pull request head may consume before the belt
119
+ # parks it for a person. It was written as the literal 6 in four places here while the
120
+ # merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
121
+ # agree, because a belt that gives up later than the worker says burns whole runs.
122
+ MAX_GATE_ATTEMPTS: "6"
123
+
124
+ on:
125
+ issues:
126
+ types: [opened, labeled]
127
+ issue_comment:
128
+ types: [created]
129
+ pull_request_review_comment:
130
+ types: [created]
131
+ pull_request_review:
132
+ types: [submitted]
133
+
134
+ pull_request_target:
135
+ # opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
136
+ # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
137
+ # moved and previously-mergeable sibling bot PRs may now conflict.
138
+ types: [opened, synchronize, reopened, labeled, closed]
139
+
140
+ # CI completion on a pull request. The classifier admits only conclusion == failure with an
141
+ # attached pull request; a green run reaches the gate through the consumer CI's
142
+ # dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
143
+ # the gate on every passing PR.
144
+ #
145
+ # Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
146
+ # whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
147
+ # follow have the bot as their actor, and no workflow_run arrives for any of them (four
148
+ # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
149
+ # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
150
+ # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
151
+ # branch with secrets, but the gate's subject job rejects any PR that is not open and
152
+ # bot-authored, so a fork PR's failing CI cannot start privileged work.
153
+ workflow_run:
154
+ # Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
155
+ workflows: ["App: CI"]
156
+ types: [completed]
157
+
158
+ # Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
159
+ schedule:
160
+ - cron: "17 1 * * 1" # audit slot, mirrored from env.AUDIT_CRON by the installer
161
+ - cron: "43 3 * * *"
162
+ - cron: "0 6 * * *"
163
+ - cron: "17 * * * *"
164
+ - cron: "23 */6 * * *" # housekeeping: retry what stalled, tidy what it stranded
165
+
166
+ workflow_dispatch:
167
+ inputs:
168
+ operation:
169
+ description: "Operation to run"
170
+ required: true
171
+ type: choice
172
+ options:
173
+ - refine
174
+ - implement
175
+ - triage
176
+ - apply-review
177
+ - merge-gate
178
+ - audit
179
+ - audit-close
180
+ - cleanup-artifacts
181
+ - reconcile-bot-pr-runs
182
+ - housekeeping
183
+ - validate
184
+ - release
185
+ issue-number:
186
+ description: "Issue number (required for refine / implement / direct)"
187
+ required: false
188
+ type: string
189
+ pr-number:
190
+ description: "Pull request number (required for apply-review / merge-gate)"
191
+ required: false
192
+ type: string
193
+ mode:
194
+ description: "Refine mode: first or rerefine"
195
+ required: false
196
+ type: choice
197
+ options:
198
+ - first
199
+ - rerefine
200
+ ci-conclusion:
201
+ description: "CI conclusion for merge-gate dispatch"
202
+ required: false
203
+ type: choice
204
+ options:
205
+ - success
206
+ - failure
207
+ - action_required
208
+ ci-run-id:
209
+ description: "CI workflow run ID for merge-gate dispatch"
210
+ required: false
211
+ type: string
212
+ attempts_so_far:
213
+ description: "Failed merge-gate attempts already made against this CI verdict (filled by the router, not by humans)"
214
+ required: false
215
+ type: string
216
+ default: '0'
217
+ trigger-kind:
218
+ description: "Audit trigger: scheduled or manual"
219
+ required: false
220
+ type: choice
221
+ options:
222
+ - manual
223
+ - scheduled
224
+ dry-run:
225
+ description: "Housekeeping only: print every intended change and write nothing"
226
+ required: false
227
+ type: boolean
228
+ default: false
229
+ version-bump:
230
+ description: "Release version bump: patch, minor, major, or auto"
231
+ required: false
232
+ type: choice
233
+ options:
234
+ - auto
235
+ - patch
236
+ - minor
237
+ - major
238
+
239
+ permissions:
240
+ contents: read
241
+
242
+ jobs:
243
+ # Public repositories must check repository permission before an event can start a model.
244
+ authorize:
245
+ runs-on: ubuntu-latest
246
+ timeout-minutes: 5
247
+ permissions:
248
+ contents: read
249
+ outputs:
250
+ trusted: ${{ steps.check.outputs.trusted }}
251
+ is_outside_collaborator: ${{ steps.check.outputs.is_outside_collaborator }}
252
+ steps:
253
+ - name: Check the actor can start an agent
254
+ id: check
255
+ env:
256
+ GH_TOKEN: ${{ github.token }}
257
+ ACTOR: ${{ github.actor }}
258
+ EVENT: ${{ github.event_name }}
259
+ # Who GitHub says this person is relative to the repository, straight from the
260
+ # payload. OWNER and MEMBER are the organisation's own; COLLABORATOR is repository
261
+ # access without organisation membership, which is what triage is for. Read from
262
+ # here because a repository-scoped GITHUB_TOKEN cannot query org membership.
263
+ AUTHOR_ASSOCIATION: ${{ github.event.issue.author_association || github.event.comment.author_association || github.event.pull_request.author_association }}
264
+ run: |
265
+ set -euo pipefail
266
+
267
+ # Events with no human actor. `schedule` and `workflow_run` are the fleet triggering
268
+ # itself; `workflow_dispatch` is already restricted by GitHub to write and above.
269
+ case "$EVENT" in
270
+ schedule | workflow_run | workflow_dispatch)
271
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
272
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
273
+ echo "::notice::$EVENT has no untrusted actor."
274
+ exit 0
275
+ ;;
276
+ esac
277
+
278
+ # Platform bot owns Safe Outputs. Its label changes must be able to
279
+ # start the follow-on worker, even though GitHub does not report it
280
+ # as a repository collaborator.
281
+ for bot in $TRUSTED_BOTS; do
282
+ if [ "$ACTOR" = "$bot" ]; then
283
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
284
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
285
+ exit 0
286
+ fi
287
+ done
288
+
289
+ permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
290
+ --jq '.permission' 2>/dev/null || echo "none")
291
+
292
+ # Is this person one of the organisation's own, or somebody with repository access
293
+ # from outside it? Triage exists for the second kind.
294
+ #
295
+ # The payload answers it. The org API cannot: `orgs/{org}/members/{user}` needs an
296
+ # org-scoped token and GITHUB_TOKEN is scoped to this repository, so that call failed
297
+ # for everybody and every admin who opened an issue was declared an outside
298
+ # collaborator and triaged. It is kept only as a fallback for events that carry no
299
+ # association, and it can no longer decide "outside" on its own.
300
+ org=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
301
+ case "${AUTHOR_ASSOCIATION:-}" in
302
+ OWNER | MEMBER)
303
+ is_member=true
304
+ ;;
305
+ COLLABORATOR)
306
+ is_member=false
307
+ ;;
308
+ *)
309
+ # No association in this payload. Ask the org, and believe a yes rather than a
310
+ # no: a failed call here is indistinguishable from a real non-member.
311
+ if gh api "orgs/$org/members/$ACTOR" >/dev/null 2>&1; then
312
+ is_member=true
313
+ else
314
+ is_member=unknown
315
+ fi
316
+ ;;
317
+ esac
318
+
319
+ case "$permission" in
320
+ admin | maintain | write)
321
+ # "unknown" counts as inside. Somebody with write or better whose membership
322
+ # cannot be established is far more likely to be a colleague than an outside
323
+ # collaborator, and treating them as outside starts a triage agent nobody asked
324
+ # for — which, with a four-hour timeout, holds a runner for the afternoon.
325
+ if [ "$is_member" != "false" ]; then
326
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
327
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
328
+ echo "::notice::$ACTOR has $permission permission and is an org member."
329
+ else
330
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
331
+ echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
332
+ echo "::notice::$ACTOR has $permission permission but is NOT an org member (outside collaborator). Triage route eligible."
333
+ fi
334
+ ;;
335
+ read)
336
+ echo "trusted=false" >> "$GITHUB_OUTPUT"
337
+ echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
338
+ echo "::notice::$ACTOR is an outside collaborator (read permission). Triage route eligible."
339
+ ;;
340
+ *)
341
+ echo "trusted=false" >> "$GITHUB_OUTPUT"
342
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
343
+ echo "::notice::$ACTOR has '$permission' permission on this repository. No agent will run."
344
+ ;;
345
+ esac
346
+
347
+ classify:
348
+ runs-on: ubuntu-latest
349
+ timeout-minutes: 5
350
+ permissions:
351
+ contents: read
352
+ pull-requests: read
353
+ outputs:
354
+ route: ${{ steps.route.outputs.route }}
355
+ issue-number: ${{ steps.route.outputs.issue-number }}
356
+ pr-number: ${{ steps.route.outputs.pr-number }}
357
+ linked-issue: ${{ steps.route.outputs.linked-issue }}
358
+ ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
359
+ ci-run-id: ${{ steps.route.outputs.ci-run-id }}
360
+ merge-gate-attempts: ${{ steps.route.outputs.merge-gate-attempts }}
361
+ implement-attempts: ${{ steps.route.outputs.implement-attempts }}
362
+ refine-mode: ${{ steps.route.outputs.refine-mode }}
363
+ triage-mode: ${{ steps.route.outputs.triage-mode }}
364
+ trigger-kind: ${{ steps.route.outputs.trigger-kind }}
365
+ steps:
366
+ - name: Checkout workflow actions
367
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
368
+ with:
369
+ persist-credentials: false
370
+ - name: Classify event into a single route
371
+ id: route
372
+ uses: ./.github/actions/classify-route
373
+ with:
374
+ token: ${{ github.token }}
375
+
376
+ call-refine:
377
+ needs: [classify, authorize]
378
+ if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
379
+ uses: ./.github/workflows/agent-refine.lock.yml
380
+ concurrency:
381
+ group: refine-${{ needs.classify.outputs.issue-number }}
382
+ cancel-in-progress: false
383
+ # write-all, not a narrower map: every worker's agent job declares `permissions: read-all`,
384
+ # which requests read on every scope. A caller granting less cannot satisfy it, and GitHub
385
+ # rejects the call with a startup failure before any job is created. The real boundary is the
386
+ # worker's own read-all plus safe-outputs, not this grant.
387
+ permissions: write-all
388
+ with:
389
+ issue-number: ${{ needs.classify.outputs.issue-number }}
390
+ mode: ${{ needs.classify.outputs.refine-mode }}
391
+ secrets:
392
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
393
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
394
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
395
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
396
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
397
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
398
+
399
+ # Checks whether an open bot PR already exists for the issue. If it does, implement is
400
+ # skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
401
+ # PRs when retries are triggered after a merge-gate failure.
402
+ check-implement-pr:
403
+ needs: [classify, authorize]
404
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
405
+ runs-on: ubuntu-latest
406
+ timeout-minutes: 3
407
+ permissions:
408
+ contents: read
409
+ pull-requests: read
410
+ outputs:
411
+ has-open-pr: ${{ steps.check.outputs.has-open-pr }}
412
+ steps:
413
+ - name: Check for existing open bot PR
414
+ id: check
415
+ env:
416
+ GH_TOKEN: ${{ github.token }}
417
+ REPO: ${{ github.repository }}
418
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
419
+ run: |
420
+ set -euo pipefail
421
+ # A refine-to-implement transition emits two label events (the implement label,
422
+ # then the worker re-adding bot-working) and each one classifies to implement.
423
+ # The older router run wins: a younger run that finds an older live implement
424
+ # route for the same issue skips instead of queueing a duplicate worker. Only
425
+ # bot-actor titles are counted, so a human's "waiting for bot-working" run can
426
+ # never block the real one.
427
+ for st in in_progress queued; do
428
+ dupes=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$st&per_page=100" --jq "[.workflow_runs[]
429
+ | select(.id < $GITHUB_RUN_ID)
430
+ | select(
431
+ ((.display_title | startswith(\"Working (Implement)\")) and (.display_title | contains(\"(#${ISSUE_NUMBER})\")))
432
+ or
433
+ ((.display_title | contains(\"implement label on #${ISSUE_NUMBER} by\")) and (.display_title | endswith(\"[bot]\")))
434
+ )
435
+ ] | length" 2>/dev/null || echo 0)
436
+ if [ "${dupes:-0}" -gt 0 ]; then
437
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
438
+ echo "::notice::An older router run is already implementing #$ISSUE_NUMBER; skipping the duplicate."
439
+ exit 0
440
+ fi
441
+ done
442
+
443
+ # Marker-first: implement stamps <!-- implement-pr: N --> on the issue when its
444
+ # pull request is created, so the duplicate check is an exact lookup. Only an
445
+ # OPEN marker PR blocks; a merged or closed one means a re-implement is legitimate.
446
+ marker_pr=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' \
447
+ | grep -oE '<!-- implement-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
448
+ if [ -n "$marker_pr" ]; then
449
+ state=$(gh pr view "$marker_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo GONE)
450
+ if [ "$state" = "OPEN" ]; then
451
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
452
+ echo "::notice::Issue #$ISSUE_NUMBER is marked as implemented by open PR #$marker_pr. Skipping duplicate implement."
453
+ exit 0
454
+ fi
455
+ echo "Marker PR #$marker_pr is $state; a fresh implement is allowed."
456
+ fi
457
+
458
+ existing=$(gh pr list --repo "$REPO" --state open --json number,author,body \
459
+ --jq "[.[]
460
+ | select(.author.login | startswith(\"app/\") or endswith(\"[bot]\"))
461
+ | (.body | ascii_downcase) as \$body
462
+ | select(\$body | contains(\"close #${ISSUE_NUMBER}\") or contains(\"closes #${ISSUE_NUMBER}\") or contains(\"closed #${ISSUE_NUMBER}\") or contains(\"fix #${ISSUE_NUMBER}\") or contains(\"fixes #${ISSUE_NUMBER}\") or contains(\"fixed #${ISSUE_NUMBER}\") or contains(\"resolve #${ISSUE_NUMBER}\") or contains(\"resolves #${ISSUE_NUMBER}\") or contains(\"resolved #${ISSUE_NUMBER}\"))
463
+ ] | length")
464
+ if [ "$existing" -gt 0 ]; then
465
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
466
+ echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
467
+ else
468
+ echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
469
+ fi
470
+
471
+ call-implement:
472
+ needs: [classify, authorize, check-implement-pr]
473
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
474
+ uses: ./.github/workflows/agent-implement.lock.yml
475
+ concurrency:
476
+ group: implement-global
477
+ cancel-in-progress: false
478
+ # FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
479
+ # was pending, so labelling six issues at once implements one and drops the rest.
480
+ queue: max
481
+ permissions: write-all
482
+ with:
483
+ issue-number: ${{ needs.classify.outputs.issue-number }}
484
+ attempts_so_far: ${{ needs.classify.outputs.implement-attempts }}
485
+ secrets:
486
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
487
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
488
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
489
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
490
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
491
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
492
+
493
+ dispatch-triage:
494
+ needs: [classify, authorize]
495
+ if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
496
+ runs-on: ubuntu-latest
497
+ timeout-minutes: 5
498
+ permissions:
499
+ actions: write
500
+ contents: read
501
+ steps:
502
+ - name: Create bot token
503
+ id: app-token
504
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
505
+ with:
506
+ client-id: ${{ secrets.BOT_APP_ID }}
507
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
508
+ - name: Dispatch trusted triage run
509
+ env:
510
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
511
+ REPO: ${{ github.repository }}
512
+ REF: ${{ github.event.repository.default_branch }}
513
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
514
+ MODE: ${{ needs.classify.outputs.triage-mode }}
515
+ run: |
516
+ set -euo pipefail
517
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
518
+ -f operation=triage \
519
+ -f issue-number="$ISSUE_NUMBER" \
520
+ -f mode="$MODE"
521
+
522
+ call-triage:
523
+ needs: [classify, authorize]
524
+ # Triage on issue open is for outside collaborators only (handled by dispatch-triage).
525
+ # A trusted actor who opens an issue should not start triage — they can add refine/implement/direct
526
+ # labels or manually dispatch triage. All other triage routes (labeled, comment, dispatch) are trusted.
527
+ if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true' && !(github.event_name == 'issues' && github.event.action == 'opened')
528
+ uses: ./.github/workflows/agent-triage.lock.yml
529
+ concurrency:
530
+ group: triage-${{ needs.classify.outputs.issue-number }}
531
+ cancel-in-progress: false
532
+ permissions: write-all
533
+ with:
534
+ issue-number: ${{ needs.classify.outputs.issue-number }}
535
+ mode: ${{ needs.classify.outputs.triage-mode }}
536
+ secrets:
537
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
538
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
539
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
540
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
541
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
542
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
543
+
544
+ call-apply-review:
545
+ needs: [classify, authorize]
546
+ if: needs.classify.outputs.route == 'apply-review' && needs.authorize.outputs.trusted == 'true'
547
+ uses: ./.github/workflows/agent-apply-review.lock.yml
548
+ concurrency:
549
+ group: pr-feedback-${{ needs.classify.outputs.pr-number }}
550
+ cancel-in-progress: false
551
+ permissions: write-all
552
+ with:
553
+ pr-number: ${{ needs.classify.outputs.pr-number }}
554
+ linked-issue: ${{ needs.classify.outputs.linked-issue }}
555
+ secrets:
556
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
557
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
558
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
559
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
560
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
561
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
562
+
563
+ # The merge belt is one lock for the whole repository, not one group per pull request.
564
+ # A batch of six overnight pull requests means every merge moves the default branch
565
+ # under the other five; gates running at once would each rebase onto a base another
566
+ # gate is about to invalidate. Serialising the belt means each gate sees a main that
567
+ # only changes between gates, every merge re-arms the siblings through
568
+ # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
569
+ call-merge-gate:
570
+ needs: classify
571
+ if: needs.classify.outputs.route == 'merge-gate'
572
+ uses: ./.github/workflows/agent-merge-gate.lock.yml
573
+ concurrency:
574
+ group: merge-belt
575
+ cancel-in-progress: false
576
+ permissions: write-all
577
+ with:
578
+ pr-number: ${{ needs.classify.outputs.pr-number }}
579
+ linked-issue: ${{ needs.classify.outputs.linked-issue }}
580
+ ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
581
+ ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
582
+ attempts_so_far: ${{ needs.classify.outputs.merge-gate-attempts }}
583
+ secrets:
584
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
585
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
586
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
587
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
588
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
589
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
590
+
591
+ call-audit:
592
+ needs: classify
593
+ if: needs.classify.outputs.route == 'audit'
594
+ uses: ./.github/workflows/agent-audit.lock.yml
595
+ concurrency:
596
+ group: audit
597
+ cancel-in-progress: false
598
+ permissions: write-all
599
+ with:
600
+ trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
601
+ secrets:
602
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
603
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
604
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
605
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
606
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
607
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
608
+
609
+ call-release:
610
+ needs: [classify, authorize]
611
+ if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
612
+ uses: ./.github/workflows/agent-release.lock.yml
613
+ permissions: write-all
614
+ with:
615
+ version-bump: ${{ github.event.inputs.version-bump || 'auto' }}
616
+ secrets:
617
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
618
+
619
+ bot-approve:
620
+ needs: classify
621
+ if: >
622
+ needs.classify.outputs.route == 'bot-approve' &&
623
+ (github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
624
+ runs-on: ubuntu-latest
625
+ timeout-minutes: 5
626
+ permissions:
627
+ actions: write
628
+ steps:
629
+ - name: Approve pending workflow runs
630
+ env:
631
+ GH_TOKEN: ${{ github.token }}
632
+ REPO: ${{ github.repository }}
633
+ BRANCH: ${{ github.head_ref }}
634
+ run: |
635
+ set -euo pipefail
636
+
637
+ mapfile -t run_ids < <(
638
+ gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
639
+ --jq '.workflow_runs[].id'
640
+ )
641
+
642
+ if [ "${#run_ids[@]}" -eq 0 ]; then
643
+ echo "No runs awaiting approval on branch $BRANCH"
644
+ exit 0
645
+ fi
646
+
647
+ for run_id in "${run_ids[@]}"; do
648
+ echo "Approving run $run_id"
649
+ gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
650
+ done
651
+
652
+ # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
653
+ # mergeability API, which the pure classifier cannot read, so this is an event-gated
654
+ # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
655
+ # the same pull_request_target event). It NEVER checks out or runs pull request code — it
656
+ # reads mergeability through the API and dispatches the Merge Gate — so it is safe on
657
+ # pull_request_target. Two entry points:
658
+ # opened / reopened / synchronize → check the pull request that changed. Gated to this
659
+ # repository's own branches so a fork PR never starts this App-token job.
660
+ # closed & merged → the target branch moved, so re-check every open bot
661
+ # pull request that may have just become conflicted (the "main changed after another PR
662
+ # merged" case). A merge into the default branch is an inherently trusted internal event.
663
+ # It dispatches only for bot-authored, open, non-draft pull requests whose head is this
664
+ # repository AND whose CI has already concluded (dispatching while CI is still pending would
665
+ # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
666
+ # covered by the CI-completion route instead). The gate's subject job re-validates ownership
667
+ # before any agent runs.
668
+ detect-pr-conflicts:
669
+ needs: classify
670
+ if: >
671
+ github.event_name == 'pull_request_target' &&
672
+ (
673
+ (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
674
+ (
675
+ (github.event.action == 'opened' ||
676
+ github.event.action == 'reopened' ||
677
+ github.event.action == 'synchronize') &&
678
+ github.event.pull_request.head.repo.full_name == github.repository
679
+ )
680
+ )
681
+ runs-on: ubuntu-latest
682
+ timeout-minutes: 10
683
+ permissions:
684
+ contents: read
685
+ pull-requests: read
686
+ actions: write
687
+ steps:
688
+ - name: Checkout for App token action
689
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
690
+ with:
691
+ persist-credentials: false
692
+ - name: Generate App token
693
+ id: app-token
694
+ uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
695
+ with:
696
+ app-id: ${{ secrets.BOT_APP_ID }}
697
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
698
+ - name: Dispatch Merge Gate for conflicting bot pull requests
699
+ env:
700
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
701
+ REPO: ${{ github.repository }}
702
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
703
+ ACTION: ${{ github.event.action }}
704
+ EVENT_PR: ${{ github.event.pull_request.number }}
705
+ run: |
706
+ set -euo pipefail
707
+
708
+ # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft,
709
+ # bot-authored pull request on this repository whose CI has concluded — the
710
+ # conflict case is one arm of that, because a conflicting PR never gets a fresh
711
+ # CI run of its own; a merge conflict with red or concluded CI is the same
712
+ # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
713
+ # to pick up when its checks finish (bot-approve / reconcile).
714
+ # GitHub computes mergeability on demand. The first read of a pull request nobody has
715
+ # opened recently answers UNKNOWN through the API and null through REST, while it works
716
+ # the answer out in the background. Reading once therefore reports "not conflicting"
717
+ # for precisely the stale conflicting pull requests that need the gate. Ask until it
718
+ # settles.
719
+ mergeable_state() {
720
+ local pr="$1" state=UNKNOWN
721
+ for _ in 1 2 3 4 5 6; do
722
+ state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
723
+ [ "$state" = "UNKNOWN" ] || break
724
+ sleep 5
725
+ done
726
+ echo "$state"
727
+ }
728
+
729
+ # A gate already queued or running for this pull request will read the same CI verdict;
730
+ # a second dispatch queues behind merge-belt and the single-slot queue cancels it. Same
731
+ # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
732
+ # title run-name gives a gate dispatch.
733
+ gate_is_live() {
734
+ local pr="$1" status live
735
+ for status in queued in_progress waiting pending; do
736
+ live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
737
+ --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
738
+ [ "${live:-0}" -eq 0 ] || return 0
739
+ done
740
+ return 1
741
+ }
742
+
743
+ maybe_dispatch() {
744
+ pr="$1"
745
+ [ -n "$pr" ] || return 0
746
+
747
+ meta=$(gh api "repos/$REPO/pulls/$pr" \
748
+ --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
749
+ 2>/dev/null || echo '')
750
+ [ -n "$meta" ] || return 0
751
+ if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
752
+ || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
753
+ || [ "$(jq -r '.bot' <<<"$meta")" != "Bot" ] \
754
+ || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
755
+ return 0
756
+ fi
757
+
758
+ # Only dispatch when CI has already concluded. A conflict while CI is still
759
+ # running is handled by the CI-completion route; dispatching now would make the
760
+ # gate read a 'pending' conclusion and prematurely ask for human review.
761
+ head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
762
+ ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
763
+ --jq '[.workflow_runs[]
764
+ | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
765
+ and (.conclusion == "success" or .conclusion == "failure"))]
766
+ | sort_by(.created_at) | last // empty')
767
+ if [ -z "$ci" ]; then
768
+ # A conflicting pull request has no merge ref for GitHub to build, so a
769
+ # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
770
+ # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
771
+ # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
772
+ # back to the branch's last verdict so the gate runs; it stays on the remediation
773
+ # path and cannot merge on a stale conclusion.
774
+ head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
775
+ mergeable_now=$(mergeable_state "$pr")
776
+ if [ "$mergeable_now" != "CONFLICTING" ]; then
777
+ echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
778
+ return 0
779
+ fi
780
+ ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
781
+ --jq '[.workflow_runs[]
782
+ | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
783
+ and (.conclusion == "success" or .conclusion == "failure"))]
784
+ | sort_by(.created_at) | last // empty')
785
+ if [ -z "$ci" ]; then
786
+ echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
787
+ return 0
788
+ fi
789
+ echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
790
+ fi
791
+ ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
792
+ ci_finished=$(jq -r '.updated_at' <<<"$ci")
793
+ ci_run_id=$(jq -r '.id' <<<"$ci")
794
+
795
+ # GitHub computes mergeability asynchronously; poll until it is known (not null).
796
+ mergeable=""
797
+ for _ in 1 2 3 4 5 6; do
798
+ mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
799
+ [ "$mergeable" = "null" ] || break
800
+ sleep 5
801
+ done
802
+
803
+ if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
804
+ # Mergeable and green: the reconcile belt and the PR's own checks finish it.
805
+ return 0
806
+ fi
807
+
808
+ # A terminal verdict newer than this CI run means a gate already decided;
809
+ # failed attempts (the attempt marker) deliberately do not count.
810
+ verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
811
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
812
+ linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
813
+ if [ -n "$linked_issue" ]; then
814
+ issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
815
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
816
+ if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
817
+ verdict="$issue_verdict"
818
+ fi
819
+ fi
820
+ # A verdict parks the CODE it was given on, not the CI run that prompted it.
821
+ # Comparing against the CI finish time meant any later run on the same commits
822
+ # reopened a park a human already owned. The newest commit on the branch is the
823
+ # right key: push, and the gate decides again; re-run CI, and the park holds.
824
+ head_committed=$(gh pr view "$pr" --repo "$REPO" \
825
+ --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
826
+ horizon="${head_committed:-$ci_finished}"
827
+ if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
828
+ echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
829
+ return 0
830
+ fi
831
+
832
+ attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
833
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
834
+ if [ -n "$linked_issue" ]; then
835
+ issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
836
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
837
+ attempts=$((attempts + issue_attempts))
838
+ fi
839
+ if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
840
+ echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
841
+ return 0
842
+ fi
843
+ if gate_is_live "$pr"; then
844
+ echo "PR #$pr: a merge-gate run is already live; not dispatching another."
845
+ return 0
846
+ fi
847
+
848
+ echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
849
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
850
+ --method POST \
851
+ -f ref="$DEFAULT_BRANCH" \
852
+ -f "inputs[operation]=merge-gate" \
853
+ -f "inputs[pr-number]=$pr" \
854
+ -f "inputs[ci-conclusion]=$ci_conclusion" \
855
+ -f "inputs[ci-run-id]=$ci_run_id" \
856
+ -f "inputs[attempts_so_far]=$attempts"
857
+ }
858
+
859
+ if [ "$ACTION" = "closed" ]; then
860
+ # The target branch just moved. Any previously-mergeable bot PR may now conflict.
861
+ # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
862
+ gh api "repos/$REPO/pulls?state=open&per_page=100" \
863
+ --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
864
+ while IFS= read -r pr; do
865
+ maybe_dispatch "$pr"
866
+ done
867
+ else
868
+ maybe_dispatch "$EVENT_PR"
869
+ fi
870
+
871
+ reconcile-bot-pr-runs:
872
+ needs: classify
873
+ if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
874
+ runs-on: ubuntu-latest
875
+ timeout-minutes: 5
876
+ permissions:
877
+ contents: read
878
+ actions: write
879
+ pull-requests: read
880
+ # the stale reservation sweep clears bot-working from parked issues
881
+ issues: write
882
+ env:
883
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
884
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
885
+ steps:
886
+ - name: Checkout for App token action
887
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
888
+ with:
889
+ persist-credentials: false
890
+ - name: Generate App token
891
+ id: app-token
892
+ uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
893
+ with:
894
+ app-id: ${{ secrets.BOT_APP_ID }}
895
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
896
+ # Runs only the default-branch workflow and never checks out pull-request code.
897
+ # External users cannot impersonate github-actions[bot].
898
+ - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
899
+ env:
900
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
901
+ REPO: ${{ github.repository }}
902
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
903
+ run: |
904
+ set -euo pipefail
905
+
906
+ # GitHub computes mergeability on demand. The first read of a pull request nobody has
907
+ # opened recently answers UNKNOWN through the API and null through REST, while it works
908
+ # the answer out in the background. Reading once therefore reports "not conflicting"
909
+ # for precisely the stale conflicting pull requests that need the gate. Ask until it
910
+ # settles.
911
+ mergeable_state() {
912
+ local pr="$1" state=UNKNOWN
913
+ for _ in 1 2 3 4 5 6; do
914
+ state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
915
+ [ "$state" = "UNKNOWN" ] || break
916
+ sleep 5
917
+ done
918
+ echo "$state"
919
+ }
920
+
921
+ # A gate already queued or running for this pull request will read the same CI verdict;
922
+ # a second dispatch queues behind merge-belt and the single-slot queue cancels it.
923
+ gate_is_live() {
924
+ local pr="$1" status live
925
+ for status in queued in_progress waiting pending; do
926
+ live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
927
+ --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
928
+ [ "${live:-0}" -eq 0 ] || return 0
929
+ done
930
+ return 1
931
+ }
932
+
933
+ # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
934
+ # because authorize-bot-work is keyed per issue, while implement-global lets exactly
935
+ # one run at a time. A queued run that is cancelled therefore leaves its issue
936
+ # carrying bot-working with no run and no pull request: parked, invisible, and not
937
+ # re-triggerable, because classify refuses an issue that is already reserved.
938
+ gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
939
+ --json number --jq '.[].number' |
940
+ while IFS= read -r issue; do
941
+ [ -n "$issue" ] || continue
942
+
943
+ live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
944
+ --jq "[.workflow_runs[]
945
+ | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
946
+ | select(.display_title | test(\"#${issue}\\b\"))] | length")
947
+ [ "${live:-0}" -eq 0 ] || continue
948
+
949
+ has_pr=$(gh pr list --repo "$REPO" --state open --json body \
950
+ --jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${issue}\\b|fix(es|ed)? #${issue}\\b|resolves? #${issue}\\b\"))] | length")
951
+ [ "${has_pr:-0}" -eq 0 ] || continue
952
+
953
+ echo "Clearing a stale bot-working reservation on #$issue: no run, no pull request."
954
+ gh issue edit "$issue" --repo "$REPO" --remove-label bot-working
955
+ done
956
+
957
+ gh api "repos/$REPO/pulls?state=open&per_page=100" |
958
+ jq -r --arg repo "$REPO" '
959
+ .[]
960
+ | select(
961
+ # Bind the login first. Inside index(...) the input is the array, so a bare
962
+ # .user.login there indexes the array and jq dies with "Cannot index array
963
+ # with string \"user\"", taking the whole hourly belt with it.
964
+ ((.user.login) as $login | (env.TRUSTED_BOTS | split(" ")) | index($login) != null)
965
+ and .draft == false
966
+ and .head.repo.full_name == $repo
967
+ )
968
+ | [.number, .head.ref, .head.sha] | @tsv
969
+ ' |
970
+ while IFS=$'\t' read -r pr_number branch head_sha; do
971
+ gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
972
+ --jq '.workflow_runs[]
973
+ | select(
974
+ .event == "pull_request"
975
+ and .name == env.CI_WORKFLOW_NAME
976
+ )
977
+ | .id' |
978
+ while IFS= read -r run_id; do
979
+ echo "Approving trusted bot run $run_id"
980
+ gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
981
+ done
982
+
983
+ ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
984
+ --jq '[.workflow_runs[]
985
+ | select(
986
+ .name == env.CI_WORKFLOW_NAME
987
+ and .event == "pull_request"
988
+ and .status == "completed"
989
+ and (.conclusion == "success" or .conclusion == "failure")
990
+ )
991
+ ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
992
+ if [ -z "$ci" ]; then
993
+ # A conflicting pull request has no merge ref for GitHub to build, so a
994
+ # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
995
+ # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
996
+ # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
997
+ # back to the branch's last verdict so the gate runs; it stays on the remediation
998
+ # path and cannot merge on a stale conclusion.
999
+ mergeable_now=$(mergeable_state "$pr_number")
1000
+ if [ "$mergeable_now" = "CONFLICTING" ]; then
1001
+ ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
1002
+ --jq '[.workflow_runs[]
1003
+ | select(
1004
+ .name == env.CI_WORKFLOW_NAME
1005
+ and .event == "pull_request"
1006
+ and .status == "completed"
1007
+ and (.conclusion == "success" or .conclusion == "failure")
1008
+ )
1009
+ ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1010
+ fi
1011
+ if [ -z "$ci" ]; then
1012
+ echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
1013
+ continue
1014
+ fi
1015
+ echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1016
+ fi
1017
+
1018
+ IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
1019
+
1020
+ # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
1021
+ # line; progress comments and failed attempts deliberately carry neither
1022
+ # matching pair, so a crashed or OOM-killed gate never masks a PR from
1023
+ # later sweeps. A recorded verdict only counts when it is NEWER than the
1024
+ # latest CI run; the old existence check made the gate one-shot per PR.
1025
+ # Attempts are failed gates (the attempt marker) since this CI verdict.
1026
+ latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1027
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1028
+ linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1029
+ if [ -n "$linked_issue" ]; then
1030
+ issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1031
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1032
+ if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
1033
+ latest_verdict="$issue_verdict"
1034
+ fi
1035
+ fi
1036
+ # A verdict parks the CODE it was given on, not the CI run that prompted it. This
1037
+ # used to compare against the CI finish time, which made the park worthless: any
1038
+ # later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
1039
+ # main moving -- was newer than the verdict, so the belt re-dispatched a pull
1040
+ # request a human had already been handed, and the attempt counter below reset to
1041
+ # zero at the same time and handed it a fresh budget of six full gate runs.
1042
+ # Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
1043
+ #
1044
+ # The newest commit on the branch is the right key. A gate verdict newer than it
1045
+ # applies to the current head, so the park holds however often CI runs. Push a
1046
+ # commit -- which is what apply-review and a `remediated` verdict both do -- and
1047
+ # the head moves past the verdict and the gate decides again, which is correct:
1048
+ # that is new code nobody has ruled on.
1049
+ head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
1050
+ --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1051
+ verdict_horizon="${head_committed:-$ci_finished}"
1052
+ if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
1053
+ echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
1054
+ continue
1055
+ fi
1056
+
1057
+ attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1058
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1059
+ if [ -n "$linked_issue" ]; then
1060
+ issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1061
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1062
+ attempts=$((attempts + issue_attempts))
1063
+ fi
1064
+ if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1065
+ echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
1066
+ continue
1067
+ fi
1068
+ if gate_is_live "$pr_number"; then
1069
+ echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
1070
+ continue
1071
+ fi
1072
+
1073
+ echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
1074
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1075
+ --method POST \
1076
+ -f ref="$DEFAULT_BRANCH" \
1077
+ -f "inputs[operation]=merge-gate" \
1078
+ -f "inputs[pr-number]=$pr_number" \
1079
+ -f "inputs[ci-conclusion]=$ci_conclusion" \
1080
+ -f "inputs[ci-run-id]=$ci_run_id" \
1081
+ -f "inputs[attempts_so_far]=$attempts"
1082
+ done
1083
+
1084
+ audit-close:
1085
+ needs: classify
1086
+ if: needs.classify.outputs.route == 'audit-close'
1087
+ runs-on: ubuntu-latest
1088
+ timeout-minutes: 15
1089
+ concurrency:
1090
+ group: audit-close
1091
+ cancel-in-progress: false
1092
+ permissions:
1093
+ contents: read
1094
+ issues: write
1095
+ # It reads the open pull requests before closing a report, so a report whose
1096
+ # implement workers are still in review is left alone. Without this the list
1097
+ # call answers 403 and the nightly job fails having closed nothing.
1098
+ pull-requests: read
1099
+ steps:
1100
+ - name: Checkout workflow actions
1101
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1102
+ with:
1103
+ persist-credentials: false
1104
+ - uses: ./.github/actions/audit-close
1105
+ with:
1106
+ token: ${{ github.token }}
1107
+ stale-after-days: ${{ env.AUDIT_STALE_AFTER_DAYS }}
1108
+
1109
+ cleanup-artifacts:
1110
+ needs: classify
1111
+ if: needs.classify.outputs.route == 'cleanup-artifacts' && !github.event.repository.fork
1112
+ runs-on: ubuntu-latest
1113
+ timeout-minutes: 15
1114
+ concurrency:
1115
+ group: cleanup-artifacts
1116
+ cancel-in-progress: false
1117
+ permissions:
1118
+ contents: read
1119
+ actions: write
1120
+ steps:
1121
+ - name: Checkout workflow actions
1122
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1123
+ with:
1124
+ persist-credentials: false
1125
+ - uses: ./.github/actions/cleanup-artifacts
1126
+ with:
1127
+ token: ${{ github.token }}
1128
+ artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
1129
+
1130
+ # The janitor. Deterministic, no model: it retries work the machine stalled on, clears the state
1131
+ # a dead run stranded, deletes branches whose pull request is finished, and keeps one issue
1132
+ # listing what genuinely needs a person.
1133
+ housekeeping:
1134
+ needs: classify
1135
+ if: needs.classify.outputs.route == 'housekeeping' && !github.event.repository.fork
1136
+ runs-on: ubuntu-latest
1137
+ timeout-minutes: 15
1138
+ concurrency:
1139
+ group: housekeeping
1140
+ cancel-in-progress: false
1141
+ permissions:
1142
+ # contents: write deletes branches whose pull request is closed or merged.
1143
+ contents: write
1144
+ issues: write
1145
+ pull-requests: read
1146
+ env:
1147
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
1148
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
1149
+ steps:
1150
+ - name: Checkout workflow actions
1151
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1152
+ with:
1153
+ persist-credentials: false
1154
+ # A retry is a workflow_dispatch, and GitHub refuses to start a workflow run from an event
1155
+ # raised with GITHUB_TOKEN. With the default token every retry would report success and do
1156
+ # nothing, which is the exact failure mode this job exists to remove.
1157
+ - name: Generate App token
1158
+ id: app-token
1159
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1160
+ with:
1161
+ app-id: ${{ secrets.BOT_APP_ID }}
1162
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1163
+ - uses: ./.github/actions/housekeeping
1164
+ with:
1165
+ token: ${{ steps.app-token.outputs.token }}
1166
+ retry-after-hours: ${{ env.HOUSEKEEPING_RETRY_AFTER_HOURS }}
1167
+ max-retries: ${{ env.HOUSEKEEPING_MAX_RETRIES }}
1168
+ stale-pr-days: ${{ env.HOUSEKEEPING_STALE_PR_DAYS }}
1169
+ digest-title: ${{ env.HOUSEKEEPING_DIGEST_TITLE }}
1170
+ dry-run: ${{ github.event.inputs.dry-run || 'false' }}
1171
+
1172
+ validate:
1173
+ needs: classify
1174
+ if: needs.classify.outputs.route == 'validate'
1175
+ runs-on: ubuntu-latest
1176
+ timeout-minutes: 10
1177
+ permissions:
1178
+ contents: read
1179
+ steps:
1180
+ - name: Checkout workflow actions
1181
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1182
+ with:
1183
+ persist-credentials: false
1184
+ - uses: ./.github/actions/verify-route-matrix
1185
+ - uses: ./.github/actions/verify-composite-actions