@plainconceptsplatform/workflows 0.4.34 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +88 -88
  2. package/dist/action-validation.test.js +5 -2
  3. package/dist/catalog-installation.js +50 -7
  4. package/dist/catalog-installation.test.js +59 -22
  5. package/dist/catalog-listing.test.js +4 -4
  6. package/dist/index.js +48 -48
  7. package/dist/index.test.js +9 -9
  8. package/dist/repository-inspection.test.js +2 -2
  9. package/dist/route-processing.js +24 -25
  10. package/dist/route-processing.test.js +166 -233
  11. package/dist/stack-defaults.js +14 -14
  12. package/dist/stack-defaults.test.js +79 -79
  13. package/dist/tui.test.js +10 -7
  14. package/dist/workflow-catalog.d.ts +1 -1
  15. package/dist/workflow-catalog.js +1 -5
  16. package/loops/actions/add-issue-labels/action.yml +2 -2
  17. package/loops/actions/agent-output.cjs +17 -17
  18. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  19. package/loops/actions/audit-close/action.yml +24 -0
  20. package/loops/actions/classify-route/action.yml +8 -3
  21. package/loops/actions/classify-route/classify-route.sh +58 -38
  22. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  23. package/loops/actions/identify-gate-subject/action.yml +3 -1
  24. package/loops/actions/remove-issue-labels/action.yml +4 -2
  25. package/loops/actions/update-changelog/action.yml +113 -0
  26. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  27. package/loops/actions/validate-refine-output/validate-refine-output.sh +34 -5
  28. package/loops/actions/validate-review-output/action.yml +35 -35
  29. package/loops/actions/validate-triage-output/action.yml +36 -36
  30. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +375 -36
  32. package/loops/scripts/compile-agent-workflows.mjs +290 -6
  33. package/loops/scripts/merge-changelog.mjs +76 -0
  34. package/loops/templates/agentics/actionlint.yaml +13 -0
  35. package/loops/templates/agentics/agentics-checks.yml +125 -8
  36. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  37. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -171
  38. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -178
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/opencode/opencode.ci.json +49 -47
  42. package/loops/templates/opencode/opencode.ci.json.md +49 -41
  43. package/loops/templates/release/github-release.yml +30 -30
  44. package/loops/workflows/agent-apply-review.md +469 -443
  45. package/loops/workflows/agent-audit.md +213 -197
  46. package/loops/workflows/agent-implement.md +640 -414
  47. package/loops/workflows/agent-merge-gate.md +844 -584
  48. package/loops/workflows/agent-refine.md +633 -418
  49. package/loops/workflows/agent-release.md +258 -0
  50. package/loops/workflows/agent-triage.md +447 -439
  51. package/loops/workflows/authorize-bot-work.yml +85 -82
  52. package/loops/workflows/shared/opencode-ci.md +206 -197
  53. package/loops/workflows/shared/platform-defaults.md +19 -16
  54. package/loops/workflows/work-router.yml +1038 -632
  55. package/package.json +7 -8
  56. package/dist/repository-state.d.ts +0 -18
  57. package/dist/repository-state.js +0 -77
  58. package/dist/repository-state.test.d.ts +0 -1
  59. package/dist/repository-state.test.js +0 -96
  60. package/loops/workflows/agent-direct.md +0 -375
  61. package/loops/workflows/agent-propose.md +0 -342
@@ -2,6 +2,9 @@
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/verify-route-matrix.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
3
  # Exercise the router's real classifier. This sources classify-route.sh rather than
4
4
  # restating it, so a change to the route table cannot pass here by being copied twice.
5
+ #
6
+ # This file greps workflow sources for literal `${{ ... }}` expressions on purpose.
7
+ # shellcheck disable=SC2016
5
8
 
6
9
  set -euo pipefail
7
10
 
@@ -59,28 +62,38 @@ assert_route() {
59
62
  }
60
63
 
61
64
  echo "── Label events ──────────────────────────────────────────────────────────"
62
- assert_route "refine label routes to refine" refine \
63
- EVENT=issues ACTION=labeled LABEL=refine EVENT_ISSUE_NUMBER=42
64
- assert_route "implement label routes to implement" implement \
65
- EVENT=issues ACTION=labeled LABEL=implement EVENT_ISSUE_NUMBER=42
65
+ assert_route "human refine label waits for authorization" none \
66
+ EVENT=issues ACTION=labeled LABEL=refine ACTOR=maintainer EVENT_ISSUE_NUMBER=42
67
+ assert_route "bot refine label routes to refine" refine \
68
+ EVENT=issues ACTION=labeled LABEL=refine ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
69
+ assert_route "human implement label waits for authorization" none \
70
+ EVENT=issues ACTION=labeled LABEL=implement ACTOR=maintainer EVENT_ISSUE_NUMBER=42
71
+ assert_route "bot implement label routes to implement" implement \
72
+ EVENT=issues ACTION=labeled LABEL=implement ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
73
+ assert_route "human feature label waits for authorization" none \
74
+ EVENT=issues ACTION=labeled LABEL=feature ACTOR=maintainer EVENT_ISSUE_NUMBER=350
66
75
  assert_route "unrelated label routes nowhere" none \
