@plainconceptsplatform/workflows 0.4.34 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +88 -88
  2. package/dist/action-validation.test.js +5 -2
  3. package/dist/catalog-installation.js +50 -7
  4. package/dist/catalog-installation.test.js +59 -22
  5. package/dist/catalog-listing.test.js +4 -4
  6. package/dist/index.js +48 -48
  7. package/dist/index.test.js +9 -9
  8. package/dist/repository-inspection.test.js +2 -2
  9. package/dist/route-processing.js +24 -25
  10. package/dist/route-processing.test.js +166 -233
  11. package/dist/stack-defaults.js +14 -14
  12. package/dist/stack-defaults.test.js +79 -79
  13. package/dist/tui.test.js +10 -7
  14. package/dist/workflow-catalog.d.ts +1 -1
  15. package/dist/workflow-catalog.js +1 -5
  16. package/loops/actions/add-issue-labels/action.yml +2 -2
  17. package/loops/actions/agent-output.cjs +17 -17
  18. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  19. package/loops/actions/audit-close/action.yml +24 -0
  20. package/loops/actions/classify-route/action.yml +8 -3
  21. package/loops/actions/classify-route/classify-route.sh +58 -38
  22. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  23. package/loops/actions/identify-gate-subject/action.yml +3 -1
  24. package/loops/actions/remove-issue-labels/action.yml +4 -2
  25. package/loops/actions/update-changelog/action.yml +113 -0
  26. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  27. package/loops/actions/validate-refine-output/validate-refine-output.sh +34 -5
  28. package/loops/actions/validate-review-output/action.yml +35 -35
  29. package/loops/actions/validate-triage-output/action.yml +36 -36
  30. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +375 -36
  32. package/loops/scripts/compile-agent-workflows.mjs +290 -6
  33. package/loops/scripts/merge-changelog.mjs +76 -0
  34. package/loops/templates/agentics/actionlint.yaml +13 -0
  35. package/loops/templates/agentics/agentics-checks.yml +125 -8
  36. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  37. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -171
  38. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -178
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/opencode/opencode.ci.json +49 -47
  42. package/loops/templates/opencode/opencode.ci.json.md +49 -41
  43. package/loops/templates/release/github-release.yml +30 -30
  44. package/loops/workflows/agent-apply-review.md +469 -443
  45. package/loops/workflows/agent-audit.md +213 -197
  46. package/loops/workflows/agent-implement.md +640 -414
  47. package/loops/workflows/agent-merge-gate.md +844 -584
  48. package/loops/workflows/agent-refine.md +633 -418
  49. package/loops/workflows/agent-release.md +258 -0
  50. package/loops/workflows/agent-triage.md +447 -439
  51. package/loops/workflows/authorize-bot-work.yml +85 -82
  52. package/loops/workflows/shared/opencode-ci.md +206 -197
  53. package/loops/workflows/shared/platform-defaults.md +19 -16
  54. package/loops/workflows/work-router.yml +1038 -632
  55. package/package.json +7 -8
  56. package/dist/repository-state.d.ts +0 -18
  57. package/dist/repository-state.js +0 -77
  58. package/dist/repository-state.test.d.ts +0 -1
  59. package/dist/repository-state.test.js +0 -96
  60. package/loops/workflows/agent-direct.md +0 -375
  61. package/loops/workflows/agent-propose.md +0 -342
