@plainconceptsplatform/workflows 0.4.34 → 0.5.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 (73) hide show
  1. package/README.md +88 -88
  2. package/loops/actions/agent-output.cjs +17 -17
  3. package/loops/actions/audit-close/action.yml +24 -0
  4. package/loops/actions/classify-route/action.yml +1 -3
  5. package/loops/actions/classify-route/classify-route.sh +40 -35
  6. package/loops/actions/update-changelog/action.yml +113 -0
  7. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  8. package/loops/actions/validate-refine-output/validate-refine-output.sh +16 -3
  9. package/loops/actions/validate-review-output/action.yml +35 -35
  10. package/loops/actions/validate-triage-output/action.yml +36 -36
  11. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +91 -36
  12. package/loops/scripts/compile-agent-workflows.mjs +195 -6
  13. package/loops/templates/agentics/agentics-checks.yml +86 -86
  14. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  15. package/loops/templates/ci/app-ci-dotnet-next.yml +268 -171
  16. package/loops/templates/ci/app-ci-node-monorepo.yml +208 -178
  17. package/loops/templates/issues/bug_report.yml +109 -109
  18. package/loops/templates/issues/feature_request.yml +75 -75
  19. package/loops/templates/opencode/opencode.ci.json +49 -47
  20. package/loops/templates/opencode/opencode.ci.json.md +46 -41
  21. package/loops/templates/release/github-release.yml +30 -30
  22. package/loops/workflows/agent-apply-review.md +465 -443
  23. package/loops/workflows/agent-audit.md +213 -197
  24. package/loops/workflows/agent-implement.md +538 -414
  25. package/loops/workflows/agent-merge-gate.md +730 -584
  26. package/loops/workflows/agent-refine.md +609 -418
  27. package/loops/workflows/agent-release.md +258 -0
  28. package/loops/workflows/agent-triage.md +447 -439
  29. package/loops/workflows/authorize-bot-work.yml +85 -82
  30. package/loops/workflows/shared/opencode-ci.md +206 -197
  31. package/loops/workflows/shared/platform-defaults.md +19 -16
  32. package/loops/workflows/work-router.yml +862 -632
  33. package/package.json +7 -8
  34. package/dist/action-validation.test.d.ts +0 -1
  35. package/dist/action-validation.test.js +0 -84
  36. package/dist/catalog-installation.d.ts +0 -30
  37. package/dist/catalog-installation.js +0 -379
  38. package/dist/catalog-installation.test.d.ts +0 -1
  39. package/dist/catalog-installation.test.js +0 -448
  40. package/dist/catalog-listing.d.ts +0 -13
  41. package/dist/catalog-listing.js +0 -70
  42. package/dist/catalog-listing.test.d.ts +0 -1
  43. package/dist/catalog-listing.test.js +0 -150
  44. package/dist/index.d.ts +0 -2
  45. package/dist/index.js +0 -218
  46. package/dist/index.test.d.ts +0 -1
  47. package/dist/index.test.js +0 -273
  48. package/dist/repository-inspection.d.ts +0 -23
  49. package/dist/repository-inspection.js +0 -113
  50. package/dist/repository-inspection.test.d.ts +0 -1
  51. package/dist/repository-inspection.test.js +0 -77
  52. package/dist/repository-state.d.ts +0 -18
  53. package/dist/repository-state.js +0 -77
  54. package/dist/repository-state.test.d.ts +0 -1
  55. package/dist/repository-state.test.js +0 -96
  56. package/dist/route-processing.d.ts +0 -6
  57. package/dist/route-processing.js +0 -150
  58. package/dist/route-processing.test.d.ts +0 -1
  59. package/dist/route-processing.test.js +0 -350
  60. package/dist/stack-defaults.d.ts +0 -11
  61. package/dist/stack-defaults.js +0 -104
  62. package/dist/stack-defaults.test.d.ts +0 -1
  63. package/dist/stack-defaults.test.js +0 -266
  64. package/dist/tui.d.ts +0 -25
  65. package/dist/tui.js +0 -287
  66. package/dist/tui.test.d.ts +0 -1
  67. package/dist/tui.test.js +0 -246
  68. package/dist/workflow-catalog.d.ts +0 -25
  69. package/dist/workflow-catalog.js +0 -59
  70. package/dist/workflow-catalog.test.d.ts +0 -1
  71. package/dist/workflow-catalog.test.js +0 -29
  72. package/loops/workflows/agent-direct.md +0 -375
  73. package/loops/workflows/agent-propose.md +0 -342
