@plainconceptsplatform/workflows 0.5.1 → 0.16.0

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