67
76
  EVENT=issues ACTION=labeled LABEL=documentation EVENT_ISSUE_NUMBER=42
68
- assert_route "a non-label issue action routes to triage" triage \
77
+ assert_route "issue opened without labels routes to triage" triage \
69
78
  EVENT=issues ACTION=opened EVENT_ISSUE_NUMBER=42
79
+ assert_route "issue opened with refine label skips triage" none \
80
+ EVENT=issues ACTION=opened 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
81
+ assert_route "issue opened with implement label skips triage" none \
82
+ EVENT=issues ACTION=opened 'ISSUE_LABELS=["implement"]' EVENT_ISSUE_NUMBER=42
70
83
  assert_route "a human triage label routes to triage" triage \
71
84
  EVENT=issues ACTION=labeled LABEL=triage ACTOR=maintainer EVENT_ISSUE_NUMBER=42
72
85
  assert_route "a bot triage label routes nowhere" none \
73
86
  EVENT=issues ACTION=labeled LABEL=triage ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
87
+ assert_route "implement + bot-working without feature routes to implement" implement \
88
+ EVENT=issues ACTION=labeled LABEL=bot-working 'ISSUE_LABELS=["implement","bot-working"]' EVENT_ISSUE_NUMBER=300
74
89
  assert "refine label starts a first pass" first \
75
- "$(route_field refine-mode EVENT=issues ACTION=labeled LABEL=refine EVENT_ISSUE_NUMBER=42)"
76
- assert_route "direct label routes to direct" direct \
77
- EVENT=issues ACTION=labeled LABEL=direct EVENT_ISSUE_NUMBER=42
78
- assert "direct label starts a first pass" first \
79
- "$(route_field direct-mode EVENT=issues ACTION=labeled LABEL=direct EVENT_ISSUE_NUMBER=42)"
90
+ "$(route_field refine-mode EVENT=issues ACTION=labeled LABEL=refine ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42)"
80
91
 
81
92
  echo "── Comment events ────────────────────────────────────────────────────────"
82
93
  assert_route "a comment on a pull request routes to apply-review" apply-review \
83
94
  EVENT=issue_comment COMMENT_ON_PR=true EVENT_ISSUE_NUMBER=7
95
+ assert_route "the bot's own comment on a pull request never re-enters apply-review" none \
96
+ EVENT=issue_comment COMMENT_ON_PR=true COMMENT_SENDER_TYPE=Bot EVENT_ISSUE_NUMBER=7
84
97
  assert_route "an author reply on a refine issue re-refines" refine \
85
98
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User \
86
99
  'ISSUE_LABELS=["refine","review"]' EVENT_ISSUE_NUMBER=42
@@ -93,12 +106,6 @@ assert_route "the bot's own comment never re-enters refine" none \
93
106
  assert_route "a comment on an issue without refine routes nowhere" none \
94
107
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User \
95
108
  'ISSUE_LABELS=["bug"]' EVENT_ISSUE_NUMBER=42
96
- assert_route "an author reply on a direct issue continues" direct \
97
- EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User \
98
- 'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42
99
- assert "a direct reply is a continue pass" continue \
100
- "$(route_field direct-mode EVENT=issue_comment COMMENT_ON_PR=false \
101
- COMMENT_SENDER_TYPE=User 'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42)"
102
109
  assert_route "the bot's own comment never re-enters direct" none \
103
110
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=Bot \
104
111
  'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42
@@ -112,6 +119,14 @@ assert_route "the bot's own comment never re-enters triage" none \
112
119
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=Bot \
113
120
  'ISSUE_LABELS=["triage"]' EVENT_ISSUE_NUMBER=42
114
121
 
122
+ echo "── Closed issues ─────────────────────────────────────────────────────────"
123
+ assert_route "a closing comment on a refine issue does not re-refine" none EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=closed 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
124
+ assert_route "a comment on a closed issue never re-triages" none EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=closed 'ISSUE_LABELS=["triage"]' EVENT_ISSUE_NUMBER=42
125
+ assert_route "a work label added to a closed issue routes nowhere" none EVENT=issues ACTION=labeled LABEL=bot-working ISSUE_STATE=closed 'ISSUE_LABELS=["implement"]' EVENT_ISSUE_NUMBER=42
126
+ assert_route "a closed issue reopened as opened still routes nowhere while closed" none EVENT=issues ACTION=opened ISSUE_STATE=closed 'ISSUE_LABELS=[]' EVENT_ISSUE_NUMBER=42
127
+ assert_route "a comment on a closed pull request still routes to apply-review" apply-review EVENT=issue_comment COMMENT_ON_PR=true ISSUE_STATE=closed EVENT_ISSUE_NUMBER=7
128
+ assert_route "an open refine issue is unaffected by the closed guard" refine EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=open 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
129
+
115
130
  echo "── Review events ─────────────────────────────────────────────────────────"
116
131
  assert_route "a review comment routes to apply-review" apply-review \
117
132
  EVENT=pull_request_review_comment EVENT_PR_NUMBER=7
