@plainconceptsplatform/workflows 0.6.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +107 -88
  2. package/dist/catalog-installation.d.ts +39 -2
  3. package/dist/catalog-installation.js +172 -109
  4. package/dist/index.js +128 -73
  5. package/dist/package-baseline.d.ts +25 -0
  6. package/dist/package-baseline.js +138 -0
  7. package/dist/route-processing.d.ts +0 -2
  8. package/dist/route-processing.js +22 -91
  9. package/dist/stack-defaults.js +18 -12
  10. package/dist/tui.js +27 -43
  11. package/dist/worker-env.d.ts +46 -0
  12. package/dist/worker-env.js +179 -0
  13. package/dist/workflow-catalog.d.ts +4 -2
  14. package/dist/workflow-catalog.js +5 -3
  15. package/loops/actions/add-issue-labels/action.yml +20 -0
  16. package/loops/actions/audit-close/action.yml +180 -128
  17. package/loops/actions/classify-route/classify-route.sh +8 -2
  18. package/loops/actions/housekeeping/action.yml +251 -0
  19. package/loops/actions/merge-agent-pr/action.yml +13 -0
  20. package/loops/actions/report-workflow-errors/action.yml +414 -0
  21. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  22. package/loops/actions/validate-triage-output/action.yml +1 -1
  23. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  24. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  25. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +911 -170
  26. package/loops/templates/agentics/agentics-error-report.yml +98 -0
  27. package/loops/templates/opencode/opencode.ci.json +1 -1
  28. package/loops/workflows/agent-apply-review.md +438 -469
  29. package/loops/workflows/agent-audit.md +200 -213
  30. package/loops/workflows/agent-implement.md +591 -640
  31. package/loops/workflows/agent-merge-gate.md +826 -844
  32. package/loops/workflows/agent-refine.md +590 -633
  33. package/loops/workflows/agent-release.md +244 -258
  34. package/loops/workflows/agent-triage.md +476 -447
  35. package/loops/workflows/authorize-bot-work.yml +26 -6
  36. package/loops/workflows/shared/platform-defaults.md +18 -1
  37. package/loops/workflows/work-router.yml +1185 -1038
  38. package/package.json +9 -8
  39. package/dist/action-validation.test.d.ts +0 -1
  40. package/dist/action-validation.test.js +0 -87
  41. package/dist/catalog-installation.test.d.ts +0 -1
  42. package/dist/catalog-installation.test.js +0 -485
  43. package/dist/catalog-listing.test.d.ts +0 -1
  44. package/dist/catalog-listing.test.js +0 -150
  45. package/dist/index.test.d.ts +0 -1
  46. package/dist/index.test.js +0 -273
  47. package/dist/repository-inspection.test.d.ts +0 -1
  48. package/dist/repository-inspection.test.js +0 -77
  49. package/dist/route-processing.test.d.ts +0 -1
  50. package/dist/route-processing.test.js +0 -283
  51. package/dist/stack-defaults.test.d.ts +0 -1
  52. package/dist/stack-defaults.test.js +0 -266
  53. package/dist/tui.test.d.ts +0 -1
  54. package/dist/tui.test.js +0 -249
  55. package/dist/workflow-catalog.test.d.ts +0 -1
  56. package/dist/workflow-catalog.test.js +0 -29
  57. package/loops/actions/stale-recovery/action.yml +0 -288
  58. package/loops/actions/update-changelog/action.yml +0 -113