@@ -1,632 +1,1038 @@
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
- && format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
35
- || github.event_name == 'issue_comment'
36
- && format('comment on #{0} by {1}', github.event.issue.number, github.actor)
37
- || github.event_name == 'pull_request_review'
38
- && format('review on PR #{0} by {1}', github.event.pull_request.number, github.actor)
39
- || github.event_name == 'pull_request_review_comment'
40
- && format('review comment on PR #{0} by {1}', github.event.pull_request.number, github.actor)
41
- || github.event_name == 'pull_request_target'
42
- && format('PR #{0} {1} by {2}', github.event.pull_request.number, github.event.action, github.actor)
43
- || github.event_name == 'workflow_run'
44
- && format('CI {0} on {1}', github.event.workflow_run.conclusion, github.event.workflow_run.head_branch)
45
- || github.event_name == 'workflow_dispatch'
46
- && inputs.operation == 'triage'
47
- && format('Working (Triage): #{0}', inputs.issue-number)
48
- || github.event_name == 'workflow_dispatch'
49
- && inputs.operation == 'refine'
50
- && format('Working (Refine): #{0}', inputs.issue-number)
51
- || github.event_name == 'workflow_dispatch'
52
- && inputs.operation == 'implement'
53
- && format('Working (Implement): #{0}', inputs.issue-number)
54
- || github.event_name == 'workflow_dispatch'
55
- && inputs.operation == 'merge-gate'
56
- && format('Working (Merge Gate): PR #{0}', inputs.pr-number)
57
- || github.event_name == 'workflow_dispatch'
58
- && format('dispatch: {0}', inputs.operation)
59
- || github.event_name == 'schedule'
60
- && format('schedule {0}', github.event.schedule)
61
- || github.event_name }}
62
-
63
- on:
64
- issues:
65
- types: [opened, labeled]
66
- issue_comment:
67
- types: [created]
68
- pull_request_review_comment:
69
- types: [created]
70
- pull_request_review:
71
- types: [submitted]
72
-
73
- pull_request_target:
74
- types: [opened, synchronize, reopened, labeled]
75
- workflow_run:
76
- workflows: ["App: CI", "CI"]
77
- types: [completed]
78
- branches:
79
- - "fix/*"
80
- - "bugfix/*"
81
- - "feature/*"
82
- - "bot/*"
83
- - "patch/*"
84
- - "hotfix/*"
85
-
86
- # Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
87
- schedule:
88
- - cron: "17 1 * * 1"
89
- - cron: "43 3 * * *"
90
- - cron: "0 6 * * *"
91
- - cron: "17 */2 * * *"
92
- - cron: "29 7 * * *"
93
-
94
- workflow_dispatch:
95
- inputs:
96
- operation:
97
- description: "Operation to run"
98
- required: true
99
- type: choice
100
- options:
101
- - refine
102
- - implement
103
- - direct
104
- - triage
105
- - apply-review
106
- - merge-gate
107
- - audit
108
- - propose
109
- - audit-close
110
- - cleanup-artifacts
111
- - reconcile-bot-pr-runs
112
- - validate
113
- issue-number:
114
- description: "Issue number (required for refine / implement / direct)"
115
- required: false
116
- type: string
117
- pr-number:
118
- description: "Pull request number (required for apply-review / merge-gate)"
119
- required: false
120
- type: string
121
- mode:
122
- description: "Refine mode: first or rerefine"
123
- required: false
124
- type: choice
125
- options:
126
- - first
127
- - rerefine
128
- ci-conclusion:
129
- description: "CI conclusion for merge-gate dispatch"
130
- required: false
131
- type: choice
132
- options:
133
- - success
134
- - failure
135
- - action_required
136
- ci-run-id:
137
- description: "CI workflow run ID for merge-gate dispatch"
138
- required: false
139
- type: string
140
- trigger-kind:
141
- description: "Audit trigger: scheduled or manual"
142
- required: false
143
- type: choice
144
- options:
145
- - manual
146
- - scheduled
147
-
148
- permissions:
149
- contents: read
150
-
151
- jobs:
152
- # Public repositories must check repository permission before an event can start a model.
153
- authorize:
154
- runs-on: RunnerLandingZone
155
- timeout-minutes: 5
156
- permissions:
157
- contents: read
158
- outputs:
159
- trusted: ${{ steps.check.outputs.trusted }}
160
- is_outside_collaborator: ${{ steps.check.outputs.is_outside_collaborator }}
161
- steps:
162
- - name: Check the actor can start an agent
163
- id: check
164
- env:
165
- GH_TOKEN: ${{ github.token }}
166
- ACTOR: ${{ github.actor }}
167
- EVENT: ${{ github.event_name }}
168
- run: |
169
- set -euo pipefail
170
-
171
- # Events with no human actor. `schedule` and `workflow_run` are the fleet triggering
172
- # itself; `workflow_dispatch` is already restricted by GitHub to write and above.
173
- case "$EVENT" in
174
- schedule | workflow_run | workflow_dispatch)
175
- echo "trusted=true" >> "$GITHUB_OUTPUT"
176
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
177
- echo "::notice::$EVENT has no untrusted actor."
178
- exit 0
179
- ;;
180
- esac
181
-
182
- # Platform bot owns Safe Outputs. Its label changes must be able to
183
- # start the follow-on worker, even though GitHub does not report it
184
- # as a repository collaborator.
185
- if [ "$ACTOR" = "github-actions[bot]" ] || [ "$ACTOR" = "platform-devbox[bot]" ]; then
186
- echo "trusted=true" >> "$GITHUB_OUTPUT"
187
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
188
- exit 0
189
- fi
190
-
191
- permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
192
- --jq '.permission' 2>/dev/null || echo "none")
193
-
194
- case "$permission" in
195
- admin | maintain | write)
196
- echo "trusted=true" >> "$GITHUB_OUTPUT"
197
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
198
- echo "::notice::$ACTOR has $permission permission."
199
- ;;
200
- read)
201
- echo "trusted=false" >> "$GITHUB_OUTPUT"
202
- echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
203
- echo "::notice::$ACTOR is an outside collaborator (read permission). Triage route eligible."
204
- ;;
205
- *)
206
- echo "trusted=false" >> "$GITHUB_OUTPUT"
207
- echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
208
- echo "::notice::$ACTOR has '$permission' permission on this repository. No agent will run."
209
- ;;
210
- esac
211
-
212
- classify:
213
- runs-on: RunnerLandingZone
214
- timeout-minutes: 5
215
- permissions:
216
- contents: read
217
- pull-requests: read
218
- outputs:
219
- route: ${{ steps.route.outputs.route }}
220
- issue-number: ${{ steps.route.outputs.issue-number }}
221
- pr-number: ${{ steps.route.outputs.pr-number }}
222
- linked-issue: ${{ steps.route.outputs.linked-issue }}
223
- ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
224
- ci-run-id: ${{ steps.route.outputs.ci-run-id }}
225
- refine-mode: ${{ steps.route.outputs.refine-mode }}
226
- direct-mode: ${{ steps.route.outputs.direct-mode }}
227
- triage-mode: ${{ steps.route.outputs.triage-mode }}
228
- trigger-kind: ${{ steps.route.outputs.trigger-kind }}
229
- steps:
230
- - name: Checkout workflow actions
231
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
232
- with:
233
- persist-credentials: false
234
- - name: Classify event into a single route
235
- id: route
236
- uses: ./.github/actions/classify-route
237
- with:
238
- token: ${{ github.token }}
239
-
240
- call-refine:
241
- needs: [classify, authorize]
242
- if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
243
- uses: ./.github/workflows/agent-refine.lock.yml
244
- concurrency:
245
- group: refine-${{ needs.classify.outputs.issue-number }}
246
- cancel-in-progress: false
247
- # write-all, not a narrower map: every worker's agent job declares `permissions: read-all`,
248
- # which requests read on every scope. A caller granting less cannot satisfy it, and GitHub
249
- # rejects the call with a startup failure before any job is created. The real boundary is the
250
- # worker's own read-all plus safe-outputs, not this grant.
251
- permissions: write-all
252
- with:
253
- issue-number: ${{ needs.classify.outputs.issue-number }}
254
- mode: ${{ needs.classify.outputs.refine-mode }}
255
- secrets:
256
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
257
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
258
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
259
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
260
-
261
- # Checks whether an open bot PR already exists for the issue. If it does, implement is
262
- # skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
263
- # PRs when retries are triggered after a merge-gate failure.
264
- check-implement-pr:
265
- needs: [classify, authorize]
266
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
267
- runs-on: RunnerLandingZone
268
- timeout-minutes: 3
269
- permissions:
270
- contents: read
271
- pull-requests: read
272
- outputs:
273
- has-open-pr: ${{ steps.check.outputs.has-open-pr }}
274
- steps:
275
- - name: Check for existing open bot PR
276
- id: check
277
- env:
278
- GH_TOKEN: ${{ github.token }}
279
- REPO: ${{ github.repository }}
280
- ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
281
- run: |
282
- set -euo pipefail
283
- existing=$(gh pr list --repo "$REPO" --state open --json number,author,body \
284
- --jq "[.[]
285
- | select(.author.login | startswith(\"app/\") or endswith(\"[bot]\"))
286
- | (.body | ascii_downcase) as \$body
287
- | 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}\"))
288
- ] | length")
289
- if [ "$existing" -gt 0 ]; then
290
- echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
291
- echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
292
- else
293
- echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
294
- fi
295
-
296
- call-implement:
297
- needs: [classify, authorize, check-implement-pr]
298
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
299
- uses: ./.github/workflows/agent-implement.lock.yml
300
- concurrency:
301
- group: write-pipeline-${{ needs.classify.outputs.issue-number }}
302
- cancel-in-progress: false
303
- permissions: write-all
304
- with:
305
- issue-number: ${{ needs.classify.outputs.issue-number }}
306
- secrets:
307
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
308
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
309
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
310
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
311
-
312
- call-direct:
313
- needs: [classify, authorize]
314
- if: needs.classify.outputs.route == 'direct' && needs.authorize.outputs.trusted == 'true'
315
- uses: ./.github/workflows/agent-direct.lock.yml
316
- concurrency:
317
- group: direct-${{ needs.classify.outputs.issue-number }}
318
- cancel-in-progress: false
319
- permissions: write-all
320
- with:
321
- issue-number: ${{ needs.classify.outputs.issue-number }}
322
- mode: ${{ needs.classify.outputs.direct-mode }}
323
- secrets:
324
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
325
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
326
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
327
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
328
-
329
- # An outside collaborator cannot pass gh-aw's worker membership check. The router has
330
- # already verified their read permission, so re-dispatch under the trusted App identity.
331
- dispatch-triage:
332
- needs: [classify, authorize]
333
- if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
334
- runs-on: RunnerLandingZone
335
- timeout-minutes: 5
336
- permissions:
337
- actions: write
338
- contents: read
339
- steps:
340
- - name: Create bot token
341
- id: app-token
342
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
343
- with:
344
- client-id: ${{ secrets.BOT_APP_ID }}
345
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
346
- - name: Dispatch trusted triage run
347
- env:
348
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
349
- REPO: ${{ github.repository }}
350
- REF: ${{ github.event.repository.default_branch }}
351
- ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
352
- MODE: ${{ needs.classify.outputs.triage-mode }}
353
- run: |
354
- set -euo pipefail
355
- gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
356
- -f operation=triage \
357
- -f issue-number="$ISSUE_NUMBER" \
358
- -f mode="$MODE"
359
-
360
- call-triage:
361
- needs: [classify, authorize]
362
- if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true'
363
- uses: ./.github/workflows/agent-triage.lock.yml
364
- concurrency:
365
- group: triage-${{ needs.classify.outputs.issue-number }}
366
- cancel-in-progress: false
367
- permissions: write-all
368
- with:
369
- issue-number: ${{ needs.classify.outputs.issue-number }}
370
- mode: ${{ needs.classify.outputs.triage-mode }}
371
- secrets:
372
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
373
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
374
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
375
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
376
-
377
- call-apply-review:
378
- needs: [classify, authorize]
379
- if: needs.classify.outputs.route == 'apply-review' && needs.authorize.outputs.trusted == 'true'
380
- uses: ./.github/workflows/agent-apply-review.lock.yml
381
- concurrency:
382
- group: pr-feedback-${{ needs.classify.outputs.pr-number }}
383
- cancel-in-progress: false
384
- permissions: write-all
385
- with:
386
- pr-number: ${{ needs.classify.outputs.pr-number }}
387
- linked-issue: ${{ needs.classify.outputs.linked-issue }}
388
- secrets:
389
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
390
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
391
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
392
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
393
-
394
- # Shares the write-pipeline group with implement so the two never race on one issue.
395
- # Falls back to the pull request number only when the closing issue cannot be resolved.
396
- call-merge-gate:
397
- needs: classify
398
- if: needs.classify.outputs.route == 'merge-gate'
399
- uses: ./.github/workflows/agent-merge-gate.lock.yml
400
- concurrency:
401
- group: write-pipeline-${{ needs.classify.outputs.linked-issue || format('pr-{0}', needs.classify.outputs.pr-number) }}
402
- cancel-in-progress: false
403
- permissions: write-all
404
- with:
405
- pr-number: ${{ needs.classify.outputs.pr-number }}
406
- linked-issue: ${{ needs.classify.outputs.linked-issue }}
407
- ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
408
- ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
409
- secrets:
410
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
411
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
412
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
413
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
414
-
415
- call-audit:
416
- needs: classify
417
- if: needs.classify.outputs.route == 'audit'
418
- uses: ./.github/workflows/agent-audit.lock.yml
419
- concurrency:
420
- group: audit
421
- cancel-in-progress: false
422
- permissions: write-all
423
- with:
424
- trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
425
- secrets:
426
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
427
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
428
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
429
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
430
-
431
- call-propose:
432
- needs: classify
433
- if: needs.classify.outputs.route == 'propose'
434
- uses: ./.github/workflows/agent-propose.lock.yml
435
- concurrency:
436
- group: propose
437
- cancel-in-progress: false
438
- permissions: write-all
439
- with:
440
- trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
441
- secrets:
442
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
443
- CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }}
444
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
445
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
446
-
447
- bot-approve:
448
- needs: classify
449
- if: >
450
- needs.classify.outputs.route == 'bot-approve' &&
451
- (github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
452
- runs-on: RunnerLandingZone
453
- timeout-minutes: 5
454
- permissions:
455
- actions: write
456
- steps:
457
- - name: Approve pending workflow runs
458
- env:
459
- GH_TOKEN: ${{ github.token }}
460
- REPO: ${{ github.repository }}
461
- BRANCH: ${{ github.head_ref }}
462
- run: |
463
- set -euo pipefail
464
-
465
- mapfile -t run_ids < <(
466
- gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
467
- --jq '.workflow_runs[].id'
468
- )
469
-
470
- if [ "${#run_ids[@]}" -eq 0 ]; then
471
- echo "No runs awaiting approval on branch $BRANCH"
472
- exit 0
473
- fi
474
-
475
- for run_id in "${run_ids[@]}"; do
476
- echo "Approving run $run_id"
477
- gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
478
- done
479
-
480
- reconcile-bot-pr-runs:
481
- needs: classify
482
- if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
483
- runs-on: RunnerLandingZone
484
- timeout-minutes: 5
485
- permissions:
486
- contents: read
487
- actions: write
488
- pull-requests: read
489
- env:
490
- BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
491
- BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
492
- steps:
493
- - name: Checkout for App token action
494
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
495
- with:
496
- persist-credentials: false
497
- - name: Generate App token
498
- id: app-token
499
- uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
500
- with:
501
- app-id: ${{ secrets.BOT_APP_ID }}
502
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
503
- # Runs only the default-branch workflow and never checks out pull-request code.
504
- # External users cannot impersonate github-actions[bot].
505
- - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
506
- env:
507
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
508
- REPO: ${{ github.repository }}
509
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
510
- run: |
511
- set -euo pipefail
512
-
513
- gh api "repos/$REPO/pulls?state=open&per_page=100" |
514
- jq -r --arg repo "$REPO" '
515
- .[]
516
- | select(
517
- (.user.login == "github-actions[bot]" or .user.login == "app/github-actions" or .user.login == "platform-devbox[bot]")
518
- and .head.repo.full_name == $repo
519
- )
520
- | [.number, .head.ref] | @tsv
521
- ' |
522
- while IFS=$'\t' read -r pr_number branch; do
523
- gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
524
- --jq '.workflow_runs[]
525
- | select(
526
- .event == "pull_request"
527
- and (.name == "App: CI" or .name == "Visual evidence")
528
- )
529
- | .id' |
530
- while IFS= read -r run_id; do
531
- echo "Approving trusted bot run $run_id"
532
- gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
533
- done
534
-
535
- # Check if merge-gate already ran for this PR (comment on PR or linked issue)
536
- gate_recorded=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
537
- --jq 'any(.[]; .body | contains("<!-- agent-merge-gate -->"))')
538
- if [ "$gate_recorded" = "true" ]; then
539
- echo "Merge gate already recorded for PR #$pr_number, skipping"
540
- continue
541
- fi
542
-
543
- # Also check the linked issue comments if the PR closes an issue
544
- linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
545
- if [ -n "$linked_issue" ]; then
546
- gate_recorded=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
547
- --jq 'any(.[]; .body | contains("<!-- agent-merge-gate -->"))')
548
- if [ "$gate_recorded" = "true" ]; then
549
- echo "Merge gate already recorded on issue #$linked_issue for PR #$pr_number, skipping"
550
- continue
551
- fi
552
- fi
553
-
554
- ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
555
- --jq '[.workflow_runs[]
556
- | select(
557
- .name == "App: CI"
558
- and .event == "pull_request"
559
- and .status == "completed"
560
- and (.conclusion == "success" or .conclusion == "failure")
561
- )
562
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion] | @tsv')
563
- if [ -z "$ci" ]; then
564
- continue
565
- fi
566
-
567
- IFS=$'\t' read -r ci_run_id ci_conclusion <<<"$ci"
568
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id)"
569
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
570
- --method POST \
571
- -f ref="$DEFAULT_BRANCH" \
572
- -f "inputs[operation]=merge-gate" \
573
- -f "inputs[pr-number]=$pr_number" \
574
- -f "inputs[ci-conclusion]=$ci_conclusion" \
575
- -f "inputs[ci-run-id]=$ci_run_id"
576
- done
577
-
578
- audit-close:
579
- needs: classify
580
- if: needs.classify.outputs.route == 'audit-close'
581
- runs-on: RunnerLandingZone
582
- timeout-minutes: 15
583
- concurrency:
584
- group: audit-close
585
- cancel-in-progress: false
586
- permissions:
587
- contents: read
588
- issues: write
589
- steps:
590
- - name: Checkout workflow actions
591
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
592
- with:
593
- persist-credentials: false
594
- - uses: ./.github/actions/audit-close
595
- with:
596
- token: ${{ github.token }}
597
-
598
- cleanup-artifacts:
599
- needs: classify
600
- if: needs.classify.outputs.route == 'cleanup-artifacts' && !github.event.repository.fork
601
- runs-on: RunnerLandingZone
602
- timeout-minutes: 15
603
- concurrency:
604
- group: cleanup-artifacts
605
- cancel-in-progress: false
606
- permissions:
607
- contents: read
608
- actions: write
609
- steps:
610
- - name: Checkout workflow actions
611
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
612
- with:
613
- persist-credentials: false
614
- - uses: ./.github/actions/cleanup-artifacts
615
- with:
616
- token: ${{ github.token }}
617
- artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
618
-
619
- validate:
620
- needs: classify
621
- if: needs.classify.outputs.route == 'validate'
622
- runs-on: RunnerLandingZone
623
- timeout-minutes: 10
624
- permissions:
625
- contents: read
626
- steps:
627
- - name: Checkout workflow actions
628
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
629
- with:
630
- persist-credentials: false
631
- - uses: ./.github/actions/verify-route-matrix
632
- - 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
+ 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