@@ -121,13 +136,20 @@ assert_route "a pull_request_target routes to bot-approve" bot-approve \
121
136
  EVENT=pull_request_target ACTION=opened
122
137
 
123
138
  echo "── CI completion ─────────────────────────────────────────────────────────"
124
- assert_route "App CI completion routes to merge-gate" merge-gate \
125
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
126
- assert "merge-gate carries the CI conclusion" failure \
139
+ assert_route "a failed App CI run on a pull request routes to merge-gate" merge-gate \
140
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
141
+ assert "merge-gate carries the failing CI conclusion" failure \
127
142
  "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 \
128
143
  RUN_CONCLUSION=failure RUN_ID=99)"
129
- assert_route "a CI run with no pull request routes nowhere" none \
130
- EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=success RUN_ID=99
144
+ assert "merge-gate carries the failing CI run id" 99 \
145
+ "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 \
146
+ RUN_CONCLUSION=failure RUN_ID=99)"
147
+ assert_route "a green App CI run does not auto-trigger the gate" none \
148
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
149
+ assert_route "a cancelled App CI run does not auto-trigger the gate" none \
150
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
151
+ assert_route "a failed CI run with no pull request routes nowhere" none \
152
+ EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
131
153
 
132
154
  echo "── Schedules ─────────────────────────────────────────────────────────────"
133
155
  while read -r cron; do
@@ -151,8 +173,6 @@ assert_route "refine dispatch rejects a non-numeric issue" none \
151
173
  EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=abc
152
174
  assert_route "refine dispatch accepts a positive issue" refine \
153
175
  EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42
154
- assert_route "direct dispatch accepts a positive issue" direct \
155
- EVENT=workflow_dispatch OPERATION=direct INPUT_ISSUE_NUMBER=42
156
176
  assert_route "direct dispatch needs an issue number" none \
157
177
  EVENT=workflow_dispatch OPERATION=direct INPUT_ISSUE_NUMBER=
158
178
  assert_route "triage dispatch accepts a positive issue" triage \
@@ -161,41 +181,318 @@ assert_route "triage dispatch needs an issue number" none \
161
181
  EVENT=workflow_dispatch OPERATION=triage INPUT_ISSUE_NUMBER=
162
182
  assert "triage dispatch defaults to first pass" first \
163
183
  "$(route_field triage-mode EVENT=workflow_dispatch OPERATION=triage INPUT_ISSUE_NUMBER=42)"
184
+ assert_route "batch dispatch needs an issue number" none \
185
+ EVENT=workflow_dispatch OPERATION=batch INPUT_ISSUE_NUMBER=
164
186
  assert_route "merge-gate dispatch needs a pull request number" none \
165
187
  EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=0
166
188
  assert_route "merge-gate dispatch accepts a positive pull request" merge-gate \
167
189
  EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7
168
- assert_route "stale-recovery dispatch needs no numbers" stale-recovery \
169
- EVENT=workflow_dispatch OPERATION=stale-recovery
190
+ assert "merge-gate dispatch defaults its attempt count to zero" 0 \
191
+ "$(route_field merge-gate-attempts EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7)"
192
+ assert "merge-gate dispatch forwards the attempt count" 3 \
193
+ "$(route_field merge-gate-attempts EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 INPUT_ATTEMPTS_SO_FAR=3)"
194
+ # The implement worker re-dispatches itself when a run dies before producing an answer, so the
195
+ # count has to survive the round trip or the budget never advances and the retry never stops.
196
+ assert "implement dispatch defaults its attempt count to zero" 0 \
197
+ "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42)"
198
+ assert "implement dispatch forwards the attempt count" 2 \
199
+ "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=2)"
200
+ assert "a refine dispatch carries no implement attempts" 0 \
201
+ "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=2)"
170
202
  assert_route "reconcile-bot-pr-runs dispatch needs no numbers" reconcile-bot-pr-runs \
171
203
  EVENT=workflow_dispatch OPERATION=reconcile-bot-pr-runs
172
204
  assert_route "an unknown operation routes nowhere" none \
173
205
  EVENT=workflow_dispatch OPERATION=deploy-everything
174
206
  assert "a scheduled audit reports its trigger kind" scheduled \
175
207
  "$(route_field trigger-kind EVENT=schedule "SCHEDULE=17 1 * * 1")"
208
+
176
209
  assert "a dispatched audit reports its trigger kind" manual \
177
210
  "$(route_field trigger-kind EVENT=workflow_dispatch OPERATION=audit INPUT_TRIGGER_KIND=manual)"
178
- assert_route "propose dispatch needs no numbers" propose \
179
- EVENT=workflow_dispatch OPERATION=propose
180
- assert "a scheduled propose reports its trigger kind" scheduled \
181
- "$(route_field trigger-kind EVENT=schedule "SCHEDULE=29 7 * * *")"
182
- assert "a dispatched propose reports its trigger kind" manual \
183
- "$(route_field trigger-kind EVENT=workflow_dispatch OPERATION=propose INPUT_TRIGGER_KIND=manual)"
184
211
 
185
212
  echo "── Router wiring ─────────────────────────────────────────────────────────"
