@plainconceptsplatform/workflows 0.4.33 → 0.5.1

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