@@ -1,632 +1,862 @@
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. Enabled so a FAILED CI run dispatches the Merge Gate
95
+ # immediately instead of waiting for the hourly reconcile. The classifier admits only
96
+ # conclusion == failure with an attached pull request; a green run still reaches merge
97
+ # through app-ci's dispatch-merge-gate and the reconcile belt, so routing success here
98
+ # would double-fire the gate on every passing PR.
99
+ #
100
+ # Caveat that still holds (2026-08-28): GitHub does not deliver workflow_run for CI runs
101
+ # whose originating event came from GITHUB_TOKEN, so a bot-opened PR's own CI completion
102
+ # may never arrive here — reconcile-bot-pr-runs remains the belt for that gap. Security:
103
+ # workflow_run runs from the default branch with secrets, but the gate's subject job
104
+ # rejects any PR that is not open and bot-authored, so a fork PR's failing CI cannot
105
+ # start privileged work.
106
+ workflow_run:
107
+ workflows: ["App: CI"]
108
+ types: [completed]
109
+
110
+ # Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
111
+ schedule:
112
+ # Slot 0. Every consumer installs this same file, and the agent fleet is one shared,
113
+ # two-VM scale set, so a second repository left on slot 0 audits at the same minute as
114
+ # the first. Pick a free slot per repository: see "Scheduling across repositories" in the
115
+ # package README, and change AUDIT_CRON in classify-route.sh to match, or the run fires
116
+ # and classifies to no route.
117
+ - cron: "17 1 * * 1"
118
+ - cron: "43 3 * * *"
119
+ - cron: "0 6 * * *"
120
+ - cron: "17 * * * *"
121
+ - cron: "29 7 * * *"
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
+ trigger-kind:
169
+ description: "Audit trigger: scheduled or manual"
170
+ required: false
171
+ type: choice
172
+ options:
173
+ - manual
174
+ - scheduled
175
+ version-bump:
176
+ description: "Release version bump: patch, minor, major, or auto"
177
+ required: false
178
+ type: choice
179
+ options:
180
+ - auto
181
+ - patch
182
+ - minor
183
+ - major
184
+
185
+ permissions:
186
+ contents: read
187
+
188
+ jobs:
189
+ # Public repositories must check repository permission before an event can start a model.
190
+ authorize:
191
+ runs-on: ubuntu-latest
192
+ timeout-minutes: 5
193
+ permissions:
194
+ contents: read
195
+ outputs:
196
+ trusted: ${{ steps.check.outputs.trusted }}
197
+ is_outside_collaborator: ${{ steps.check.outputs.is_outside_collaborator }}
198
+ steps:
199
+ - name: Check the actor can start an agent
200
+ id: check
201
+ env:
202
+ GH_TOKEN: ${{ github.token }}
203
+ ACTOR: ${{ github.actor }}
204
+ EVENT: ${{ github.event_name }}
205
+ run: |
206
+ set -euo pipefail
207
+
208
+ # Events with no human actor. `schedule` and `workflow_run` are the fleet triggering
209
+ # itself; `workflow_dispatch` is already restricted by GitHub to write and above.
210
+ case "$EVENT" in
211
+ schedule | workflow_run | workflow_dispatch)
212
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
213
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
214
+ echo "::notice::$EVENT has no untrusted actor."
215
+ exit 0
216
+ ;;
217
+ esac
218
+
219
+ # Platform bot owns Safe Outputs. Its label changes must be able to
220
+ # start the follow-on worker, even though GitHub does not report it
221
+ # as a repository collaborator.
222
+ if [ "$ACTOR" = "github-actions[bot]" ] || [ "$ACTOR" = "platform-devbox[bot]" ]; then
223
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
224
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
225
+ exit 0
226
+ fi
227
+
228
+ permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
229
+ --jq '.permission' 2>/dev/null || echo "none")
230
+
231
+ # Check if the actor is an organization member. Outside collaborators can have
232
+ # write/maintain permission on a repo without being org members. Triage is for
233
+ # outside collaborators only — a non-member with write access is still outside.
234
+ org=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
235
+ is_member=false
236
+ if gh api "orgs/$org/members/$ACTOR" >/dev/null 2>&1; then
237
+ is_member=true
238
+ fi
239
+
240
+ case "$permission" in
241
+ admin | maintain | write)
242
+ if [ "$is_member" = "true" ]; then
243
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
244
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
245
+ echo "::notice::$ACTOR has $permission permission and is an org member."
246
+ else
247
+ echo "trusted=true" >> "$GITHUB_OUTPUT"
248
+ echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
249
+ echo "::notice::$ACTOR has $permission permission but is NOT an org member (outside collaborator). Triage route eligible."
250
+ fi
251
+ ;;
252
+ read)
253
+ echo "trusted=false" >> "$GITHUB_OUTPUT"
254
+ echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
255
+ echo "::notice::$ACTOR is an outside collaborator (read permission). Triage route eligible."
256
+ ;;
257
+ *)
258
+ echo "trusted=false" >> "$GITHUB_OUTPUT"
259
+ echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
260
+ echo "::notice::$ACTOR has '$permission' permission on this repository. No agent will run."
261
+ ;;
262
+ esac
263
+
264
+ classify:
265
+ runs-on: ubuntu-latest
266
+ timeout-minutes: 5
267
+ permissions:
268
+ contents: read
269
+ pull-requests: read
270
+ outputs:
271
+ route: ${{ steps.route.outputs.route }}
272
+ issue-number: ${{ steps.route.outputs.issue-number }}
273
+ pr-number: ${{ steps.route.outputs.pr-number }}
274
+ linked-issue: ${{ steps.route.outputs.linked-issue }}
275
+ ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
276
+ ci-run-id: ${{ steps.route.outputs.ci-run-id }}
277
+ refine-mode: ${{ steps.route.outputs.refine-mode }}
278
+ triage-mode: ${{ steps.route.outputs.triage-mode }}
279
+ trigger-kind: ${{ steps.route.outputs.trigger-kind }}
280
+ steps:
281
+ - name: Checkout workflow actions
282
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
283
+ with:
284
+ persist-credentials: false
285
+ - name: Classify event into a single route
286
+ id: route
287
+ uses: ./.github/actions/classify-route
288
+ with:
289
+ token: ${{ github.token }}
290
+
291
+ call-refine:
292
+ needs: [classify, authorize]
293
+ if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
294
+ uses: ./.github/workflows/agent-refine.lock.yml
295
+ concurrency:
296
+ group: refine-${{ needs.classify.outputs.issue-number }}
297
+ cancel-in-progress: false
298
+ # write-all, not a narrower map: every worker's agent job declares `permissions: read-all`,
299
+ # which requests read on every scope. A caller granting less cannot satisfy it, and GitHub
300
+ # rejects the call with a startup failure before any job is created. The real boundary is the
301
+ # worker's own read-all plus safe-outputs, not this grant.
302
+ permissions: write-all
303
+ with:
304
+ issue-number: ${{ needs.classify.outputs.issue-number }}
305
+ mode: ${{ needs.classify.outputs.refine-mode }}
306
+ secrets:
307
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
308
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
309
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
310
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
311
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
312
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
313
+
314
+ # Checks whether an open bot PR already exists for the issue. If it does, implement is
315
+ # skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
316
+ # PRs when retries are triggered after a merge-gate failure.
317
+ check-implement-pr:
318
+ needs: [classify, authorize]
319
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
320
+ runs-on: ubuntu-latest
321
+ timeout-minutes: 3
322
+ permissions:
323
+ contents: read
324
+ pull-requests: read
325
+ outputs:
326
+ has-open-pr: ${{ steps.check.outputs.has-open-pr }}
327
+ steps:
328
+ - name: Check for existing open bot PR
329
+ id: check
330
+ env:
331
+ GH_TOKEN: ${{ github.token }}
332
+ REPO: ${{ github.repository }}
333
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
334
+ run: |
335
+ set -euo pipefail
336
+ # A refine-to-implement transition emits two label events (the implement label,
337
+ # then the worker re-adding bot-working) and each one classifies to implement.
338
+ # The older router run wins: a younger run that finds an older live implement
339
+ # route for the same issue skips instead of queueing a duplicate worker. Only
340
+ # bot-actor titles are counted, so a human's "waiting for bot-working" run can
341
+ # never block the real one.
342
+ for st in in_progress queued; do
343
+ dupes=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$st&per_page=100" --jq "[.workflow_runs[]
344
+ | select(.id < $GITHUB_RUN_ID)
345
+ | select(
346
+ ((.display_title | startswith(\"Working (Implement)\")) and (.display_title | contains(\"(#${ISSUE_NUMBER})\")))
347
+ or
348
+ ((.display_title | contains(\"implement label on #${ISSUE_NUMBER} by\")) and (.display_title | endswith(\"[bot]\")))
349
+ )
350
+ ] | length" 2>/dev/null || echo 0)
351
+ if [ "${dupes:-0}" -gt 0 ]; then
352
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
353
+ echo "::notice::An older router run is already implementing #$ISSUE_NUMBER; skipping the duplicate."
354
+ exit 0
355
+ fi
356
+ done
357
+
358
+ # Marker-first: implement stamps <!-- implement-pr: N --> on the issue when its
359
+ # pull request is created, so the duplicate check is an exact lookup. Only an
360
+ # OPEN marker PR blocks; a merged or closed one means a re-implement is legitimate.
361
+ marker_pr=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' \
362
+ | grep -oE '<!-- implement-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
363
+ if [ -n "$marker_pr" ]; then
364
+ state=$(gh pr view "$marker_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo GONE)
365
+ if [ "$state" = "OPEN" ]; then
366
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
367
+ echo "::notice::Issue #$ISSUE_NUMBER is marked as implemented by open PR #$marker_pr. Skipping duplicate implement."
368
+ exit 0
369
+ fi
370
+ echo "Marker PR #$marker_pr is $state; a fresh implement is allowed."
371
+ fi
372
+
373
+ existing=$(gh pr list --repo "$REPO" --state open --json number,author,body \
374
+ --jq "[.[]
375
+ | select(.author.login | startswith(\"app/\") or endswith(\"[bot]\"))
376
+ | (.body | ascii_downcase) as \$body
377
+ | 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}\"))
378
+ ] | length")
379
+ if [ "$existing" -gt 0 ]; then
380
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
381
+ echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
382
+ else
383
+ echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
384
+ fi
385
+
386
+ call-implement:
387
+ needs: [classify, authorize, check-implement-pr]
388
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
389
+ uses: ./.github/workflows/agent-implement.lock.yml
390
+ concurrency:
391
+ group: implement-global
392
+ cancel-in-progress: false
393
+ # FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
394
+ # was pending, so labelling six issues at once implements one and drops the rest.
395
+ queue: max
396
+ permissions: write-all
397
+ with:
398
+ issue-number: ${{ needs.classify.outputs.issue-number }}
399
+ secrets:
400
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
401
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
402
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
403
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
404
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
405
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
406
+
407
+ dispatch-triage:
408
+ needs: [classify, authorize]
409
+ if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
410
+ runs-on: ubuntu-latest
411
+ timeout-minutes: 5
412
+ permissions:
413
+ actions: write
414
+ contents: read
415
+ steps:
416
+ - name: Create bot token
417
+ id: app-token
418
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
419
+ with:
420
+ client-id: ${{ secrets.BOT_APP_ID }}
421
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
422
+ - name: Dispatch trusted triage run
423
+ env:
424
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
425
+ REPO: ${{ github.repository }}
426
+ REF: ${{ github.event.repository.default_branch }}
427
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
428
+ MODE: ${{ needs.classify.outputs.triage-mode }}
429
+ run: |
430
+ set -euo pipefail
431
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
432
+ -f operation=triage \
433
+ -f issue-number="$ISSUE_NUMBER" \
434
+ -f mode="$MODE"
435
+
436
+ call-triage:
437
+ needs: [classify, authorize]
438
+ # Triage on issue open is for outside collaborators only (handled by dispatch-triage).
439
+ # A trusted actor who opens an issue should not start triage — they can add refine/implement/direct
440
+ # labels or manually dispatch triage. All other triage routes (labeled, comment, dispatch) are trusted.
441
+ if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true' && !(github.event_name == 'issues' && github.event.action == 'opened')
442
+ uses: ./.github/workflows/agent-triage.lock.yml
443
+ concurrency:
444
+ group: triage-${{ needs.classify.outputs.issue-number }}
445
+ cancel-in-progress: false
446
+ permissions: write-all
447
+ with:
448
+ issue-number: ${{ needs.classify.outputs.issue-number }}
449
+ mode: ${{ needs.classify.outputs.triage-mode }}
450
+ secrets:
451
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
452
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
453
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
454
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
455
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
456
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
457
+
458
+ call-apply-review:
459
+ needs: [classify, authorize]
460
+ if: needs.classify.outputs.route == 'apply-review' && needs.authorize.outputs.trusted == 'true'
461
+ uses: ./.github/workflows/agent-apply-review.lock.yml
462
+ concurrency:
463
+ group: pr-feedback-${{ needs.classify.outputs.pr-number }}
464
+ cancel-in-progress: false
465
+ permissions: write-all
466
+ with:
467
+ pr-number: ${{ needs.classify.outputs.pr-number }}
468
+ linked-issue: ${{ needs.classify.outputs.linked-issue }}
469
+ secrets:
470
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
471
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
472
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
473
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
474
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
475
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
476
+
477
+ # Shares the write-pipeline group with implement so the two never race on one issue.
478
+ # Falls back to the pull request number only when the closing issue cannot be resolved.
479
+ call-merge-gate:
480
+ needs: classify
481
+ if: needs.classify.outputs.route == 'merge-gate'
482
+ uses: ./.github/workflows/agent-merge-gate.lock.yml
483
+ concurrency:
484
+ group: write-pipeline-${{ needs.classify.outputs.linked-issue || format('pr-{0}', needs.classify.outputs.pr-number) }}
485
+ cancel-in-progress: false
486
+ permissions: write-all
487
+ with:
488
+ pr-number: ${{ needs.classify.outputs.pr-number }}
489
+ linked-issue: ${{ needs.classify.outputs.linked-issue }}
490
+ ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
491
+ ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
492
+ secrets:
493
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
494
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
495
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
496
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
497
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
498
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
499
+
500
+ call-audit:
501
+ needs: classify
502
+ if: needs.classify.outputs.route == 'audit'
503
+ uses: ./.github/workflows/agent-audit.lock.yml
504
+ concurrency:
505
+ group: audit
506
+ cancel-in-progress: false
507
+ permissions: write-all
508
+ with:
509
+ trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
510
+ secrets:
511
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
512
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
513
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
514
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
515
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
516
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
517
+
518
+ call-release:
519
+ needs: [classify, authorize]
520
+ if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
521
+ uses: ./.github/workflows/agent-release.lock.yml
522
+ permissions: write-all
523
+ with:
524
+ version-bump: ${{ github.event.inputs.version-bump || 'auto' }}
525
+ secrets:
526
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
527
+
528
+ bot-approve:
529
+ needs: classify
530
+ if: >
531
+ needs.classify.outputs.route == 'bot-approve' &&
532
+ (github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
533
+ runs-on: ubuntu-latest
534
+ timeout-minutes: 5
535
+ permissions:
536
+ actions: write
537
+ steps:
538
+ - name: Approve pending workflow runs
539
+ env:
540
+ GH_TOKEN: ${{ github.token }}
541
+ REPO: ${{ github.repository }}
542
+ BRANCH: ${{ github.head_ref }}
543
+ run: |
544
+ set -euo pipefail
545
+
546
+ mapfile -t run_ids < <(
547
+ gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
548
+ --jq '.workflow_runs[].id'
549
+ )
550
+
551
+ if [ "${#run_ids[@]}" -eq 0 ]; then
552
+ echo "No runs awaiting approval on branch $BRANCH"
553
+ exit 0
554
+ fi
555
+
556
+ for run_id in "${run_ids[@]}"; do
557
+ echo "Approving run $run_id"
558
+ gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
559
+ done
560
+
561
+ # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
562
+ # mergeability API, which the pure classifier cannot read, so this is an event-gated
563
+ # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
564
+ # the same pull_request_target event). It NEVER checks out or runs pull request code — it
565
+ # reads mergeability through the API and dispatches the Merge Gate — so it is safe on
566
+ # pull_request_target. Two entry points:
567
+ # opened / reopened / synchronize → check the pull request that changed. Gated to this
568
+ # repository's own branches so a fork PR never starts this App-token job.
569
+ # closed & merged → the target branch moved, so re-check every open bot
570
+ # pull request that may have just become conflicted (the "main changed after another PR
571
+ # merged" case). A merge into the default branch is an inherently trusted internal event.
572
+ # It dispatches only for bot-authored, open, non-draft pull requests whose head is this
573
+ # repository AND whose CI has already concluded (dispatching while CI is still pending would
574
+ # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
575
+ # covered by the CI-completion route instead). The gate's subject job re-validates ownership
576
+ # before any agent runs.
577
+ detect-pr-conflicts:
578
+ needs: classify
579
+ if: >
580
+ github.event_name == 'pull_request_target' &&
581
+ (
582
+ (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
583
+ (
584
+ (github.event.action == 'opened' ||
585
+ github.event.action == 'reopened' ||
586
+ github.event.action == 'synchronize') &&
587
+ github.event.pull_request.head.repo.full_name == github.repository
588
+ )
589
+ )
590
+ runs-on: ubuntu-latest
591
+ timeout-minutes: 10
592
+ permissions:
593
+ contents: read
594
+ pull-requests: read
595
+ actions: write
596
+ steps:
597
+ - name: Checkout for App token action
598
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
599
+ with:
600
+ persist-credentials: false
601
+ - name: Generate App token
602
+ id: app-token
603
+ uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
604
+ with:
605
+ app-id: ${{ secrets.BOT_APP_ID }}
606
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
607
+ - name: Dispatch Merge Gate for conflicting bot pull requests
608
+ env:
609
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
610
+ REPO: ${{ github.repository }}
611
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
612
+ ACTION: ${{ github.event.action }}
613
+ EVENT_PR: ${{ github.event.pull_request.number }}
614
+ run: |
615
+ set -euo pipefail
616
+
617
+ # Dispatch the Merge Gate for one pull request when it is a conflicting, open,
618
+ # non-draft, bot-authored pull request on this repository whose CI has concluded.
619
+ maybe_dispatch() {
620
+ pr="$1"
621
+ [ -n "$pr" ] || return 0
622
+
623
+ meta=$(gh api "repos/$REPO/pulls/$pr" \
624
+ --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
625
+ 2>/dev/null || echo '')
626
+ [ -n "$meta" ] || return 0
627
+ [ "$(jq -r '.state' <<<"$meta")" = "open" ] || return 0
628
+ [ "$(jq -r '.draft' <<<"$meta")" = "false" ] || return 0
629
+ [ "$(jq -r '.bot' <<<"$meta")" = "Bot" ] || return 0
630
+ [ "$(jq -r '.head' <<<"$meta")" = "$REPO" ] || return 0
631
+
632
+ # GitHub computes mergeability asynchronously; poll until it is known (not null).
633
+ mergeable=""
634
+ for _ in 1 2 3 4 5 6; do
635
+ mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
636
+ [ "$mergeable" = "null" ] || break
637
+ sleep 5
638
+ done
639
+ # mergeable == false means a git-level conflict (mergeable_state "dirty").
640
+ if [ "$mergeable" != "false" ]; then
641
+ echo "PR #$pr mergeable=$mergeable; no conflict dispatch."
642
+ return 0
643
+ fi
644
+
645
+ # Only dispatch when CI has already concluded. A conflict while CI is still
646
+ # running is handled by the CI-completion route; dispatching now would make the
647
+ # gate read a 'pending' conclusion and prematurely ask for human review.
648
+ head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
649
+ ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
650
+ --jq '[.workflow_runs[]
651
+ | select(.name == "App: CI" and .status == "completed"
652
+ and (.conclusion == "success" or .conclusion == "failure"))]
653
+ | sort_by(.created_at) | last // empty')
654
+ if [ -z "$ci" ]; then
655
+ echo "PR #$pr conflicts but CI has not concluded; deferring to the CI-completion route."
656
+ return 0
657
+ fi
658
+ ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
659
+ ci_run_id=$(jq -r '.id' <<<"$ci")
660
+
661
+ echo "PR #$pr is conflicting (CI $ci_conclusion); dispatching Merge Gate."
662
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
663
+ --method POST \
664
+ -f ref="$DEFAULT_BRANCH" \
665
+ -f "inputs[operation]=merge-gate" \
666
+ -f "inputs[pr-number]=$pr" \
667
+ -f "inputs[ci-conclusion]=$ci_conclusion" \
668
+ -f "inputs[ci-run-id]=$ci_run_id"
669
+ }
670
+
671
+ if [ "$ACTION" = "closed" ]; then
672
+ # The target branch just moved. Any previously-mergeable bot PR may now conflict.
673
+ # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
674
+ gh api "repos/$REPO/pulls?state=open&per_page=100" \
675
+ --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
676
+ while IFS= read -r pr; do
677
+ maybe_dispatch "$pr"
678
+ done
679
+ else
680
+ maybe_dispatch "$EVENT_PR"
681
+ fi
682
+
683
+ reconcile-bot-pr-runs:
684
+ needs: classify
685
+ if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
686
+ runs-on: ubuntu-latest
687
+ timeout-minutes: 5
688
+ permissions:
689
+ contents: read
690
+ actions: write
691
+ pull-requests: read
692
+ # the stale reservation sweep clears bot-working from parked issues
693
+ issues: write
694
+ env:
695
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
696
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
697
+ steps:
698
+ - name: Checkout for App token action
699
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
700
+ with:
701
+ persist-credentials: false
702
+ - name: Generate App token
703
+ id: app-token
704
+ uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
705
+ with:
706
+ app-id: ${{ secrets.BOT_APP_ID }}
707
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
708
+ # Runs only the default-branch workflow and never checks out pull-request code.
709
+ # External users cannot impersonate github-actions[bot].
710
+ - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
711
+ env:
712
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
713
+ REPO: ${{ github.repository }}
714
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
715
+ run: |
716
+ set -euo pipefail
717
+
718
+ # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
719
+ # because authorize-bot-work is keyed per issue, while implement-global lets exactly
720
+ # one run at a time. A queued run that is cancelled therefore leaves its issue
721
+ # carrying bot-working with no run and no pull request: parked, invisible, and not
722
+ # re-triggerable, because classify refuses an issue that is already reserved.
723
+ gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
724
+ --json number --jq '.[].number' |
725
+ while IFS= read -r issue; do
726
+ [ -n "$issue" ] || continue
727
+
728
+ live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
729
+ --jq "[.workflow_runs[]
730
+ | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
731
+ | select(.display_title | test(\"#${issue}\\b\"))] | length")
732
+ [ "${live:-0}" -eq 0 ] || continue
733
+
734
+ has_pr=$(gh pr list --repo "$REPO" --state open --json body \
735
+ --jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${issue}\\b|fix(es|ed)? #${issue}\\b|resolves? #${issue}\\b\"))] | length")
736
+ [ "${has_pr:-0}" -eq 0 ] || continue
737
+
738
+ echo "Clearing a stale bot-working reservation on #$issue: no run, no pull request."
739
+ gh issue edit "$issue" --repo "$REPO" --remove-label bot-working
740
+ done
741
+
742
+ gh api "repos/$REPO/pulls?state=open&per_page=100" |
743
+ jq -r --arg repo "$REPO" '
744
+ .[]
745
+ | select(
746
+ (.user.login == "github-actions[bot]" or .user.login == "app/github-actions" or .user.login == "platform-devbox[bot]")
747
+ and .draft == false
748
+ and .head.repo.full_name == $repo
749
+ )
750
+ | [.number, .head.ref, .head.sha] | @tsv
751
+ ' |
752
+ while IFS=$'\t' read -r pr_number branch head_sha; do
753
+ gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
754
+ --jq '.workflow_runs[]
755
+ | select(
756
+ .event == "pull_request"
757
+ and .name == "App: CI"
758
+ )
759
+ | .id' |
760
+ while IFS= read -r run_id; do
761
+ echo "Approving trusted bot run $run_id"
762
+ gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
763
+ done
764
+
765
+ ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
766
+ --jq '[.workflow_runs[]
767
+ | select(
768
+ .name == "App: CI"
769
+ and .event == "pull_request"
770
+ and .status == "completed"
771
+ and (.conclusion == "success" or .conclusion == "failure")
772
+ )
773
+ ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
774
+ if [ -z "$ci" ]; then
775
+ continue
776
+ fi
777
+
778
+ IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
779
+
780
+ # A recorded verdict only counts when it is NEWER than the latest CI run.
781
+ # The old existence check made the gate one-shot per PR: after any verdict
782
+ # (say remediated), a later CI completion could never reopen the gate, so
783
+ # the fix-CI loop starved. ISO-8601 timestamps compare lexicographically.
784
+ latest_marker=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
785
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate -->")) | .created_at] | max // empty')
786
+ linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
787
+ if [ -n "$linked_issue" ]; then
788
+ issue_marker=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
789
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate -->")) | .created_at] | max // empty')
790
+ if [ -n "$issue_marker" ] && { [ -z "$latest_marker" ] || [ "$issue_marker" \> "$latest_marker" ]; }; then
791
+ latest_marker="$issue_marker"
792
+ fi
793
+ fi
794
+ if [ -n "$latest_marker" ] && [ "$latest_marker" \> "$ci_finished" ]; then
795
+ echo "Merge gate verdict ($latest_marker) is newer than CI ($ci_finished) for PR #$pr_number, skipping"
796
+ continue
797
+ fi
798
+ echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id)"
799
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
800
+ --method POST \
801
+ -f ref="$DEFAULT_BRANCH" \
802
+ -f "inputs[operation]=merge-gate" \
803
+ -f "inputs[pr-number]=$pr_number" \
804
+ -f "inputs[ci-conclusion]=$ci_conclusion" \
805
+ -f "inputs[ci-run-id]=$ci_run_id"
806
+ done
807
+
808
+ audit-close:
809
+ needs: classify
810
+ if: needs.classify.outputs.route == 'audit-close'
811
+ runs-on: ubuntu-latest
812
+ timeout-minutes: 15
813
+ concurrency:
814
+ group: audit-close
815
+ cancel-in-progress: false
816
+ permissions:
817
+ contents: read
818
+ issues: write
819
+ steps:
820
+ - name: Checkout workflow actions
821
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
822
+ with:
823
+ persist-credentials: false
824
+ - uses: ./.github/actions/audit-close
825
+ with:
826
+ token: ${{ github.token }}
827
+
828
+ cleanup-artifacts:
829
+ needs: classify
830
+ if: needs.classify.outputs.route == 'cleanup-artifacts' && !github.event.repository.fork
831
+ runs-on: ubuntu-latest
832
+ timeout-minutes: 15
833
+ concurrency:
834
+ group: cleanup-artifacts
835
+ cancel-in-progress: false
836
+ permissions:
837
+ contents: read
838
+ actions: write
839
+ steps:
840
+ - name: Checkout workflow actions
841
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
842
+ with:
843
+ persist-credentials: false
844
+ - uses: ./.github/actions/cleanup-artifacts
845
+ with:
846
+ token: ${{ github.token }}
847
+ artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
848
+
849
+ validate:
850
+ needs: classify
851
+ if: needs.classify.outputs.route == 'validate'
852
+ runs-on: ubuntu-latest
853
+ timeout-minutes: 10
854
+ permissions:
855
+ contents: read
856
+ steps:
857
+ - name: Checkout workflow actions
858
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
859
+ with:
860
+ persist-credentials: false
861
+ - uses: ./.github/actions/verify-route-matrix
862
+ - uses: ./.github/actions/verify-composite-actions