213
+
214
+ # GitHub evaluates every Actions expression in a workflow file, including ones written inside
215
+ # shell comments. An empty pair is not a valid expression and fails the whole file to parse,
216
+ # with an error that points at a line number rather than saying what is wrong. Prose about
217
+ # expressions must not contain one.
218
+ empty_expr=$(grep -rl -e '${{[[:space:]]*}}' "${HERE}/../../workflows"/*.yml "${HERE}/../../workflows"/*.md 2>/dev/null || true)
219
+ if [ -z "$empty_expr" ]; then
220
+ PASS=$((PASS + 1))
221
+ else
222
+ FAIL=$((FAIL + 1))
223
+ echo "FAIL: workflow files contain an empty Actions expression:" >&2
224
+ while IFS= read -r offending; do echo " $offending" >&2; done <<<"$empty_expr"
225
+ fi
226
+
227
+
228
+ # A hyphen inside a ${{ }} property path is parsed as subtraction, so the reference silently
229
+ # resolves to nothing and the rendered prompt keeps the raw expression. Underscores only.
230
+ if ! grep -qE 'needs\.[a-z_]+\.outputs\.[a-zA-Z0-9_]*-' "$IMPLEMENT_WORKER_MD"; then
231
+ PASS=$((PASS + 1))
232
+ else
233
+ FAIL=$((FAIL + 1))
234
+ echo "FAIL: implement worker reads a hyphenated job output inside an expression" >&2
235
+ grep -nE 'needs\.[a-z_]+\.outputs\.[a-zA-Z0-9_]*-' "$IMPLEMENT_WORKER_MD" >&2
236
+ fi
237
+
238
+ # A worker that prints `${{ env.NAME }}` without defining NAME in its own env: block renders
239
+ # an empty value, and the model fills the gap itself. That is how a child shipped `dotnet build
240
+ # --no-restore` against an unrestored workspace: the verification block was empty. Every name a
241
+ # worker prints must be defined in that worker. The values are consumer-owned (a consumer may
242
+ # split VERIFY_COMMANDS per area, or keep one); only the wiring is asserted here.
243
+ VERIFY_OK=1
244
+ for worker in "${HERE}/../../workflows"/agent-*.md; do
245
+ while read -r name; do
246
+ [ -n "$name" ] || continue
247
+ if ! grep -q "^ ${name}:" "$worker"; then
248
+ VERIFY_OK=0
249
+ echo "FAIL: $(basename "$worker") prints env.${name} without defining it" >&2
250
+ fi
251
+ done < <(grep -oE '\$\{\{ *env\.[A-Za-z_][A-Za-z0-9_]* *\}\}' "$worker" | sed -E 's/.*env\.([A-Za-z_][A-Za-z0-9_]*).*/\1/' | sort -u)
252
+ done
253
+ if [ "$VERIFY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
254
+
186
255
  if grep -Fq 'protected-files: allowed' "$IMPLEMENT_WORKER_MD" &&
187
- grep -Fq 'protected_changes:' "$MERGE_GATE_WORKER_MD"; then
256
+ grep -Fq 'protected-files: allowed' "$MERGE_GATE_WORKER_MD" &&
257
+ grep -Fq "needs.protected_changes.outputs.requires_review != 'true' || needs.subject.outputs.conclusion == 'failure'" "$MERGE_GATE_WORKER_MD"; then
258
+ PASS=$((PASS + 1))
259
+ else
260
+ FAIL=$((FAIL + 1))
261
+ echo "FAIL: protected changes must allow failed-CI repair while remaining held from merge" >&2
262
+ fi
263
+
264
+ # gh-aw folds the worker's top-level `if:` into the generated activation job but computes
265
+ # activation's `needs` on its own: only custom jobs the prompt references AND that declare no
266
+ # `needs:` are hoisted. A guard with its own `needs:` (protected_changes needs subject) is read
267
+ # before it has run, resolves to '' and gates nothing, unless it is listed in `on.needs`, the
268
+ # documented way to add jobs to pre_activation and activation. Inline list form is expected.
269
+ TOP_IF="$(tr -d '\r' <"$MERGE_GATE_WORKER_MD" | sed -n 's/^if: //p')"
270
+ ON_NEEDS="$(tr -d '\r' <"$MERGE_GATE_WORKER_MD" | sed -n '/^on:$/,/^[a-z]/p' |
271
+ sed -n 's/^ needs: *\[\(.*\)\].*/\1/p' | tr -d ' ' | tr ',' '\n')"
272
+ ACTIVATION_OK=1
273
+ [ -n "$TOP_IF" ] || { ACTIVATION_OK=0; echo "FAIL: could not read the merge-gate worker's top-level if" >&2; }
274
+ while read -r job; do
275
+ [ -n "$job" ] || continue
276
+ if tr -d '\r' <"$MERGE_GATE_WORKER_MD" | sed -n "/^ ${job}:$/,/^ [a-z_]*:$/p" | grep -q '^ needs:' &&
277
+ ! grep -qx "$job" <<<"$ON_NEEDS"; then
278
+ ACTIVATION_OK=0
279
+ echo "FAIL: merge-gate top-level if reads needs.${job}, which has its own needs and is not in on.needs; activation would read it before it runs" >&2
280
+ fi
281
+ done < <(grep -oE 'needs\.[a-z_]+\.' <<<"$TOP_IF" | sed 's/^needs\.//; s/\.$//' | sort -u)
282
+ if [ "$ACTIVATION_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
283
+
284
+ # The merge belt is serial for the whole repository: several overnight pull requests
285
+ # mean every merge moves the default branch under the rest, and gates running at once
286
+ # rebase onto bases other gates are about to invalidate. A per-issue group here would
287
+ # reintroduce that race, so assert the repo-wide lock is the one in use.
288
+ if grep -A7 'call-merge-gate:' "$ROUTER_YML" | grep -q 'group: merge-belt'; then
289
+ PASS=$((PASS + 1))
290
+ else
291
+ FAIL=$((FAIL + 1))
292
+ echo "FAIL: call-merge-gate must hold the repo-wide merge-belt lock" >&2
293
+ fi
294
+
295
+ # A verdict is the gate marker AND a `**Verdict:**` line together. Comments carrying the
296
+ # marker alone were progress notes and failed attempts, and the reconcile belt read every
297
+ # one of them as final: a crashed or OOM-killed gate parked its pull request for the rest
298
+ # of the night. Attempts are counted separately, capped, and reset by any new CI run.
299
+ BELT_OK=1
300
+ if ! grep -q 'agent-merge-gate-attempt' "$ROUTER_YML"; then
301
+ BELT_OK=0; echo "FAIL: router never counts gate attempts" >&2
302
+ fi
303
+ if [ "$(grep -cF 'contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))' "$ROUTER_YML")" -lt 4 ]; then
304
+ BELT_OK=0; echo "FAIL: verdict detection must pair the gate marker with a Verdict line in both dispatch paths" >&2
305
+ fi
306
+ if [ "$(grep -c 'attempts_so_far' "$ROUTER_YML")" -lt 2 ]; then
307
+ BELT_OK=0; echo "FAIL: dispatch sites must forward attempts_so_far" >&2
308
+ fi
309
+ # A second gate for a pull request whose gate is already queued or running reads the same CI
310
+ # verdict and is cancelled by the single-slot merge-belt queue (two cancellations on 2026-09-06).
311
+ if [ "$(grep -c 'a merge-gate run is already live' "$ROUTER_YML")" -lt 2 ]; then
312
+ BELT_OK=0; echo "FAIL: both dispatch paths must skip a pull request whose gate is already live" >&2
313
+ fi
314
+ # A conflicting pull request has no refs/pull/N/merge for GitHub to build, so a `pull_request`
315
+ # CI workflow can never run on that head. Requiring a fresh verdict before dispatching deadlocks
316
+ # the belt: only the gate resolves the conflict, and the gate never runs. Both paths fall back to
317
+ # the branch's last verdict when, and only when, the pull request is conflicting.
318
+ if [ "$(grep -c 'conflicts, so CI cannot run on' "$ROUTER_YML")" -lt 2 ]; then
319
+ BELT_OK=0
320
+ echo "FAIL: both dispatch paths must gate a conflicting pull request that can never get fresh CI" >&2
321
+ fi
322
+ # That fallback has to read the computed mergeable state. The REST boolean is null until GitHub
323
+ # recomputes it, and stays null for a pull request nobody has opened recently, which is exactly
324
+ # the stale conflicting pull request the fallback exists for: it never fired once in production.
325
+ # The state has to be polled, not read once. GitHub computes mergeability on demand and the
326
+ # first read answers UNKNOWN (or null through REST) while it works it out, so a single read
327
+ # reports "not conflicting" for exactly the stale pull requests the fallback is for. Observed
328
+ # twice in production: the fallback logged "no completed CI run" for a pull request that
329
+ # `gh pr view` reported as CONFLICTING from a warm cache seconds later.
330
+ if [ "$(grep -c 'mergeable_state()' "$ROUTER_YML")" -lt 2 ] ||
331
+ [ "$(grep -c 'mergeable_now=$(mergeable_state' "$ROUTER_YML")" -lt 2 ]; then
332
+ BELT_OK=0
333
+ echo "FAIL: both dispatch paths must poll the mergeable state; a single read answers UNKNOWN" >&2
334
+ fi
335
+ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
336
+
337
+ # GitHub delivers workflow_run only for CI runs whose actor is a human, so a bot pull request's
338
+ # CI never reaches the router's CI-completion route. The package ships a dispatch-merge-gate job
339
+ # in templates/ci that hands the verdict over from inside CI; a consumer CI workflow, where one
340
+ # exists beside the router, must carry it or bot pull requests wait for the hourly belt.
341
+ for ci in "${HERE}/../../workflows/ci.yml" "${HERE}/../../workflows/app-ci.yml"; do
342
+ [ -f "$ci" ] || continue
343
+ if grep -q 'operation=merge-gate' "$ci"; then
344
+ PASS=$((PASS + 1))
345
+ else
346
+ FAIL=$((FAIL + 1))
347
+ echo "FAIL: $(basename "$ci") has no dispatch-merge-gate job; bot pull requests would wait for the hourly belt" >&2
348
+ fi
349
+ done
350
+
351
+ # The router forwards a fact to a worker by reading `needs.classify.outputs.<x>`; a name the
352
+ # classify job does not export resolves to '' with no error. That is how the gate received
353
+ # attempts_so_far='' (the classifier emitted merge-gate-attempts, the job never exported it),
354
+ # fromJson('') killed the incomplete job before its attempt comment, and the belt re-dispatched
355
+ # the same crash every hour. Every name the router reads must be exported by the classify job.
356
+ CLASSIFY_EXPORTS="$(tr -d '\r' <"$ROUTER_YML" |
357
+ sed -n '/^ classify:$/,/^ [a-z-]*:$/p' |
358
+ sed -n '/^ outputs:$/,/^ [a-z]*:$/p' |
359
+ sed -n 's/^ \([a-zA-Z0-9_-]*\):.*/\1/p')"
360
+ CLASSIFY_OK=1
361
+ [ -n "$CLASSIFY_EXPORTS" ] || { CLASSIFY_OK=0; echo "FAIL: could not read the classify job's outputs from work-router.yml" >&2; }
362
+ while read -r name; do
363
+ [ -n "$name" ] || continue
364
+ if ! grep -qx "$name" <<<"$CLASSIFY_EXPORTS"; then
365
+ CLASSIFY_OK=0
366
+ echo "FAIL: work-router.yml reads needs.classify.outputs.${name} but the classify job does not export it" >&2
367
+ fi
368
+ done < <(grep -oE 'needs\.classify\.outputs\.[a-zA-Z0-9_-]+' "$ROUTER_YML" | sed 's/.*\.//' | sort -u)
369
+ if [ "$CLASSIFY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
370
+
371
+ # fromJson('') is a hard failure ("Error reading JToken"), and a workflow_call input arrives as
372
+ # '' whenever the caller passes an empty expression, declared default or not. The gate must never
373
+ # hand a raw input to fromJson; `inputs.x || '0'` reads the empty case as zero.
374
+ if ! grep -qE "fromJson\(inputs\.[a-zA-Z0-9_]+\)" "$MERGE_GATE_WORKER_MD"; then
375
+ PASS=$((PASS + 1))
376
+ else
377
+ FAIL=$((FAIL + 1))
378
+ echo "FAIL: merge-gate worker calls fromJson on a raw input; an empty caller value kills the job" >&2
379
+ grep -nE "fromJson\(inputs\.[a-zA-Z0-9_]+\)" "$MERGE_GATE_WORKER_MD" >&2
380
+ fi
381
+
382
+ # The worker's own comments must keep the distinction: progress notes carry no marker,
383
+ # failed attempts carry the attempt marker, verdicts carry the marker AND the Verdict line.
384
+ # Three verdict sites: the review hold on the issue, the agent's assessment on the issue,
385
+ # and conclude's short verdict on the pull request itself.
386
+ if grep -q 'ATTEMPT_MARKER: "<!-- agent-merge-gate-attempt -->"' "$MERGE_GATE_WORKER_MD" &&
387
+ [ "$(grep -c '\${{ env.GATE_MARKER }}' "$MERGE_GATE_WORKER_MD")" -eq 3 ]; then
388
+ PASS=$((PASS + 1))
389
+ else
390
+ FAIL=$((FAIL + 1))
391
+ echo "FAIL: merge-gate worker must keep verdict and attempt markers distinct" >&2
392
+ fi
393
+
394
+ # add-issue-labels and remove-issue-labels split `labels` on newlines. A caller that joined two
395
+ # names with a comma removed one label called "bot-working,pr-pending": a 404 the action swallows
396
+ # on purpose, so the release never happened and Pliny-Bot #49/#54 carried implement, pr-pending
397
+ # and review together for a day. Callers use block scalars, one label per line; the actions also
398
+ # accept commas so a consumer copy of an old caller keeps working.
399
+ LABELS_OK=1
400
+ if grep -nE '^[[:space:]]+labels: [^|>].*,' "${HERE}/../../workflows"/agent-*.md >&2; then
401
+ LABELS_OK=0
402
+ echo "FAIL: a worker passes comma-joined labels to a label action; use a block scalar, one label per line" >&2
403
+ fi
404
+ for action in add-issue-labels remove-issue-labels; do
405
+ if ! grep -qF 'split(/\r?\n|,/)' "${HERE}/../${action}/action.yml"; then
406
+ LABELS_OK=0
407
+ echo "FAIL: ${action} must accept comma-separated labels as well as one per line" >&2
408
+ fi
409
+ done
410
+ if [ "$LABELS_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
411
+
412
+ # The agent's fix reaches the branch as a bundle applied fast-forward only (apply-agent-output).
413
+ # gh-aw's push tool description tells the model to rebase, and a rebased branch cannot
414
+ # fast-forward: the push is refused and the verdict is lost (Pliny-Bot run 33952565835). The
415
+ # worker must start on the pull request branch and must never say `git rebase`. Its progress
416
+ # comment is posted on the first attempt only; retries are recorded by the attempt comment.
417
+ BRANCH_OK=1
418
+ # Path B: staged safe outputs, applied by conclude with the App token. Without `staged: true`
419
+ # gh-aw's safe_outputs job writes too, and it runs first: it pushed a flattened single-parent
420
+ # commit with GITHUB_TOKEN, which lost the agent's merge, left the pull request conflicting,
421
+ # and started no CI, because GITHUB_TOKEN writes raise no events.
422
+ if ! grep -qE '^ staged: true' "$MERGE_GATE_WORKER_MD"; then
423
+ BRANCH_OK=0; echo "FAIL: merge-gate safe-outputs must be staged; conclude owns the write path" >&2
424
+ fi
425
+ if grep -q 'git rebase' "$MERGE_GATE_WORKER_MD"; then
426
+ BRANCH_OK=0; echo "FAIL: merge-gate worker tells the agent to rebase; the push is fast-forward only" >&2
427
+ fi
428
+ if ! grep -q 'name: Check out the pull request branch' "$MERGE_GATE_WORKER_MD"; then
429
+ BRANCH_OK=0; echo "FAIL: merge-gate worker must check out the pull request branch before the agent starts" >&2
430
+ fi
431
+ if ! grep -qF "conclusion == 'failure' && (inputs.attempts_so_far || '0') == '0'" "$MERGE_GATE_WORKER_MD"; then
432
+ BRANCH_OK=0; echo "FAIL: the reserve job's progress comment must be posted on the first attempt only" >&2
433
+ fi
434
+ # A conflicting pull request has no CI run to read logs from, so the gate is handed empty
435
+ # failure artifacts. Read on its own that looks like "no evidence", and the agent asked for a
436
+ # human instead of resolving the conflict that caused it.
437
+ if ! grep -qF 'Empty failure evidence is not a reason to ask for review' "$MERGE_GATE_WORKER_MD"; then
438
+ BRANCH_OK=0
439
+ echo "FAIL: the gate must treat empty failure evidence on a conflicting PR as the conflict to fix" >&2
440
+ fi
441
+ if [ "$BRANCH_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
442
+
443
+ # pr-pending means a pull request for this issue is open and waiting. Only merging retires it.
444
+ # Every other path (the protected-files hold, a review verdict, a failed attempt) leaves the
445
+ # pull request open, and stripping the label there produced a board where issues with open
446
+ # pull requests looked like they had none. It went unnoticed while the label actions silently
447
+ # removed nothing, so the two bugs hid each other.
448
+ PENDING_OK=1
449
+ grep -q '^ PR_PENDING_LABEL:' "$MERGE_GATE_WORKER_MD" ||
450
+ { PENDING_OK=0; echo "FAIL: merge gate lost its PR_PENDING_LABEL definition" >&2; }
451
+ if [ "$(grep -c '\${{ env.PR_PENDING_LABEL }}' "$MERGE_GATE_WORKER_MD")" -ne 1 ]; then
452
+ PENDING_OK=0
453
+ echo "FAIL: pr-pending must be removed in exactly one place, the merge path" >&2
454
+ grep -n '\${{ env.PR_PENDING_LABEL }}' "$MERGE_GATE_WORKER_MD" >&2
455
+ fi
456
+ # And that one place has to be the merge outcome, not a hold or a failed attempt.
457
+ grep -B12 '\${{ env.PR_PENDING_LABEL }}' "$MERGE_GATE_WORKER_MD" | grep -q "outcome == 'merge'" ||
458
+ { PENDING_OK=0; echo "FAIL: the only pr-pending removal must sit under the merge outcome" >&2; }
459
+ if [ "$PENDING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
460
+
461
+ # A provider outage kills a run in a couple of minutes with no answer, and the same issue used
462
+ # to be handed to a human for it. The implement worker retries those and only those: a run that
463
+ # worked for half an hour and then failed produced an answer that was wrong, and repeating it
464
+ # costs the fleet the same half hour to be wrong again.
465
+ IMPLEMENT_RETRY_OK=1
466
+ for needle in 'RETRY_UNDER_MINUTES' 'ATTEMPT_MARKER' 'attempts_so_far' 'operation=implement'; do
467
+ grep -qF "$needle" "$IMPLEMENT_WORKER_MD" || {
468
+ IMPLEMENT_RETRY_OK=0
469
+ echo "FAIL: implement worker lost its retry belt: no '$needle'" >&2
470
+ }
471
+ done
472
+ # Park and retry are mutually exclusive: the retry path must never add the review label, and
473
+ # the park path must never re-dispatch.
474
+ grep -A3 'Flag for human review' "$IMPLEMENT_WORKER_MD" | grep -q "retry != 'true'" ||
475
+ grep -B3 'Flag for human review' "$IMPLEMENT_WORKER_MD" | grep -q "retry != 'true'" || {
476
+ IMPLEMENT_RETRY_OK=0
477
+ echo "FAIL: the implement worker must not flag review on a run it is about to retry" >&2
478
+ }
479
+ if [ "$IMPLEMENT_RETRY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
480
+
481
+ # A failed attempt must not strip `implement`: identify-gate-subject refuses an issue
482
+ # without it, so the first crash would starve every retry at the subject check.
483
+ if grep -A6 'Park the issue' "$MERGE_GATE_WORKER_MD" | grep -q 'REVIEW_LABEL' &&
484
+ ! grep -qF 'labels: ${{ env.WORKING_LABEL }},${{ env.IMPLEMENT_LABEL }}' "$MERGE_GATE_WORKER_MD"; then
188
485
  PASS=$((PASS + 1))
189
486
  else
190
487
  FAIL=$((FAIL + 1))
191
- echo "FAIL: protected changes are not held for Merge Gate review" >&2
488
+ echo "FAIL: the incomplete job must keep implement and only park on an exhausted budget" >&2
192
489
  fi
193
490
 
194
491
  # This repository is public. Every route a human can start from a comment, a review or a
195
492
  # label must pass the authorize gate, or anyone able to comment can start a model run that
196
493
  # writes code. Asserted here because removing the gate would otherwise be a silent, one-line
197
494
  # change that nothing fails on.
198
- for route in refine implement direct apply-review; do
495
+ for route in refine implement apply-review; do
199
496
  if grep -qE "route == '${route}'.*needs\.authorize\.outputs\.trusted == 'true'" "$ROUTER_YML"; then
200
497
  PASS=$((PASS + 1))
201
498
  else
@@ -215,8 +512,8 @@ else
215
512
  echo "FAIL: route 'triage' does not dispatch outside collaborators and require a trusted worker actor" >&2
216
513
  fi
217
514
 
218
- for route in refine implement direct triage apply-review merge-gate audit propose bot-approve \
219
- audit-close cleanup-artifacts reconcile-bot-pr-runs stale-recovery validate; do
515
+ for route in refine implement triage apply-review merge-gate audit bot-approve \
516
+ audit-close cleanup-artifacts reconcile-bot-pr-runs validate release; do
220
517
  if grep -q "route == '${route}'" "$ROUTER_YML"; then
221
518
  PASS=$((PASS + 1))
222
519
  else
@@ -235,6 +532,48 @@ while read -r operation; do
235
532
  done < <(sed -n '/^ operation:/,/^ issue-number:/p' "$ROUTER_YML" |
236
533
  sed -n 's/^ - //p')
237
534
 
535
+ # A router job that reads pull requests has to say so. audit-close listed them with only
536
+ # contents and issues and failed nightly on a 403 that named the endpoint and nothing else.
537
+ # Paired job-to-scope rather than parsed out of each action: the jobs that touch pull
538
+ # requests are few and known, and naming them here is what makes the omission visible.
539
+ for pr_job in audit-close reconcile-bot-pr-runs detect-pr-conflicts; do
540
+ if ! grep -q "^ ${pr_job}:$" "$ROUTER_YML"; then
541
+ continue
542
+ fi
543
+ pr_scopes=$(sed -n "/^ ${pr_job}:$/,/^ steps:$/p" "$ROUTER_YML")
544
+ if printf '%s' "$pr_scopes" | grep -qE '^ pull-requests: (read|write)$'; then
545
+ PASS=$((PASS + 1))
546
+ else
547
+ FAIL=$((FAIL + 1))
548
+ echo "FAIL: router job '${pr_job}' reads pull requests but grants no pull-requests scope" >&2
549
+ fi
550
+ done
551
+
552
+ # No written-down passwords in anything this repository ships. A throwaway credential for
553
+ # a test container is still a policy finding, and one sat in every consumer's CI for weeks
554
+ # until a scan found it rather than us. Two shapes: a password-ish name assigned a quoted
555
+ # value, and a command-line flag given one; a line containing a dollar sign is taken to be
556
+ # an expression or a shell variable and allowed. Paths resolve relative to this script, so
557
+ # upstream this reads the templates and in a consumer it reads the real workflows.
558
+ PASSWORD_SCAN_DIRS=("${HERE}/../../workflows")
559
+ [ -d "${HERE}/../../templates/ci" ] && PASSWORD_SCAN_DIRS+=("${HERE}/../../templates/ci")
560
+ [ -d "${HERE}/../../templates/agentics" ] && PASSWORD_SCAN_DIRS+=("${HERE}/../../templates/agentics")
561
+ password_hits=$(
562
+ find "${PASSWORD_SCAN_DIRS[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) \
563
+ -not -name '*.lock.yml' -print0 |
564
+ xargs -0 -r grep -nEi \
565
+ -e "(password|passwd|pwd)[\"']?[[:space:]]*[:=][[:space:]]*[\"'][^\"']" \
566
+ -e "(^|[[:space:]])(-P|--password)[[:space:]=]*[\"'][^\"']" |
567
+ grep -v '[$]' || true
568
+ )
569
+ if [ -z "$password_hits" ]; then
570
+ PASS=$((PASS + 1))
571
+ else
572
+ FAIL=$((FAIL + 1))
573
+ echo "FAIL: a password is written down in a shipped workflow; use a secret or derive it per run" >&2
574
+ printf '%s\n' "$password_hits" >&2
575
+ fi
576
+
238
577
  echo
239
578
  if [ "$FAIL" -eq 0 ]; then
240
579
  echo "Route matrix: ${PASS} passed"