@@ -1,844 +1,826 @@
1
- ---
2
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-merge-gate.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
- env:
4
- VERIFY_COMMANDS: ""
5
- REPO_RULES: "Make a risk-based merge decision for the selected bot pull request. Merge only when CI is green and no risk indicators are present. Review risk indicators defined in the repository's guardrails or project documentation. Any of these require human review. Do not merge protected file changes."
6
- WORKING_LABEL: bot-working
7
- IMPLEMENT_LABEL: implement
8
- REVIEW_LABEL: review
9
- PR_PENDING_LABEL: pr-pending
10
- GATE_MARKER: "<!-- agent-merge-gate -->"
11
- ATTEMPT_MARKER: "<!-- agent-merge-gate-attempt -->"
12
- MAX_ATTEMPTS: "6"
13
- PARK_AT_ATTEMPT: "5"
14
- INCOMPLETE_COMMENT: "Automated CI failure remediation ended without an outcome. The issue remains for a retry."
15
- ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
16
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
17
- GIT_AUTHOR_NAME: "github-actions[bot]"
18
- GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
19
- GIT_COMMITTER_NAME: "github-actions[bot]"
20
- GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com"
21
- description: |
22
- Decides what happens to a bot-authored pull request once CI has reported: merge when the
23
- risk assessment is clean, hand to a human when it is not, fix CI when it failed. Called by
24
- the Work Router; does not trigger on public events.
25
-
26
- The router supplies the CI conclusion and run ID as facts, so there is no polling and no
27
- timeout branch. The conclusion is read from the inputs instead of filtered at the trigger.
28
-
29
- name: "Agent: Merge Gate"
30
-
31
- # Router-only worker. The Work Router owns triggers, classification, and rung 1-2 checks.
32
- # This workflow receives the classified inputs and runs rung 3+.
33
- imports:
34
- - github/gh-aw/.github/workflows/shared/opencode.md@v0.87.5
35
- - shared/platform-defaults.md
36
- - shared/opencode-ci.md
37
-
38
- on:
39
- workflow_call:
40
- inputs:
41
- pr-number:
42
- description: Pull request number to gate.
43
- required: true
44
- type: string
45
- linked-issue:
46
- description: Issue number the pull request closes. May be empty.
47
- required: false
48
- type: string
49
- ci-conclusion:
50
- description: CI conclusion (success, failure, action_required, cancelled, etc.).
51
- required: true
52
- type: string
53
- ci-run-id:
54
- description: CI workflow run ID for fetching failing logs.
55
- required: false
56
- type: string
57
- attempts_so_far:
58
- description: Failed gate attempts already made against this CI verdict. Parked when it reaches the cap.
59
- required: false
60
- type: string
61
- default: '0'
62
- # gh-aw folds the top-level `if:` below into the generated activation job but does not carry
63
- # the jobs that `if:` reads into activation's `needs`: only prompt-referenced custom jobs with
64
- # no `needs:` of their own are hoisted (subject). protected_changes needs subject, so without
65
- # this entry activation read needs.protected_changes.outputs.requires_review before
66
- # protected_changes had started; the value was '' and the clause was always true
67
- # (Pliny-Bot run 34042143350: activation finished 18 s before protected_changes began).
68
- needs: [protected_changes]
69
-
70
- # Rung 4. Router has classified the event; identify-gate-subject validates PR ownership,
71
- # resolves the closing issue, and confirms the CI verdict.
72
- # A custom job, not `on.steps`, because the prompt and the precompute step need these
73
- # values and `on.steps` outputs do not reach the agent job.
74
- jobs:
75
- subject:
76
- runs-on: agents-arc
77
- permissions:
78
- contents: read
79
- issues: read
80
- pull-requests: read
81
- actions: read
82
- outputs:
83
- found: ${{ steps.subject.outputs.found }}
84
- pr: ${{ steps.subject.outputs.pr }}
85
- issue: ${{ steps.subject.outputs.issue }}
86
- conclusion: ${{ steps.subject.outputs.conclusion }}
87
- run-id: ${{ steps.subject.outputs.run-id }}
88
- review_blocked: ${{ steps.review.outputs.review_blocked }}
89
- steps:
90
- - name: Checkout workflow actions
91
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
92
- with:
93
- persist-credentials: false
94
- - name: Identify the pull request, its issue, and the CI verdict
95
- id: subject
96
- uses: ./.github/actions/identify-gate-subject
97
- with:
98
- token: ${{ github.token }}
99
- pr-number: ${{ inputs.pr-number }}
100
- ci-conclusion: ${{ inputs.ci-conclusion }}
101
- linked-issue: ${{ inputs.linked-issue }}
102
- require-label: ${{ env.IMPLEMENT_LABEL }}
103
- - name: Block a pull request with requested changes
104
- id: review
105
- env:
106
- GH_TOKEN: ${{ github.token }}
107
- REPO: ${{ github.repository }}
108
- PR: ${{ steps.subject.outputs.pr }}
109
- run: |
110
- set -euo pipefail
111
- # A transient API failure must not kill the gate (the reconcile cron can push the
112
- # token into secondary rate limits). Three tries, then default to not blocked; the
113
- # agent re-reads the review state itself before any merge.
114
- decision=""
115
- for _ in 1 2 3; do
116
- if decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision // ""'); then
117
- break
118
- fi
119
- sleep 5
120
- done
121
- echo "review_blocked=$([ "$decision" = 'CHANGES_REQUESTED' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
122
-
123
- protected_changes:
124
- needs: subject
125
- if: needs.subject.outputs.found == 'true'
126
- runs-on: agents-arc
127
- permissions:
128
- pull-requests: read
129
- outputs:
130
- requires_review: ${{ steps.files.outputs.requires_review }}
131
- files: ${{ steps.files.outputs.files }}
132
- steps:
133
- - name: Require review for protected pull request files
134
- id: files
135
- env:
136
- GH_TOKEN: ${{ github.token }}
137
- REPO: ${{ github.repository }}
138
- PR: ${{ needs.subject.outputs.pr }}
139
- run: |
140
- set -euo pipefail
141
- files=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename')
142
- protected=$(printf '%s\n' "$files" | grep -E '^(\.|AGENTS\.md$|ARCHITECTURE\.md$|opencode\.jsonc$|package\.json$|pnpm-lock\.yaml$|Directory\.Packages\.props$|global\.json$)' || true)
143
-
144
- if [ -n "$protected" ]; then
145
- echo "requires_review=true" >> "$GITHUB_OUTPUT"
146
- {
147
- echo 'files<<EOF'
148
- printf '%s\n' "$protected"
149
- echo EOF
150
- } >> "$GITHUB_OUTPUT"
151
- else
152
- echo "requires_review=false" >> "$GITHUB_OUTPUT"
153
- echo "files=" >> "$GITHUB_OUTPUT"
154
- fi
155
-
156
- review_required:
157
- needs: [subject, protected_changes]
158
- # gh-aw makes the agent depend on custom jobs. Keep this job successful when
159
- # there are no protected files instead of skipping it and blocking remediation.
160
- if: always() && needs.subject.outputs.found == 'true'
161
- runs-on: agents-arc
162
- permissions:
163
- contents: read
164
- issues: write
165
- steps:
166
- - name: Checkout workflow actions
167
- if: needs.protected_changes.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure'
168
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
169
- with:
170
- persist-credentials: false
171
- - name: Create bot token
172
- if: needs.protected_changes.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure'
173
- id: app-token
174
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
175
- with:
176
- client-id: ${{ secrets.BOT_APP_ID }}
177
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
178
- # Only the reservation comes off. pr-pending says a pull request for this issue is open
179
- # and waiting, which is still true when the gate hands it to a human, so taking it off
180
- # here left a board where three issues with three open pull requests looked like they
181
- # had none. The merge path is the one place the label stops being true.
182
- - name: Release the issue
183
- if: needs.protected_changes.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure'
184
- uses: ./.github/actions/remove-issue-labels
185
- with:
186
- token: ${{ steps.app-token.outputs.token }}
187
- issue-number: ${{ needs.subject.outputs.issue }}
188
- labels: ${{ env.WORKING_LABEL }}
189
- - name: Flag human review
190
- if: needs.protected_changes.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure'
191
- uses: ./.github/actions/add-issue-labels
192
- with:
193
- token: ${{ steps.app-token.outputs.token }}
194
- issue-number: ${{ needs.subject.outputs.issue }}
195
- labels: ${{ env.REVIEW_LABEL }}
196
- - name: Explain the merge hold
197
- if: needs.protected_changes.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure'
198
- uses: ./.github/actions/create-issue-comment
199
- with:
200
- token: ${{ steps.app-token.outputs.token }}
201
- issue-number: ${{ needs.subject.outputs.issue }}
202
- body: |
203
- ${{ env.GATE_MARKER }}
204
- PR #${{ needs.subject.outputs.pr }} changes protected files and cannot be auto-merged.
205
- The `review` label is set: a human must merge this PR manually.
206
-
207
- Protected files:
208
- ${{ needs.protected_changes.outputs.files }}
209
-
210
- **Verdict:** review
211
-
212
- reserve:
213
- needs: subject
214
- if: needs.subject.outputs.found == 'true'
215
- runs-on: agents-arc
216
- permissions:
217
- contents: read
218
- issues: write
219
- pull-requests: read
220
- outputs:
221
- has_conflicts: ${{ steps.conflicts.outputs.has_conflicts || 'false' }}
222
- steps:
223
- - name: Checkout workflow actions
224
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
225
- with:
226
- persist-credentials: false
227
- - name: Create bot token
228
- id: app-token
229
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
230
- with:
231
- client-id: ${{ secrets.BOT_APP_ID }}
232
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
233
- - name: Check for merge conflicts
234
- id: conflicts
235
- env:
236
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
237
- REPO: ${{ github.repository }}
238
- PR: ${{ needs.subject.outputs.pr }}
239
- run: |
240
- set -euo pipefail
241
- mergeable=$(gh pr view "$PR" --repo "$REPO" --json mergeable --jq '.mergeable')
242
- if [ "$mergeable" = "CONFLICTING" ]; then
243
- echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
244
- else
245
- echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
246
- fi
247
- # First attempt only. Retries are recorded by the incomplete job's attempt comment, and
248
- # every App comment is a router event: one issue collected sixteen of these in a day.
249
- - name: Comment on issue - problems found, solving them
250
- if: needs.subject.outputs.conclusion == 'failure' && (inputs.attempts_so_far || '0') == '0'
251
- uses: ./.github/actions/create-issue-comment
252
- with:
253
- token: ${{ steps.app-token.outputs.token }}
254
- issue-number: ${{ needs.subject.outputs.issue }}
255
- body: |
256
- Problems found in PR #${{ needs.subject.outputs.pr }}. ${{ steps.conflicts.outputs.has_conflicts == 'true' && 'Merge conflicts detected.' || 'CI failed.' }}
257
- Bot is working on fixing it.
258
- validate_output:
259
- needs: [activation, subject, agent, safe_outputs]
260
- if: >
261
- always() &&
262
- needs.agent.result == 'success' &&
263
- needs.safe_outputs.result == 'success'
264
- runs-on: agents-arc
265
- permissions:
266
- contents: read
267
- outputs:
268
- valid: ${{ steps.validate.outputs.valid }}
269
- outcome: ${{ steps.validate.outputs.outcome }}
270
- steps:
271
- - name: Checkout workflow actions
272
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
273
- with:
274
- persist-credentials: false
275
- - name: Download agent output
276
- id: output
277
- uses: ./.github/actions/download-agent-output
278
- with:
279
- artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
280
- - name: Validate merge-gate outcome
281
- id: validate
282
- uses: ./.github/actions/validate-merge-gate-output
283
- with:
284
- output-file: ${{ steps.output.outputs.output-file }}
285
- issue-number: ${{ needs.subject.outputs.issue }}
286
- ci-conclusion: ${{ needs.subject.outputs.conclusion }}
287
- conclude:
288
- needs: [activation, subject, protected_changes, agent, safe_outputs, validate_output]
289
- if: >
290
- needs.agent.result == 'success' &&
291
- needs.safe_outputs.result == 'success' &&
292
- needs.validate_output.outputs.valid == 'true' &&
293
- (needs.protected_changes.outputs.requires_review != 'true' || needs.validate_output.outputs.outcome != 'merge')
294
- runs-on: agents-arc
295
- permissions:
296
- contents: write
297
- issues: write
298
- pull-requests: write
299
- steps:
300
- - name: Create bot token
301
- id: app-token
302
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
303
- with:
304
- client-id: ${{ secrets.BOT_APP_ID }}
305
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
306
- - name: Checkout repository
307
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
308
- with:
309
- token: ${{ steps.app-token.outputs.token }}
310
- fetch-depth: 0
311
- - name: Verify pull request closes the source issue
312
- continue-on-error: true
313
- uses: ./.github/actions/link-pr-to-issue
314
- with:
315
- token: ${{ steps.app-token.outputs.token }}
316
- pr-number: ${{ needs.subject.outputs.pr }}
317
- issue-number: ${{ needs.subject.outputs.issue }}
318
- - name: Apply agent output
319
- uses: ./.github/actions/apply-agent-output
320
- with:
321
- artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
322
- token: ${{ steps.app-token.outputs.token }}
323
- push-to-branch: 'true'
324
- apply-labels: 'false'
325
- # GITHUB_TOKEN on purpose: an App-token comment on a pull request is an issue_comment
326
- # event, and GITHUB_TOKEN raises none. The full assessment lives on the issue, where the
327
- # lifecycle is; this is what a reviewer opening the pull request sees. Carrying the
328
- # marker and the Verdict line makes the router's verdict detection independent of
329
- # whether the model remembered the marker.
330
- - name: Show the verdict on the pull request
331
- uses: ./.github/actions/create-issue-comment
332
- with:
333
- token: ${{ github.token }}
334
- issue-number: ${{ needs.subject.outputs.pr }}
335
- body: |
336
- ${{ env.GATE_MARKER }}
337
- **Verdict:** ${{ needs.validate_output.outputs.outcome }} (CI concluded ${{ needs.subject.outputs.conclusion }}).
338
- Full assessment on the linked issue: #${{ needs.subject.outputs.issue }}. [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
339
- - name: Merge approved pull request
340
- if: needs.validate_output.outputs.outcome == 'merge'
341
- env:
342
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
343
- REPO: ${{ github.repository }}
344
- PR: ${{ needs.subject.outputs.pr }}
345
- run: |
346
- set -euo pipefail
347
- head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
348
- gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
349
- - name: Release remediated issue
350
- if: needs.validate_output.outputs.outcome == 'remediated'
351
- uses: ./.github/actions/remove-issue-labels
352
- with:
353
- token: ${{ steps.app-token.outputs.token }}
354
- issue-number: ${{ needs.subject.outputs.issue }}
355
- labels: ${{ env.WORKING_LABEL }}
356
- - name: Flag review outcome
357
- if: needs.validate_output.outputs.outcome == 'review'
358
- uses: ./.github/actions/add-issue-labels
359
- with:
360
- token: ${{ steps.app-token.outputs.token }}
361
- issue-number: ${{ needs.subject.outputs.issue }}
362
- labels: ${{ env.REVIEW_LABEL }}
363
- # The reservation only. The pull request is still open and still waiting, so pr-pending
364
- # stays until the merge path below retires it.
365
- - name: Release review outcome
366
- if: needs.validate_output.outputs.outcome == 'review'
367
- uses: ./.github/actions/remove-issue-labels
368
- with:
369
- token: ${{ steps.app-token.outputs.token }}
370
- issue-number: ${{ needs.subject.outputs.issue }}
371
- labels: ${{ env.WORKING_LABEL }}
372
- - name: Clear merged issue labels
373
- if: needs.validate_output.outputs.outcome == 'merge'
374
- uses: ./.github/actions/remove-issue-labels
375
- with:
376
- token: ${{ steps.app-token.outputs.token }}
377
- issue-number: ${{ needs.subject.outputs.issue }}
378
- labels: |
379
- ${{ env.IMPLEMENT_LABEL }}
380
- ${{ env.WORKING_LABEL }}
381
- ${{ env.REVIEW_LABEL }}
382
- ${{ env.PR_PENDING_LABEL }}
383
- incomplete:
384
- needs: [subject, protected_changes, agent, safe_outputs, validate_output]
385
- if: >
386
- always() &&
387
- needs.subject.outputs.found == 'true' &&
388
- (needs.protected_changes.outputs.requires_review != 'true' || needs.subject.outputs.conclusion == 'failure') &&
389
- (
390
- needs.agent.result != 'success' ||
391
- needs.safe_outputs.result != 'success' ||
392
- needs.validate_output.outputs.valid != 'true'
393
- )
394
- runs-on: agents-arc
395
- permissions:
396
- contents: read
397
- issues: write
398
- steps:
399
- - name: Checkout workflow actions
400
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
401
- with:
402
- persist-credentials: false
403
- - name: Create bot token
404
- id: app-token
405
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
406
- with:
407
- client-id: ${{ secrets.BOT_APP_ID }}
408
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
409
- # The attempt record comes first. The belt bounds its retries by counting attempt comments
410
- # newer than the CI verdict, not by labels; releasing the labels before the record existed
411
- # meant a failure in either step below un-reserved the issue with nothing to count, and the
412
- # belt re-dispatched the same crash every cycle. The steps stay sequential on purpose: an
413
- # always() release after a failed park would strip bot-working from an issue that was
414
- # meant to be parked with review.
415
- # attempts_so_far is a workflow_call input and arrives as '' when the caller passes an
416
- # empty expression, declared default or not; fromJson('') is a hard failure, so the empty
417
- # case reads as 0.
418
- - name: Report the failed attempt
419
- if: fromJson(inputs.attempts_so_far || '0') < fromJson(env.PARK_AT_ATTEMPT)
420
- uses: ./.github/actions/create-issue-comment
421
- with:
422
- token: ${{ steps.app-token.outputs.token }}
423
- issue-number: ${{ needs.subject.outputs.issue }}
424
- body: |
425
- ${{ env.ATTEMPT_MARKER }}
426
- Attempt ${{ inputs.attempts_so_far || '0' }} of ${{ env.MAX_ATTEMPTS }} on PR #${{ needs.subject.outputs.pr }} ended without an outcome.
427
- The issue keeps `implement`; the merge belt will retry.
428
- [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
429
- - name: Report the exhausted attempt budget
430
- if: fromJson(inputs.attempts_so_far || '0') >= fromJson(env.PARK_AT_ATTEMPT)
431
- uses: ./.github/actions/create-issue-comment
432
- with:
433
- token: ${{ steps.app-token.outputs.token }}
434
- issue-number: ${{ needs.subject.outputs.issue }}
435
- body: |
436
- ${{ env.ATTEMPT_MARKER }}
437
- Attempt ${{ inputs.attempts_so_far || '0' }} of ${{ env.MAX_ATTEMPTS }} on PR #${{ needs.subject.outputs.pr }} ended without an outcome.
438
- The attempt budget for this CI verdict is exhausted. The review label is set: a human must take over.
439
- [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
440
- - name: Park the issue for a human
441
- if: fromJson(inputs.attempts_so_far || '0') >= fromJson(env.PARK_AT_ATTEMPT)
442
- uses: ./.github/actions/add-issue-labels
443
- with:
444
- token: ${{ steps.app-token.outputs.token }}
445
- issue-number: ${{ needs.subject.outputs.issue }}
446
- labels: ${{ env.REVIEW_LABEL }}
447
- # The reservation only. A failed attempt does not close the pull request, so pr-pending
448
- # is still true and the board should keep saying so.
449
- - name: Release the issue
450
- uses: ./.github/actions/remove-issue-labels
451
- with:
452
- token: ${{ steps.app-token.outputs.token }}
453
- issue-number: ${{ needs.subject.outputs.issue }}
454
- labels: ${{ env.WORKING_LABEL }}
455
-
456
- agent:
457
- # The top-level guard reads both outputs. GitHub Actions does not make a
458
- # dependency's dependencies available through `needs` transitively.
459
- needs: [subject, protected_changes]
460
- if: always() && (needs.protected_changes.outputs.requires_review != 'true' || needs.subject.outputs.conclusion == 'failure') && needs.subject.outputs.review_blocked != 'true'
461
-
462
- if: always() && needs.subject.outputs.found == 'true' && (needs.protected_changes.outputs.requires_review != 'true' || needs.subject.outputs.conclusion == 'failure') && needs.subject.outputs.review_blocked != 'true'
463
-
464
- runs-on: agents-arc
465
- runs-on-slim: agents-arc
466
-
467
- secrets:
468
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
469
-
470
- engine:
471
- id: opencode
472
- version: "1.2.14"
473
- env:
474
- OPENAI_BASE_URL: https://forge.plainconcepts.com/v1
475
-
476
- model: openai/glm-5-3
477
-
478
- max-turns: 300
479
- max-turn-cache-misses: 3000
480
- max-ai-credits: 5000
481
-
482
- permissions: read-all
483
-
484
- # push-to-pull-request-branch with target "*" cannot reach a branch the shallow clone
485
- # does not have.
486
- checkout:
487
- fetch: ["*"]
488
- fetch-depth: 0
489
-
490
- # Rung 3. The diff is what the risk assessment reads, and the failing logs are what a fix
491
- # starts from. Both are known from the inputs, so neither costs the agent a turn.
492
- steps:
493
- # gh-aw checks out the router's ref. Its own "Checkout PR branch" step runs only when the event
494
- # carries a pull request, which a router dispatch does not, so the agent would start on main.
495
- # apply-agent-output fast-forwards origin/<branch> to the bundle tip and refuses anything else,
496
- # and gh-aw builds that bundle from what the agent committed on top of the checkout; both need
497
- # the agent to start on the branch it pushes to.
498
- - name: Check out the pull request branch
499
- env:
500
- GH_TOKEN: ${{ github.token }}
501
- REPO: ${{ github.repository }}
502
- PR: ${{ needs.subject.outputs.pr }}
503
- run: |
504
- set -euo pipefail
505
- branch=$(gh pr view "$PR" --repo "$REPO" --json headRefName --jq '.headRefName')
506
- git switch --track "origin/$branch" 2>/dev/null || git switch "$branch"
507
- echo "On $(git branch --show-current) at $(git rev-parse --short HEAD)"
508
- # Every merge into the default branch invalidates every other open pull request that added a
509
- # changelog entry, because they all insert at the top of the same list. The conflict is real
510
- # and never interesting: both entries belong, newest first. Without a driver it costs a model
511
- # run per sibling pull request, and it recurs on every merge while more than one is in flight.
512
- # A consumer opts in by naming its own changelog in .gitattributes with `merge=changelog`;
513
- # repositories that do not are unaffected, because git only calls a driver a path asks for.
514
- - name: Teach git how to merge a changelog
515
- run: |
516
- set -euo pipefail
517
- git config merge.changelog.name "newest-first changelog entries"
518
- git config merge.changelog.driver "node $GITHUB_WORKSPACE/scripts/merge-changelog.mjs %O %A %B"
519
- - name: Load the issue context
520
- uses: ./.github/actions/load-issue-context
521
- with:
522
- token: ${{ github.token }}
523
- issue-number: ${{ needs.subject.outputs.issue }}
524
- output-path: ${{ env.ISSUE_CONTEXT_PATH }}
525
- - name: Fetch the diff and any failing job logs
526
- env:
527
- GH_TOKEN: ${{ github.token }}
528
- REPO: ${{ github.repository }}
529
- PR: ${{ needs.subject.outputs.pr }}
530
- RUN_ID: ${{ needs.subject.outputs.run-id }}
531
- CONCLUSION: ${{ needs.subject.outputs.conclusion }}
532
- run: |
533
- set -euo pipefail
534
- mkdir -p /tmp/gh-aw/agent
535
- gh pr diff "$PR" --repo "$REPO" > /tmp/gh-aw/agent/diff.patch
536
- gh pr view "$PR" --repo "$REPO" --json title,body,files,additions,deletions \
537
- > /tmp/gh-aw/agent/pr.json
538
- if [ "$CONCLUSION" = "failure" ] && [ -n "$RUN_ID" ]; then
539
- gh run view "$RUN_ID" --repo "$REPO" --log-failed \
540
- > /tmp/gh-aw/agent/failed-logs.txt 2>/dev/null || \
541
- echo "logs unavailable" > /tmp/gh-aw/agent/failed-logs.txt
542
- gh run view "$RUN_ID" --repo "$REPO" --json jobs \
543
- --jq '[.jobs[] | select(.conclusion == "failure") | {name, conclusion}]' \
544
- > /tmp/gh-aw/agent/failed-jobs.json
545
- elif [ "$CONCLUSION" = "failure" ]; then
546
- echo "[]" > /tmp/gh-aw/agent/failed-jobs.json
547
- echo "CI run ID unavailable, cannot fetch logs" > /tmp/gh-aw/agent/failed-logs.txt
548
- fi
549
-
550
- safe-outputs:
551
- # Path B. Without this, gh-aw's own safe_outputs job writes as well as the conclude job, and
552
- # it runs first: it pushed a flattened, single-parent commit with GITHUB_TOKEN while conclude
553
- # was still waiting, so the agent's merge commit was lost, the pull request stayed
554
- # conflicting, and GITHUB_TOKEN raises no events, so no CI ran on the new head and the belt
555
- # stalled (Pliny-Bot run 34051821011). Staged runs everything and writes nothing; conclude
556
- # applies the bundle and the comment with the App token, which does start CI.
557
- staged: true
558
- # A failed run is already a red run. An issue per failure buries the real backlog
559
- # under noise nobody closes.
560
- report-failure-as-issue: false
561
- threat-detection: false
562
- # target "*" because these workers are dispatched, not triggered by the pull request:
563
- # the default "triggering" target has no pull request in context and rejects the push
564
- # with "requires pull request context", so the agent's fix is computed and discarded.
565
- push-to-pull-request-branch:
566
- target: "*"
567
- required-title-prefix: "[bot] "
568
- # A failed bot PR may already contain protected files. Permit a verified repair push,
569
- # but protected_changes still prevents the later green-CI cycle from auto-merging it.
570
- protected-files: allowed
571
- add-comment:
572
- target: "*"
573
-
574
- timeout-minutes: 60
575
- ---
576
-
577
- 1. You are gating pull request **#${{ needs.subject.outputs.pr }}**, which closes issue
578
- **#${{ needs.subject.outputs.issue }}**. CI concluded
579
- **${{ needs.subject.outputs.conclusion }}**.
580
-
581
- It has already been confirmed that this is an open pull request we authored, that it closes
582
- an issue, and that the issue carries `implement`. Do not re-check any of that, and do not
583
- poll for checks: the conclusion above is the answer.
584
-
585
- You are on the pull request branch. Never rebase, reset, amend or otherwise rewrite
586
- history: the workflow applies your commits as a bundle with a fast-forward-only push and
587
- discards anything that is not a descendant of the branch tip. The
588
- `push_to_pull_request_branch` tool's own description recommends rebasing; in this
589
- repository that advice is wrong. Merge, commit, and let the workflow push.
590
-
591
- 2. Read `${{ env.ISSUE_CONTEXT_PATH }}`. It contains the issue body and its discussion. When
592
- running `/repo-verify`, the acceptance criteria there define what the implementation must
593
- satisfy.
594
-
595
- 3. Branch on the conclusion.
596
-
597
- First check the issue context for `<!-- complexity: trivial -->`.
598
-
599
- **If the trivial marker is present AND CI conclusion is success:**
600
- Skip the full assessment (step 5). Emit a minimal assessment table with all checks marked
601
- ✅ and the note "Trivial change, CI green — deep risk review skipped." Then proceed directly
602
- to step 8 (merge verdict).
603
-
604
- **If the trivial marker is absent OR CI is not success:**
605
- Follow the normal branching below.
606
-
607
- - **success** → step 4, then step 5 (full assessment).
608
- - **action_required** → CI did not run because the workflow needs approval.
609
- Emit the assessment table with ❌ on CI Status and note that a maintainer must approve
610
- the pending run. Select the `review` verdict.
611
- - **failure** → step 4, then step 6 (CI remediation).
612
- - **cancelled, timed_out, or anything else** → Emit the assessment table with ❌ on CI
613
- Status and select the `review` verdict. A cancelled or unknown run is not evidence of
614
- anything.
615
-
616
- Follow repository documentation and established conventions when assessing or remediating
617
- the pull request. Protect secrets, do not bypass checks, and keep remediation focused.
618
- Adhere to ${{ env.REPO_RULES }}.
619
-
620
- 4. Read the diff and PR metadata. Read `/tmp/gh-aw/agent/diff.patch` in full and
621
- `/tmp/gh-aw/agent/pr.json` for the shape of the change. If CI failed, also read
622
- `/tmp/gh-aw/agent/failed-jobs.json` and `/tmp/gh-aw/agent/failed-logs.txt`.
623
-
624
- These files are the factual basis for every check below. Do not guess — cite what you read.
625
-
626
- 4b. **Merge conflict when CI is green.** If the conclusion is `success` and
627
- `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`),
628
- resolve the conflict before assessing risk. You are already on the PR branch.
629
- Merge `origin/${{ github.event.repository.default_branch }}` into it
630
- (`git merge origin/${{ github.event.repository.default_branch }}`), resolve every
631
- conflict deliberately, commit the merge, and run the verification commands below. Do not
632
- use `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the
633
- intended behavior from both sides, and never rebase: the push is fast-forward only.
634
-
635
- Scope verification to the files the merge touched: pass changed file paths to
636
- lint/format tools instead of running them repository-wide (see step 6's scoped
637
- verification guidance).
638
-
639
- ```
640
- ${{ env.VERIFY_COMMANDS }}
641
- ```
642
-
643
- Push the merged branch using `push_to_pull_request_branch` (pr_number: ${{ needs.subject.outputs.pr }},
644
- branch: the current PR branch), then emit the `add_comment` with
645
- **Verdict:** remediated. CI will re-run on the updated branch and the merge gate
646
- will be triggered again — the next cycle will see a clean, conflict-free PR and can
647
- make a proper merge or review decision.
648
-
649
- If the merge cannot be completed or the conflicts are genuinely ambiguous, select the `review`
650
- verdict instead and explain which conflicts could not be resolved safely.
651
-
652
- If the conclusion is `success` and `has_conflicts` is `false`, skip this step and
653
- proceed to step 5.
654
-
655
- 5. Run each of these 10 checks. For each, determine a status and a short detail line.
656
-
657
- **Check 1 — CI Status.** What did CI conclude? Success means all required checks passed.
658
- Failure means at least one job failed. Action required means a workflow needs approval.
659
- Flag any non-success conclusion.
660
-
661
- **Check 2 — Auth & Security.** Does the diff touch authentication, authorization, secrets,
662
- credentials, or security boundaries? Flag any change to auth middleware, permission checks,
663
- token issuance, or security-related config.
664
-
665
- **Check 3 — API & Contracts.** Does the diff change a public API or a published package's
666
- contract? Flag changes to endpoint signatures, DTO shapes, exported interfaces, or
667
- serialization formats that could break consumers.
668
-
669
- **Check 4 — Tests.** Does the diff delete, weaken, or lower a threshold in a test? Flag
670
- removed assertions, skipped tests, lowered coverage bars, or deleted test files.
671
-
672
- **Check 5 — CI/CD & Workflow files.** Does the diff change CI, CD, or workflow files?
673
- Flag changes to `.github/workflows/`, Dockerfiles, deployment scripts, or infrastructure
674
- configuration.
675
-
676
- **Check 6 — Protected files.** Does the diff change a protected file? This is normally
677
- handled before you run, but never merge one if it reaches this gate. Flag any match against
678
- the repository's protected file list.
679
-
680
- **Check 7 — Scope.** Is the diff size consistent with what the issue implied? Compare the
681
- number of files changed and lines added/removed against the complexity the issue described.
682
- Flag if the diff is materially larger or smaller than expected.
683
-
684
- **Check 8 — Repository risk indicators.** Does the diff touch any risk indicator defined in
685
- ${{ env.REPO_RULES }}? Review the repository guardrails for domain-specific risk areas such
686
- as calculation engines, audit chains, authentication, database migrations, or money handling.
687
- Flag any
688
- match and name the specific indicator.
689
-
690
- **Check 9 — Mergeability.** Can the PR be merged cleanly? The value is
691
- `${{ needs.reserve.outputs.has_conflicts }}`. If conflicts exist, this is ❌ but not a
692
- blocking verdict — proceed to remediation (step 6). If no conflicts, ✅.
693
-
694
- **Check 10 — Confidence.** Are you confident in the merge decision? Low confidence is
695
- itself a flag. If you are unsure about the impact of the change, mark ⚠️ and explain what
696
- is uncertain. A human should review when confidence is low.
697
-
698
- 6. **CI failed** → read `/tmp/gh-aw/agent/failed-jobs.json` and
699
- `/tmp/gh-aw/agent/failed-logs.txt`, which are already on disk. Load only skills required to
700
- fix the actual cause. Run these verification commands before a push. Do not weaken a test,
701
- disable a check, or push an unverified guess.
702
-
703
- **Empty failure evidence is not a reason to ask for review.** If `failed-jobs.json` is `[]`
704
- or the logs say they were unavailable, then no CI run judged this head. That is the normal
705
- state of a conflicting pull request: GitHub cannot build `refs/pull/N/merge` while the
706
- conflict lasts, so no `pull_request` CI can run on it and there is nothing to read. The
707
- conflict is the failure to fix. Resolve it as described above, push, and select
708
- `remediated`; CI runs on the result and the next cycle gets a real verdict. Select `review`
709
- for missing evidence only when `has_conflicts` is `false`, because then there is genuinely
710
- nothing to act on.
711
-
712
- **Scoped verification.** The commands below are the full suite. This runner has limited
713
- memory, and a whole-repo lint or build can be killed mid-run. Scope verification to the
714
- files you actually changed first, and only escalate to the full suite when the scoped run
715
- passes and you are still unsure:
716
- - Lint/format (biome, eslint, prettier, ruff, etc.): pass the changed file paths as
717
- arguments so the tool checks only those files (e.g. `pnpm exec biome check <files>`),
718
- never the whole repository.
719
- - Build: prefer building only the project(s) containing the changed files; use the full
720
- solution build only when the change crosses project boundaries.
721
- - Tests: run the test project covering the changed files; run the full suite only when
722
- the change is cross-cutting.
723
- If verification of exactly the CI-failing job is what you need, reproduce just that job's
724
- command, not the entire pipeline.
725
-
726
- A PR that already contains protected files still requires remediation. You may include those
727
- files in the verified repair push, but the next green-CI cycle will require human review and
728
- must not auto-merge the PR.
729
-
730
- **If `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`):** You are already on the PR branch. Resolve the conflict;
731
- it is not a reason to hand the PR to a human. Merge `origin/${{ github.event.repository.default_branch }}`
732
- into the current branch, resolve every conflict deliberately, stage the resolutions, and
733
- commit the merge. Then run verification and push the resulting branch update. Do not use
734
- `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the intended
735
- behavior from both sides.
736
-
737
- ```
738
- ${{ env.VERIFY_COMMANDS }}
739
- ```
740
-
741
- Propose `push_to_pull_request_branch` (pr_number: ${{ needs.subject.outputs.pr }}, branch:
742
- the current PR branch), then select the `remediated` verdict. CI will run again and trigger
743
- you again with the new result.
744
-
745
- Before pushing, run the project's lint fix command (e.g. `pnpm lint:fix` or
746
- `pnpm exec biome check --write <changed-files>`) to auto-format. If lint:fix is not
747
- available, fix formatting manually. Never push code with lint errors.
748
-
749
- If you cannot fix it after a concrete repair attempt, or the logs show you have already tried on this same head commit,
750
- stop looping: select the `review` verdict and explain the failure and what you tried. A human
751
- decides from there.
752
-
753
- 7. Decide the verdict based on the assessment table:
754
-
755
- - **All checks ✅ → `merge`.** The PR is safe to merge. CI is green, no risk indicators
756
- triggered, tests are intact, scope matches, mergeability is clean.
757
- - **Any check ⚠️ or ❌ (except CI failure) → `review`.** Do not merge. Explain exactly which
758
- check tripped, why, and what a reviewer should look at. Leave `implement` in place: the
759
- work is not finished until a human merges it.
760
- - **CI failed and you fixed it → `remediated`.** You pushed a verified fix and CI will
761
- re-run.
762
- - **CI failed and you cannot fix it → `review`.** Explain the failure and what you tried.
763
-
764
- Never merge with administrator privileges and never bypass a required check. If the merge
765
- is refused, that refusal is the answer: select `review` and leave it for a human.
766
-
767
- 8. Emit exactly one `add_comment` targeting issue `${{ needs.subject.outputs.issue }}` with:
768
- 1. `${{ env.GATE_MARKER }}`
769
- 2. A heading: `## Merge gate decision for PR #${{ needs.subject.outputs.pr }}`
770
- 3. A structured assessment table with all 10 check results
771
- 4. A one-line detail per check (what was found and why it passed or flagged)
772
- 5. A line `**Verdict:** merge`, `**Verdict:** review`, or `**Verdict:** remediated`
773
-
774
- The workflow applies comments, labels, merges, and closures with the App token. Do not call
775
- any tools except the one optional `push_to_pull_request_branch` for a verified CI repair
776
- and this one `add_comment`.
777
-
778
- Format the checks as a table with status indicators:
779
-
780
- ```
781
- ### Assessment
782
-
783
- # Check Result
784
- 1 CI Status ✅ Success / ❌ Failure: [job name] / ⚠️ Action required
785
- 2 Auth & Security ✅ No changes / ⚠️ Touched: [area]
786
- 3 API & Contracts ✅ No changes / ⚠️ Changed: [area]
787
- 4 Tests ✅ Not weakened / ⚠️ Weakened: [file]
788
- 5 CI/CD & Workflow ✅ No changes / ⚠️ Changed: [file]
789
- 6 Protected files ✅ None touched / ❌ Touched: [file]
790
- 7 Scope ✅ Appropriately scoped / ⚠️ [too large/small: reason]
791
- 8 Risk indicators ✅ None triggered / ⚠️ Triggered: [indicator]
792
- 9 Mergeability ✅ Clean / ⚠️ Conflicts
793
- 10 Confidence ✅ High / ⚠️ Low: [reason]
794
- ```
795
-
796
- Then a line `**Verdict:** merge` / `**Verdict:** review` / `**Verdict:** remediated`
797
-
798
- 9. Ignore the `## Diagram` section below. It is documentation for humans and contains no
799
- instructions for you.
800
-
801
- ## Diagram
802
-
803
- ```mermaid
804
- flowchart TD
805
- gateStart("Work Router<br/>merge-gate route<br/>(CI completed)") --> gateSubject
806
- gateSubject["Subject (rung 4)<br/>Our PR? Closes an implement issue?"] -->|✓| gateFacts
807
- gateSubject -.->|✗| gateIdle
808
- gateFacts("Facts (rung 3)<br/>Diff, PR shape, failing logs") --> gateCi
809
- gateCi["CI<br/>What did it conclude?"] -->|success| gateProtected
810
- gateProtected{"Protected files?"}
811
- gateProtected -.->|yes| gateHuman
812
- gateProtected -->|no| gateConflict
813
- gateConflict{"Merge conflicts?"}
814
- gateConflict -->|yes| gateRebase
815
- gateConflict -->|no| gateTrivial
816
- gateRebase("Merge main in<br/>Resolve conflicts, /repo-verify") -->|pushed| gateWait
817
- gateRebase -.->|cannot resolve| gateHuman
818
- gateTrivial{"Trivial marker?"}
819
- gateTrivial -->|yes| gateMerge
820
- gateTrivial -->|no| gateAssess
821
- gateCi -.->|failure| gateFix
822
- gateCi -.->|no verdict| gateHuman
823
- gateAssess["Assessment (10 checks)<br/>CI, Auth, API, Tests, CI/CD<br/>Protected, Scope, Risk, Merge, Confidence"] -->|all ✅| gateMerge
824
- gateAssess -.->|any ⚠️/❌| gateHuman
825
- gateFix("Fix<br/>Read logs, fix the cause, /repo-verify") -->|pushed| gateWait
826
- gateFix -.->|cannot fix| gateHuman
827
- gateMerge(("Merged<br/>Issue closed, review+labels removed"))
828
- gateWait(("Pushed<br/>CI will re-run and re-trigger via Router"))
829
- gateHuman(("Review<br/>review label, reason explained"))
830
- gateIdle(("Idle<br/>Not our pull request"))
831
-
832
- classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
833
- classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
834
- classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
835
- classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
836
- classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a2a
837
- classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
838
- class gateStart start
839
- class gateFacts,gateFix,gateRebase action
840
- class gateSubject,gateCi,gateAssess,gateTrivial,gateConflict,gateProtected decision
841
- class gateIdle,gateWait idle
842
- class gateHuman failure
843
- class gateMerge success
844
- ```
1
+ ---
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-merge-gate.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ env:
4
+ VERIFY_COMMANDS: ""
5
+ REPO_RULES: "Make a risk-based merge decision for the selected bot pull request. Merge only when CI is green and no risk indicator is present. Do not merge protected file changes."
6
+ # The list that decides whether a machine merges without a human looking. It used to be a
7
+ # sentence inside REPO_RULES telling the agent to consult "the repository's guardrails or
8
+ # project documentation", which named no list at all and left the most consequential check in
9
+ # the pipeline resolving against nothing. Name the areas this repository will not auto-merge.
10
+ # One line: gh-aw joins a multi-line env value onto a single line when it compiles the lock.
11
+ RISK_INDICATORS: "Any diff touching authentication, authorization or session handling. Any change to a calculation or pricing engine, or to code handling money. Any database migration, or a change to an entity or schema. Any change to an audit or event log, or anything that could break its continuity. Any change to a public API contract or a shared library other repositories consume."
12
+ # Paths a bot may change but never merge on its own: an extended regular expression matched
13
+ # against every changed path in the pull request. The default names this stack's dependency
14
+ # and toolchain manifests plus everything under a dotted directory, and is wrong for a
15
+ # repository built on anything else, which is the failure worth knowing about: an unmatched
16
+ # list protects nothing and reports nothing. A match holds the merge for a human; it does not
17
+ # stop the agent repairing failed CI on the same files.
18
+ PROTECTED_PATHS: '^(\.|AGENTS\.md$|ARCHITECTURE\.md$|opencode\.jsonc$|package\.json$|pnpm-lock\.yaml$|Directory\.Packages\.props$|global\.json$)'
19
+ WORKING_LABEL: bot-working
20
+ IMPLEMENT_LABEL: implement
21
+ REVIEW_LABEL: review
22
+ # Marks a park the machine caused — a crash, a timeout, an empty output — as opposed to one it
23
+ # decided on. The janitor retries these after a while and never touches a decision park, because
24
+ # re-running a decision produces the same decision. Created idempotently where it is applied.
25
+ STALLED_LABEL: stalled
26
+ PR_PENDING_LABEL: pr-pending
27
+ GATE_MARKER: "<!-- agent-merge-gate -->"
28
+ ATTEMPT_MARKER: "<!-- agent-merge-gate-attempt -->"
29
+ MAX_ATTEMPTS: "6"
30
+ PARK_AT_ATTEMPT: "5"
31
+ ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
32
+ GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
33
+ GIT_AUTHOR_NAME: "github-actions[bot]"
34
+ GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
35
+ GIT_COMMITTER_NAME: "github-actions[bot]"
36
+ GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com"
37
+ description: |
38
+ Decides what happens to a bot-authored pull request once CI has reported: merge when the
39
+ risk assessment is clean, hand to a human when it is not, fix CI when it failed. Called by
40
+ the Work Router; does not trigger on public events.
41
+
42
+ The router supplies the CI conclusion and run ID as facts, so there is no polling and no
43
+ timeout branch. The conclusion is read from the inputs instead of filtered at the trigger.
44
+
45
+ name: "Agent: Merge Gate"
46
+
47
+ # Router-only worker. The Work Router owns triggers, classification, and rung 1-2 checks.
48
+ # This workflow receives the classified inputs and runs rung 3+.
49
+ imports:
50
+ - github/gh-aw/.github/workflows/shared/opencode.md@v0.87.5
51
+ - shared/platform-defaults.md
52
+ - shared/opencode-ci.md
53
+
54
+ on:
55
+ workflow_call:
56
+ inputs:
57
+ pr-number:
58
+ description: Pull request number to gate.
59
+ required: true
60
+ type: string
61
+ linked-issue:
62
+ description: Issue number the pull request closes. May be empty.
63
+ required: false
64
+ type: string
65
+ ci-conclusion:
66
+ description: CI conclusion (success, failure, action_required, cancelled, etc.).
67
+ required: true
68
+ type: string
69
+ ci-run-id:
70
+ description: CI workflow run ID for fetching failing logs.
71
+ required: false
72
+ type: string
73
+ attempts_so_far:
74
+ description: Failed gate attempts already made against this CI verdict. Parked when it reaches the cap.
75
+ required: false
76
+ type: string
77
+ default: '0'
78
+ # gh-aw folds the top-level `if:` below into the generated activation job but does not carry
79
+ # the jobs that `if:` reads into activation's `needs`: only prompt-referenced custom jobs with
80
+ # no `needs:` of their own are hoisted (subject). protected_changes needs subject, so without
81
+ # this entry activation read needs.protected_changes.outputs.requires_review before
82
+ # protected_changes had started; the value was '' and the clause was always true
83
+ # (Pliny-Bot run 34042143350: activation finished 18 s before protected_changes began).
84
+ needs: [protected_changes]
85
+
86
+ # Rung 4. Router has classified the event; identify-gate-subject validates PR ownership,
87
+ # resolves the closing issue, and confirms the CI verdict.
88
+ # A custom job, not `on.steps`, because the prompt and the precompute step need these
89
+ # values and `on.steps` outputs do not reach the agent job.
90
+ jobs:
91
+ subject:
92
+ runs-on: agents-arc
93
+ permissions:
94
+ contents: read
95
+ issues: read
96
+ pull-requests: read
97
+ actions: read
98
+ outputs:
99
+ found: ${{ steps.subject.outputs.found }}
100
+ pr: ${{ steps.subject.outputs.pr }}
101
+ issue: ${{ steps.subject.outputs.issue }}
102
+ conclusion: ${{ steps.subject.outputs.conclusion }}
103
+ run-id: ${{ steps.subject.outputs.run-id }}
104
+ review_blocked: ${{ steps.review.outputs.review_blocked }}
105
+ steps:
106
+ - name: Checkout workflow actions
107
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
108
+ with:
109
+ persist-credentials: false
110
+ - name: Identify the pull request, its issue, and the CI verdict
111
+ id: subject
112
+ uses: ./.github/actions/identify-gate-subject
113
+ with:
114
+ token: ${{ github.token }}
115
+ pr-number: ${{ inputs.pr-number }}
116
+ ci-conclusion: ${{ inputs.ci-conclusion }}
117
+ linked-issue: ${{ inputs.linked-issue }}
118
+ require-label: ${{ env.IMPLEMENT_LABEL }}
119
+ - name: Block a pull request with requested changes
120
+ id: review
121
+ env:
122
+ GH_TOKEN: ${{ github.token }}
123
+ REPO: ${{ github.repository }}
124
+ PR: ${{ steps.subject.outputs.pr }}
125
+ run: |
126
+ set -euo pipefail
127
+ # A transient API failure must not kill the gate (the reconcile cron can push the
128
+ # token into secondary rate limits). Three tries, then default to not blocked; the
129
+ # agent re-reads the review state itself before any merge.
130
+ decision=""
131
+ for _ in 1 2 3; do
132
+ if decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision // ""'); then
133
+ break
134
+ fi
135
+ sleep 5
136
+ done
137
+ echo "review_blocked=$([ "$decision" = 'CHANGES_REQUESTED' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
138
+
139
+ protected_changes:
140
+ needs: subject
141
+ if: needs.subject.outputs.found == 'true'
142
+ runs-on: agents-arc
143
+ permissions:
144
+ pull-requests: read
145
+ outputs:
146
+ requires_review: ${{ steps.files.outputs.requires_review }}
147
+ files: ${{ steps.files.outputs.files }}
148
+ # The decision, computed once. A protected path holds the merge for a human, but it must
149
+ # not stop the agent repairing failed CI on those same files: blocking there strands the
150
+ # pull request with nobody able to fix it. That pair of conditions used to be restated at
151
+ # eight call sites, five of them steps of one job, and the trap table documents it because
152
+ # it has already been got wrong. `holds_review` is the only place it is decided now.
153
+ holds_review: ${{ steps.files.outputs.requires_review == 'true' && needs.subject.outputs.conclusion != 'failure' }}
154
+ steps:
155
+ - name: Require review for protected pull request files
156
+ id: files
157
+ env:
158
+ GH_TOKEN: ${{ github.token }}
159
+ REPO: ${{ github.repository }}
160
+ PR: ${{ needs.subject.outputs.pr }}
161
+ PROTECTED_PATHS: ${{ env.PROTECTED_PATHS }}
162
+ run: |
163
+ set -euo pipefail
164
+ files=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename')
165
+ protected=$(printf '%s\n' "$files" | grep -E "$PROTECTED_PATHS" || true)
166
+
167
+ if [ -n "$protected" ]; then
168
+ echo "requires_review=true" >> "$GITHUB_OUTPUT"
169
+ {
170
+ echo 'files<<EOF'
171
+ printf '%s\n' "$protected"
172
+ echo EOF
173
+ } >> "$GITHUB_OUTPUT"
174
+ else
175
+ echo "requires_review=false" >> "$GITHUB_OUTPUT"
176
+ echo "files=" >> "$GITHUB_OUTPUT"
177
+ fi
178
+
179
+ review_required:
180
+ needs: [subject, protected_changes]
181
+ # gh-aw makes the agent depend on custom jobs. Keep this job successful when
182
+ # there are no protected files instead of skipping it and blocking remediation.
183
+ if: always() && needs.subject.outputs.found == 'true'
184
+ runs-on: agents-arc
185
+ permissions:
186
+ contents: read
187
+ issues: write
188
+ steps:
189
+ - name: Checkout workflow actions
190
+ if: needs.protected_changes.outputs.holds_review == 'true'
191
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
192
+ with:
193
+ persist-credentials: false
194
+ - name: Create bot token
195
+ if: needs.protected_changes.outputs.holds_review == 'true'
196
+ id: app-token
197
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
198
+ with:
199
+ client-id: ${{ secrets.BOT_APP_ID }}
200
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
201
+ # Only the reservation comes off. pr-pending says a pull request for this issue is open
202
+ # and waiting, which is still true when the gate hands it to a human, so taking it off
203
+ # here left a board where three issues with three open pull requests looked like they
204
+ # had none. The merge path is the one place the label stops being true.
205
+ - name: Release the issue
206
+ if: needs.protected_changes.outputs.holds_review == 'true'
207
+ uses: ./.github/actions/remove-issue-labels
208
+ with:
209
+ token: ${{ steps.app-token.outputs.token }}
210
+ issue-number: ${{ needs.subject.outputs.issue }}
211
+ labels: ${{ env.WORKING_LABEL }}
212
+ - name: Flag human review
213
+ if: needs.protected_changes.outputs.holds_review == 'true'
214
+ uses: ./.github/actions/add-issue-labels
215
+ with:
216
+ token: ${{ steps.app-token.outputs.token }}
217
+ issue-number: ${{ needs.subject.outputs.issue }}
218
+ labels: ${{ env.REVIEW_LABEL }}
219
+ - name: Explain the merge hold
220
+ if: needs.protected_changes.outputs.holds_review == 'true'
221
+ uses: ./.github/actions/create-issue-comment
222
+ with:
223
+ token: ${{ steps.app-token.outputs.token }}
224
+ issue-number: ${{ needs.subject.outputs.issue }}
225
+ body: |
226
+ ${{ env.GATE_MARKER }}
227
+ PR #${{ needs.subject.outputs.pr }} changes protected files and cannot be auto-merged.
228
+ The `review` label is set: a human must merge this PR manually.
229
+
230
+ Protected files:
231
+ ${{ needs.protected_changes.outputs.files }}
232
+
233
+ **Verdict:** review
234
+
235
+ reserve:
236
+ needs: subject
237
+ if: needs.subject.outputs.found == 'true'
238
+ runs-on: agents-arc
239
+ permissions:
240
+ contents: read
241
+ issues: write
242
+ pull-requests: read
243
+ outputs:
244
+ has_conflicts: ${{ steps.conflicts.outputs.has_conflicts || 'false' }}
245
+ steps:
246
+ - name: Checkout workflow actions
247
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
248
+ with:
249
+ persist-credentials: false
250
+ - name: Create bot token
251
+ id: app-token
252
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
253
+ with:
254
+ client-id: ${{ secrets.BOT_APP_ID }}
255
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
256
+ - name: Check for merge conflicts
257
+ id: conflicts
258
+ env:
259
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
260
+ REPO: ${{ github.repository }}
261
+ PR: ${{ needs.subject.outputs.pr }}
262
+ run: |
263
+ set -euo pipefail
264
+ mergeable=$(gh pr view "$PR" --repo "$REPO" --json mergeable --jq '.mergeable')
265
+ if [ "$mergeable" = "CONFLICTING" ]; then
266
+ echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
267
+ else
268
+ echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
269
+ fi
270
+ # First attempt only. Retries are recorded by the incomplete job's attempt comment, and
271
+ # every App comment is a router event: one issue collected sixteen of these in a day.
272
+ - name: Comment on issue - problems found, solving them
273
+ if: needs.subject.outputs.conclusion == 'failure' && (inputs.attempts_so_far || '0') == '0'
274
+ uses: ./.github/actions/create-issue-comment
275
+ with:
276
+ token: ${{ steps.app-token.outputs.token }}
277
+ issue-number: ${{ needs.subject.outputs.issue }}
278
+ body: |
279
+ Problems found in PR #${{ needs.subject.outputs.pr }}. ${{ steps.conflicts.outputs.has_conflicts == 'true' && 'Merge conflicts detected.' || 'CI failed.' }}
280
+ Bot is working on fixing it.
281
+ validate_output:
282
+ needs: [activation, subject, agent, safe_outputs]
283
+ if: >
284
+ always() &&
285
+ needs.agent.result == 'success' &&
286
+ needs.safe_outputs.result == 'success'
287
+ runs-on: agents-arc
288
+ permissions:
289
+ contents: read
290
+ outputs:
291
+ valid: ${{ steps.validate.outputs.valid }}
292
+ outcome: ${{ steps.validate.outputs.outcome }}
293
+ steps:
294
+ - name: Checkout workflow actions
295
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
296
+ with:
297
+ persist-credentials: false
298
+ - name: Download agent output
299
+ id: output
300
+ uses: ./.github/actions/download-agent-output
301
+ with:
302
+ artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
303
+ - name: Validate merge-gate outcome
304
+ id: validate
305
+ uses: ./.github/actions/validate-merge-gate-output
306
+ with:
307
+ output-file: ${{ steps.output.outputs.output-file }}
308
+ issue-number: ${{ needs.subject.outputs.issue }}
309
+ ci-conclusion: ${{ needs.subject.outputs.conclusion }}
310
+ conclude:
311
+ needs: [activation, subject, protected_changes, agent, safe_outputs, validate_output]
312
+ if: >
313
+ needs.agent.result == 'success' &&
314
+ needs.safe_outputs.result == 'success' &&
315
+ needs.validate_output.outputs.valid == 'true' &&
316
+ (needs.protected_changes.outputs.requires_review != 'true' || needs.validate_output.outputs.outcome != 'merge')
317
+ runs-on: agents-arc
318
+ permissions:
319
+ contents: write
320
+ issues: write
321
+ pull-requests: write
322
+ steps:
323
+ - name: Create bot token
324
+ id: app-token
325
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
326
+ with:
327
+ client-id: ${{ secrets.BOT_APP_ID }}
328
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
329
+ - name: Checkout repository
330
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
331
+ with:
332
+ token: ${{ steps.app-token.outputs.token }}
333
+ fetch-depth: 0
334
+ - name: Verify pull request closes the source issue
335
+ continue-on-error: true
336
+ uses: ./.github/actions/link-pr-to-issue
337
+ with:
338
+ token: ${{ steps.app-token.outputs.token }}
339
+ pr-number: ${{ needs.subject.outputs.pr }}
340
+ issue-number: ${{ needs.subject.outputs.issue }}
341
+ - name: Apply agent output
342
+ uses: ./.github/actions/apply-agent-output
343
+ with:
344
+ artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
345
+ token: ${{ steps.app-token.outputs.token }}
346
+ push-to-branch: 'true'
347
+ apply-labels: 'false'
348
+ # GITHUB_TOKEN on purpose: an App-token comment on a pull request is an issue_comment
349
+ # event, and GITHUB_TOKEN raises none. The full assessment lives on the issue, where the
350
+ # lifecycle is; this is what a reviewer opening the pull request sees. Carrying the
351
+ # marker and the Verdict line makes the router's verdict detection independent of
352
+ # whether the model remembered the marker.
353
+ - name: Show the verdict on the pull request
354
+ uses: ./.github/actions/create-issue-comment
355
+ with:
356
+ token: ${{ github.token }}
357
+ issue-number: ${{ needs.subject.outputs.pr }}
358
+ body: |
359
+ ${{ env.GATE_MARKER }}
360
+ **Verdict:** ${{ needs.validate_output.outputs.outcome }} (CI concluded ${{ needs.subject.outputs.conclusion }}).
361
+ Full assessment on the linked issue: #${{ needs.subject.outputs.issue }}. [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
362
+ - name: Merge approved pull request
363
+ if: needs.validate_output.outputs.outcome == 'merge'
364
+ env:
365
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
366
+ REPO: ${{ github.repository }}
367
+ PR: ${{ needs.subject.outputs.pr }}
368
+ run: |
369
+ set -euo pipefail
370
+ head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
371
+ gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
372
+ - name: Release remediated issue
373
+ if: needs.validate_output.outputs.outcome == 'remediated'
374
+ uses: ./.github/actions/remove-issue-labels
375
+ with:
376
+ token: ${{ steps.app-token.outputs.token }}
377
+ issue-number: ${{ needs.subject.outputs.issue }}
378
+ labels: ${{ env.WORKING_LABEL }}
379
+ - name: Flag review outcome
380
+ if: needs.validate_output.outputs.outcome == 'review'
381
+ uses: ./.github/actions/add-issue-labels
382
+ with:
383
+ token: ${{ steps.app-token.outputs.token }}
384
+ issue-number: ${{ needs.subject.outputs.issue }}
385
+ labels: ${{ env.REVIEW_LABEL }}
386
+ # The reservation only. The pull request is still open and still waiting, so pr-pending
387
+ # stays until the merge path below retires it.
388
+ - name: Release review outcome
389
+ if: needs.validate_output.outputs.outcome == 'review'
390
+ uses: ./.github/actions/remove-issue-labels
391
+ with:
392
+ token: ${{ steps.app-token.outputs.token }}
393
+ issue-number: ${{ needs.subject.outputs.issue }}
394
+ labels: ${{ env.WORKING_LABEL }}
395
+ - name: Clear merged issue labels
396
+ if: needs.validate_output.outputs.outcome == 'merge'
397
+ uses: ./.github/actions/remove-issue-labels
398
+ with:
399
+ token: ${{ steps.app-token.outputs.token }}
400
+ issue-number: ${{ needs.subject.outputs.issue }}
401
+ labels: |
402
+ ${{ env.IMPLEMENT_LABEL }}
403
+ ${{ env.WORKING_LABEL }}
404
+ ${{ env.REVIEW_LABEL }}
405
+ ${{ env.PR_PENDING_LABEL }}
406
+ incomplete:
407
+ needs: [subject, protected_changes, agent, safe_outputs, validate_output]
408
+ if: >
409
+ always() &&
410
+ needs.subject.outputs.found == 'true' &&
411
+ needs.protected_changes.outputs.holds_review != 'true' &&
412
+ (
413
+ needs.agent.result != 'success' ||
414
+ needs.safe_outputs.result != 'success' ||
415
+ needs.validate_output.outputs.valid != 'true'
416
+ )
417
+ runs-on: agents-arc
418
+ permissions:
419
+ contents: read
420
+ issues: write
421
+ steps:
422
+ - name: Checkout workflow actions
423
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
424
+ with:
425
+ persist-credentials: false
426
+ - name: Create bot token
427
+ id: app-token
428
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
429
+ with:
430
+ client-id: ${{ secrets.BOT_APP_ID }}
431
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
432
+ # The attempt record comes first. The belt bounds its retries by counting attempt comments
433
+ # newer than the CI verdict, not by labels; releasing the labels before the record existed
434
+ # meant a failure in either step below un-reserved the issue with nothing to count, and the
435
+ # belt re-dispatched the same crash every cycle. The steps stay sequential on purpose: an
436
+ # always() release after a failed park would strip bot-working from an issue that was
437
+ # meant to be parked with review.
438
+ # attempts_so_far is a workflow_call input and arrives as '' when the caller passes an
439
+ # empty expression, declared default or not; fromJson('') is a hard failure, so the empty
440
+ # case reads as 0.
441
+ - name: Report the failed attempt
442
+ if: fromJson(inputs.attempts_so_far || '0') < fromJson(env.PARK_AT_ATTEMPT)
443
+ uses: ./.github/actions/create-issue-comment
444
+ with:
445
+ token: ${{ steps.app-token.outputs.token }}
446
+ issue-number: ${{ needs.subject.outputs.issue }}
447
+ body: |
448
+ ${{ env.ATTEMPT_MARKER }}
449
+ Attempt ${{ inputs.attempts_so_far || '0' }} of ${{ env.MAX_ATTEMPTS }} on PR #${{ needs.subject.outputs.pr }} ended without an outcome.
450
+ The issue keeps `implement`; the merge belt will retry.
451
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
452
+ - name: Report the exhausted attempt budget
453
+ if: fromJson(inputs.attempts_so_far || '0') >= fromJson(env.PARK_AT_ATTEMPT)
454
+ uses: ./.github/actions/create-issue-comment
455
+ with:
456
+ token: ${{ steps.app-token.outputs.token }}
457
+ issue-number: ${{ needs.subject.outputs.issue }}
458
+ body: |
459
+ ${{ env.ATTEMPT_MARKER }}
460
+ Attempt ${{ inputs.attempts_so_far || '0' }} of ${{ env.MAX_ATTEMPTS }} on PR #${{ needs.subject.outputs.pr }} ended without an outcome.
461
+ The attempt budget for this CI verdict is exhausted. The review label is set: a human must take over.
462
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
463
+ - name: Park the issue for a human
464
+ if: fromJson(inputs.attempts_so_far || '0') >= fromJson(env.PARK_AT_ATTEMPT)
465
+ uses: ./.github/actions/add-issue-labels
466
+ with:
467
+ token: ${{ steps.app-token.outputs.token }}
468
+ issue-number: ${{ needs.subject.outputs.issue }}
469
+ labels: |-
470
+ ${{ env.REVIEW_LABEL }}
471
+ ${{ env.STALLED_LABEL }}
472
+ # The reservation only. A failed attempt does not close the pull request, so pr-pending
473
+ # is still true and the board should keep saying so.
474
+ - name: Release the issue
475
+ uses: ./.github/actions/remove-issue-labels
476
+ with:
477
+ token: ${{ steps.app-token.outputs.token }}
478
+ issue-number: ${{ needs.subject.outputs.issue }}
479
+ labels: ${{ env.WORKING_LABEL }}
480
+
481
+ agent:
482
+ # The top-level guard reads both outputs. GitHub Actions does not make a
483
+ # dependency's dependencies available through `needs` transitively.
484
+ needs: [subject, protected_changes]
485
+ if: always() && needs.protected_changes.outputs.holds_review != 'true' && needs.subject.outputs.review_blocked != 'true'
486
+
487
+ if: always() && needs.subject.outputs.found == 'true' && needs.protected_changes.outputs.holds_review != 'true' && needs.subject.outputs.review_blocked != 'true'
488
+
489
+ runs-on: agents-arc
490
+ runs-on-slim: agents-arc
491
+
492
+ secrets:
493
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
494
+
495
+ engine:
496
+ id: opencode
497
+ version: "1.2.14"
498
+ env:
499
+ OPENAI_BASE_URL: https://forge.plainconcepts.com/v1
500
+
501
+ model: openai/glm-5-3
502
+
503
+ max-turns: 300
504
+ max-turn-cache-misses: 3000
505
+ max-ai-credits: 5000
506
+
507
+ permissions: read-all
508
+
509
+ # push-to-pull-request-branch with target "*" cannot reach a branch the shallow clone
510
+ # does not have.
511
+ checkout:
512
+ fetch: ["*"]
513
+ fetch-depth: 0
514
+
515
+ # Rung 3. The diff is what the risk assessment reads, and the failing logs are what a fix
516
+ # starts from. Both are known from the inputs, so neither costs the agent a turn.
517
+ steps:
518
+ # gh-aw checks out the router's ref. Its own "Checkout PR branch" step runs only when the event
519
+ # carries a pull request, which a router dispatch does not, so the agent would start on main.
520
+ # apply-agent-output fast-forwards origin/<branch> to the bundle tip and refuses anything else,
521
+ # and gh-aw builds that bundle from what the agent committed on top of the checkout; both need
522
+ # the agent to start on the branch it pushes to.
523
+ - name: Check out the pull request branch
524
+ env:
525
+ GH_TOKEN: ${{ github.token }}
526
+ REPO: ${{ github.repository }}
527
+ PR: ${{ needs.subject.outputs.pr }}
528
+ run: |
529
+ set -euo pipefail
530
+ branch=$(gh pr view "$PR" --repo "$REPO" --json headRefName --jq '.headRefName')
531
+ git switch --track "origin/$branch" 2>/dev/null || git switch "$branch"
532
+ echo "On $(git branch --show-current) at $(git rev-parse --short HEAD)"
533
+ # Every merge into the default branch invalidates every other open pull request that added a
534
+ # changelog entry, because they all insert at the top of the same list. The conflict is real
535
+ # and never interesting: both entries belong, newest first. Without a driver it costs a model
536
+ # run per sibling pull request, and it recurs on every merge while more than one is in flight.
537
+ # A consumer opts in by naming its own changelog in .gitattributes with `merge=changelog`;
538
+ # repositories that do not are unaffected, because git only calls a driver a path asks for.
539
+ - name: Teach git how to merge a changelog
540
+ run: |
541
+ set -euo pipefail
542
+ git config merge.changelog.name "newest-first changelog entries"
543
+ git config merge.changelog.driver "node $GITHUB_WORKSPACE/scripts/merge-changelog.mjs %O %A %B"
544
+ - name: Load the issue context
545
+ uses: ./.github/actions/load-issue-context
546
+ with:
547
+ token: ${{ github.token }}
548
+ issue-number: ${{ needs.subject.outputs.issue }}
549
+ output-path: ${{ env.ISSUE_CONTEXT_PATH }}
550
+ - name: Fetch the diff and any failing job logs
551
+ env:
552
+ GH_TOKEN: ${{ github.token }}
553
+ REPO: ${{ github.repository }}
554
+ PR: ${{ needs.subject.outputs.pr }}
555
+ RUN_ID: ${{ needs.subject.outputs.run-id }}
556
+ CONCLUSION: ${{ needs.subject.outputs.conclusion }}
557
+ run: |
558
+ set -euo pipefail
559
+ mkdir -p /tmp/gh-aw/agent
560
+ gh pr diff "$PR" --repo "$REPO" > /tmp/gh-aw/agent/diff.patch
561
+ gh pr view "$PR" --repo "$REPO" --json title,body,files,additions,deletions \
562
+ > /tmp/gh-aw/agent/pr.json
563
+ if [ "$CONCLUSION" = "failure" ] && [ -n "$RUN_ID" ]; then
564
+ gh run view "$RUN_ID" --repo "$REPO" --log-failed \
565
+ > /tmp/gh-aw/agent/failed-logs.txt 2>/dev/null || \
566
+ echo "logs unavailable" > /tmp/gh-aw/agent/failed-logs.txt
567
+ gh run view "$RUN_ID" --repo "$REPO" --json jobs \
568
+ --jq '[.jobs[] | select(.conclusion == "failure") | {name, conclusion}]' \
569
+ > /tmp/gh-aw/agent/failed-jobs.json
570
+ elif [ "$CONCLUSION" = "failure" ]; then
571
+ echo "[]" > /tmp/gh-aw/agent/failed-jobs.json
572
+ echo "CI run ID unavailable, cannot fetch logs" > /tmp/gh-aw/agent/failed-logs.txt
573
+ fi
574
+
575
+ safe-outputs:
576
+ # Path B. Without this, gh-aw's own safe_outputs job writes as well as the conclude job, and
577
+ # it runs first: it pushed a flattened, single-parent commit with GITHUB_TOKEN while conclude
578
+ # was still waiting, so the agent's merge commit was lost, the pull request stayed
579
+ # conflicting, and GITHUB_TOKEN raises no events, so no CI ran on the new head and the belt
580
+ # stalled (Pliny-Bot run 34051821011). Staged runs everything and writes nothing; conclude
581
+ # applies the bundle and the comment with the App token, which does start CI.
582
+ staged: true
583
+ # A failed run is already a red run. An issue per failure buries the real backlog
584
+ # under noise nobody closes.
585
+ report-failure-as-issue: false
586
+ threat-detection: false
587
+ # target "*" because these workers are dispatched, not triggered by the pull request:
588
+ # the default "triggering" target has no pull request in context and rejects the push
589
+ # with "requires pull request context", so the agent's fix is computed and discarded.
590
+ push-to-pull-request-branch:
591
+ target: "*"
592
+ required-title-prefix: "[bot] "
593
+ # A failed bot PR may already contain protected files. Permit a verified repair push,
594
+ # but protected_changes still prevents the later green-CI cycle from auto-merging it.
595
+ protected-files: allowed
596
+ add-comment:
597
+ target: "*"
598
+
599
+ # The fleet is two machines, so this clock is also how long a stuck run can hold half of it.
600
+ # 240 went on to every worker at once when the provider was slow, which fixed the deaths and
601
+ # made every worker equally expensive to hang. These numbers are per worker: enough headroom
602
+ # for a slow gateway on the work it actually does, and not four hours for a run that reads one
603
+ # issue. Turns remain the guard against a confused agent looping; for a custom model the credit
604
+ # ceiling is models.dev fallback pricing and guards nothing.
605
+ #
606
+ # Reads CI failure evidence and may fix, verify and re-push, so it can do implement's work on a smaller diff.
607
+ timeout-minutes: 120
608
+ ---
609
+
610
+ 1. You are gating pull request **#${{ needs.subject.outputs.pr }}**, which closes issue
611
+ **#${{ needs.subject.outputs.issue }}**. CI concluded
612
+ **${{ needs.subject.outputs.conclusion }}**.
613
+
614
+ It has already been confirmed that this is an open pull request we authored, that it closes
615
+ an issue, and that the issue carries `implement`. Do not re-check any of that, and do not
616
+ poll for checks: the conclusion above is the answer.
617
+
618
+ You are on the pull request branch. Never rebase, reset, amend or otherwise rewrite
619
+ history: the workflow applies your commits as a bundle with a fast-forward-only push and
620
+ discards anything that is not a descendant of the branch tip. The
621
+ `push_to_pull_request_branch` tool's own description recommends rebasing; in this
622
+ repository that advice is wrong. Merge, commit, and let the workflow push.
623
+
624
+ 2. Read `${{ env.ISSUE_CONTEXT_PATH }}`. It contains the issue body and its discussion. When
625
+ running `/repo-verify`, the acceptance criteria there define what the implementation must
626
+ satisfy.
627
+
628
+ 3. Branch on the conclusion.
629
+
630
+ First check the issue context for `<!-- complexity: trivial -->`.
631
+
632
+ **If the trivial marker is present AND CI conclusion is success:**
633
+ Skip the full assessment (step 5). Emit a minimal assessment table with all checks marked
634
+ ✅ and the note "Trivial change, CI green — deep risk review skipped." Then proceed directly
635
+ to step 8 (merge verdict).
636
+
637
+ **If the trivial marker is absent OR CI is not success:**
638
+ Follow the normal branching below.
639
+
640
+ - **success** → step 4, then step 5 (full assessment).
641
+ - **action_required** → CI did not run because the workflow needs approval.
642
+ Emit the assessment table with ❌ on CI Status and note that a maintainer must approve
643
+ the pending run. Select the `review` verdict.
644
+ - **failure** → step 4, then step 6 (CI remediation).
645
+ - **cancelled, timed_out, or anything else** → Emit the assessment table with ❌ on CI
646
+ Status and select the `review` verdict. A cancelled or unknown run is not evidence of
647
+ anything.
648
+
649
+ Follow repository documentation and established conventions when assessing or remediating
650
+ the pull request. Protect secrets, do not bypass checks, and keep remediation focused.
651
+ Adhere to ${{ env.REPO_RULES }}.
652
+
653
+ 4. Read the diff and PR metadata. Read `/tmp/gh-aw/agent/diff.patch` in full and
654
+ `/tmp/gh-aw/agent/pr.json` for the shape of the change. If CI failed, also read
655
+ `/tmp/gh-aw/agent/failed-jobs.json` and `/tmp/gh-aw/agent/failed-logs.txt`.
656
+
657
+ These files are the factual basis for every check below. Do not guess — cite what you read.
658
+
659
+ 4b. **Merge conflict when CI is green.** If the conclusion is `success` and
660
+ `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`),
661
+ resolve the conflict before assessing risk. You are already on the PR branch.
662
+ Merge `origin/${{ github.event.repository.default_branch }}` into it
663
+ (`git merge origin/${{ github.event.repository.default_branch }}`), resolve every
664
+ conflict deliberately, commit the merge, and run the verification commands below. Do not
665
+ use `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the
666
+ intended behavior from both sides, and never rebase: the push is fast-forward only.
667
+
668
+ Scope verification to the files the merge touched: pass changed file paths to
669
+ lint/format tools instead of running them repository-wide (see step 6's scoped
670
+ verification guidance).
671
+
672
+ ```
673
+ ${{ env.VERIFY_COMMANDS }}
674
+ ```
675
+
676
+ Push the merged branch using `push_to_pull_request_branch` (pr_number: ${{ needs.subject.outputs.pr }},
677
+ branch: the current PR branch), then emit the `add_comment` with
678
+ **Verdict:** remediated. CI will re-run on the updated branch and the merge gate
679
+ will be triggered again — the next cycle will see a clean, conflict-free PR and can
680
+ make a proper merge or review decision.
681
+
682
+ If the merge cannot be completed or the conflicts are genuinely ambiguous, select the `review`
683
+ verdict instead and explain which conflicts could not be resolved safely.
684
+
685
+ If the conclusion is `success` and `has_conflicts` is `false`, skip this step and
686
+ proceed to step 5.
687
+
688
+ 5. Run each of these 10 checks. For each, determine a status and a short detail line.
689
+
690
+ **Check 1 — CI Status.** What did CI conclude? Success means all required checks passed.
691
+ Failure means at least one job failed. Action required means a workflow needs approval.
692
+ Flag any non-success conclusion.
693
+
694
+ **Check 2 — Auth & Security.** Does the diff touch authentication, authorization, secrets,
695
+ credentials, or security boundaries? Flag any change to auth middleware, permission checks,
696
+ token issuance, or security-related config.
697
+
698
+ **Check 3 — API & Contracts.** Does the diff change a public API or a published package's
699
+ contract? Flag changes to endpoint signatures, DTO shapes, exported interfaces, or
700
+ serialization formats that could break consumers.
701
+
702
+ **Check 4 — Tests.** Does the diff delete, weaken, or lower a threshold in a test? Flag
703
+ removed assertions, skipped tests, lowered coverage bars, or deleted test files.
704
+
705
+ **Check 5 — CI/CD & Workflow files.** Does the diff change CI, CD, or workflow files?
706
+ Flag changes to `.github/workflows/`, Dockerfiles, deployment scripts, or infrastructure
707
+ configuration.
708
+
709
+ **Check 6 — Protected files.** Does the diff change a protected file? This is normally
710
+ handled before you run, but never merge one if it reaches this gate. Flag any match against
711
+ the repository's protected file list.
712
+
713
+ **Check 7 — Scope.** Is the diff size consistent with what the issue implied? Compare the
714
+ number of files changed and lines added/removed against the complexity the issue described.
715
+ Flag if the diff is materially larger or smaller than expected.
716
+
717
+ **Check 8 — Repository risk indicators.** Does the diff touch any of these?
718
+ ${{ env.RISK_INDICATORS }}
719
+
720
+ Name the specific indicator you matched. A match is not a defect, it is a reason this
721
+ pull request needs a person, so do not argue it away because the change looks correct.
722
+
723
+ **Check 9 — Mergeability.** Can the PR be merged cleanly? The value is
724
+ `${{ needs.reserve.outputs.has_conflicts }}`. If conflicts exist, this is ❌ but not a
725
+ blocking verdict — proceed to remediation (step 6). If no conflicts, ✅.
726
+
727
+ **Check 10 — Confidence.** Are you confident in the merge decision? Low confidence is
728
+ itself a flag. If you are unsure about the impact of the change, mark ⚠️ and explain what
729
+ is uncertain. A human should review when confidence is low.
730
+
731
+ 6. **CI failed** → read `/tmp/gh-aw/agent/failed-jobs.json` and
732
+ `/tmp/gh-aw/agent/failed-logs.txt`, which are already on disk. Load only skills required to
733
+ fix the actual cause. Run these verification commands before a push. Do not weaken a test,
734
+ disable a check, or push an unverified guess.
735
+
736
+ **Empty failure evidence is not a reason to ask for review.** If `failed-jobs.json` is `[]`
737
+ or the logs say they were unavailable, then no CI run judged this head. That is the normal
738
+ state of a conflicting pull request: GitHub cannot build `refs/pull/N/merge` while the
739
+ conflict lasts, so no `pull_request` CI can run on it and there is nothing to read. The
740
+ conflict is the failure to fix. Resolve it as described above, push, and select
741
+ `remediated`; CI runs on the result and the next cycle gets a real verdict. Select `review`
742
+ for missing evidence only when `has_conflicts` is `false`, because then there is genuinely
743
+ nothing to act on.
744
+
745
+ **Scoped verification.** The commands below are the full suite. This runner has limited
746
+ memory, and a whole-repo lint or build can be killed mid-run. Scope verification to the
747
+ files you actually changed first, and only escalate to the full suite when the scoped run
748
+ passes and you are still unsure:
749
+ - Lint/format (biome, eslint, prettier, ruff, etc.): pass the changed file paths as
750
+ arguments so the tool checks only those files (e.g. `pnpm exec biome check <files>`),
751
+ never the whole repository.
752
+ - Build: prefer building only the project(s) containing the changed files; use the full
753
+ solution build only when the change crosses project boundaries.
754
+ - Tests: run the test project covering the changed files; run the full suite only when
755
+ the change is cross-cutting.
756
+ If verification of exactly the CI-failing job is what you need, reproduce just that job's
757
+ command, not the entire pipeline.
758
+
759
+ A PR that already contains protected files still requires remediation. You may include those
760
+ files in the verified repair push, but the next green-CI cycle will require human review and
761
+ must not auto-merge the PR.
762
+
763
+ **If `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`):** You are already on the PR branch. Resolve the conflict;
764
+ it is not a reason to hand the PR to a human. Merge `origin/${{ github.event.repository.default_branch }}`
765
+ into the current branch, resolve every conflict deliberately, stage the resolutions, and
766
+ commit the merge. Then run verification and push the resulting branch update. Do not use
767
+ `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the intended
768
+ behavior from both sides.
769
+
770
+ ```
771
+ ${{ env.VERIFY_COMMANDS }}
772
+ ```
773
+
774
+ Propose `push_to_pull_request_branch` (pr_number: ${{ needs.subject.outputs.pr }}, branch:
775
+ the current PR branch), then select the `remediated` verdict. CI will run again and trigger
776
+ you again with the new result.
777
+
778
+ If you cannot fix it after a concrete repair attempt, or the logs show you have already tried on this same head commit,
779
+ stop looping: select the `review` verdict and explain the failure and what you tried. A human
780
+ decides from there.
781
+
782
+ 7. Decide the verdict based on the assessment table:
783
+
784
+ - **All checks ✅ → `merge`.** The PR is safe to merge. CI is green, no risk indicators
785
+ triggered, tests are intact, scope matches, mergeability is clean.
786
+ - **Any check ⚠️ or ❌ (except CI failure) → `review`.** Do not merge. Explain exactly which
787
+ check tripped, why, and what a reviewer should look at. Leave `implement` in place: the
788
+ work is not finished until a human merges it.
789
+ - **CI failed and you fixed it → `remediated`.** You pushed a verified fix and CI will
790
+ re-run.
791
+ - **CI failed and you cannot fix it → `review`.** Explain the failure and what you tried.
792
+
793
+ Never merge with administrator privileges and never bypass a required check. If the merge
794
+ is refused, that refusal is the answer: select `review` and leave it for a human.
795
+
796
+ 8. Emit exactly one `add_comment` targeting issue `${{ needs.subject.outputs.issue }}` with:
797
+ 1. `${{ env.GATE_MARKER }}`
798
+ 2. A heading: `## Merge gate decision for PR #${{ needs.subject.outputs.pr }}`
799
+ 3. A structured assessment table with all 10 check results
800
+ 4. A one-line detail per check (what was found and why it passed or flagged)
801
+ 5. A line `**Verdict:** merge`, `**Verdict:** review`, or `**Verdict:** remediated`
802
+
803
+ The workflow applies comments, labels, merges, and closures with the App token. Do not call
804
+ any tools except the one optional `push_to_pull_request_branch` for a verified CI repair
805
+ and this one `add_comment`.
806
+
807
+ Format the checks as a table with status indicators:
808
+
809
+ ```
810
+ ### Assessment
811
+
812
+ # Check Result
813
+ 1 CI Status ✅ Success / ❌ Failure: [job name] / ⚠️ Action required
814
+ 2 Auth & Security ✅ No changes / ⚠️ Touched: [area]
815
+ 3 API & Contracts ✅ No changes / ⚠️ Changed: [area]
816
+ 4 Tests ✅ Not weakened / ⚠️ Weakened: [file]
817
+ 5 CI/CD & Workflow ✅ No changes / ⚠️ Changed: [file]
818
+ 6 Protected files ✅ None touched / ❌ Touched: [file]
819
+ 7 Scope ✅ Appropriately scoped / ⚠️ [too large/small: reason]
820
+ 8 Risk indicators ✅ None triggered / ⚠️ Triggered: [indicator]
821
+ 9 Mergeability ✅ Clean / ⚠️ Conflicts
822
+ 10 Confidence ✅ High / ⚠️ Low: [reason]
823
+ ```
824
+
825
+ Then a line `**Verdict:** merge` / `**Verdict:** review` / `**Verdict:** remediated`
826
+