cpflow 5.1.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -0
  3. data/.agents/bin/README.md +20 -0
  4. data/.agents/bin/docs +5 -0
  5. data/.agents/bin/lint +5 -0
  6. data/.agents/bin/setup +5 -0
  7. data/.agents/bin/test +5 -0
  8. data/.agents/bin/validate +5 -0
  9. data/.agents/trusted-github-actors.yml +32 -0
  10. data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
  11. data/.github/actions/cpflow-setup-environment/action.yml +1 -1
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/pull_request_template.md +18 -0
  14. data/.github/workflows/claude-code-review.yml +2 -0
  15. data/.github/workflows/claude.yml +94 -1
  16. data/.github/workflows/cpflow-delete-review-app.yml +621 -33
  17. data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
  18. data/.github/workflows/cpflow-review-app-help.yml +5 -13
  19. data/.github/workflows/rspec-shared.yml +10 -3
  20. data/.github/workflows/rspec-specific.yml +1 -0
  21. data/.github/workflows/rspec.yml +58 -1
  22. data/AGENTS.md +14 -0
  23. data/CHANGELOG.md +54 -1
  24. data/CLAUDE.md +3 -0
  25. data/CONTRIBUTING.md +15 -3
  26. data/Gemfile.lock +1 -1
  27. data/README.md +21 -7
  28. data/docs/ai-github-flow-prompt.md +18 -16
  29. data/docs/ci-automation.md +239 -27
  30. data/docs/commands.md +30 -2
  31. data/docs/grafana-opentelemetry.md +699 -0
  32. data/docs/secrets-and-env-values.md +37 -2
  33. data/docs/sidebars.ts +70 -0
  34. data/docs/telemetry/application-instrumentation.md +161 -0
  35. data/docs/telemetry/collector.md +297 -0
  36. data/docs/telemetry/index.md +152 -0
  37. data/docs/telemetry/pipelines.md +98 -0
  38. data/docs/telemetry/review-apps.md +55 -0
  39. data/docs/telemetry/troubleshooting.md +92 -0
  40. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  41. data/docs/terraform/overview.md +11 -0
  42. data/docs/tips.md +475 -28
  43. data/examples/controlplane.yml +2 -0
  44. data/lib/command/ai_github_flow_prompt.rb +2 -2
  45. data/lib/command/apply_template.rb +104 -2
  46. data/lib/command/base.rb +69 -5
  47. data/lib/command/deploy_image.rb +93 -7
  48. data/lib/command/promote_app_from_upstream.rb +1 -0
  49. data/lib/command/ps_wait.rb +2 -10
  50. data/lib/command/run.rb +133 -10
  51. data/lib/command/setup_app.rb +10 -5
  52. data/lib/core/config.rb +94 -0
  53. data/lib/core/controlplane.rb +38 -5
  54. data/lib/core/controlplane_api.rb +8 -0
  55. data/lib/core/controlplane_api_direct.rb +257 -63
  56. data/lib/core/doctor_service.rb +44 -3
  57. data/lib/core/shell.rb +9 -2
  58. data/lib/core/template_parser.rb +43 -9
  59. data/lib/cpflow/version.rb +1 -1
  60. data/lib/generator_templates/controlplane.yml +1 -2
  61. data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
  62. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  64. metadata +22 -2
@@ -8,32 +8,540 @@ on:
8
8
  required: true
9
9
 
10
10
  permissions:
11
+ actions: write
11
12
  contents: read
13
+ deployments: write
12
14
  issues: write
13
15
  pull-requests: write
14
16
 
15
- concurrency:
16
- group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
17
- # Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave
18
- # partial state behind. Let the in-progress deletion finish before the next run starts.
19
- cancel-in-progress: false
20
-
21
17
  env:
22
18
  PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
23
19
 
24
20
  jobs:
21
+ authorize-comment-command:
22
+ runs-on: ubuntu-latest
23
+ timeout-minutes: 5
24
+ permissions:
25
+ actions: read
26
+ # PR issue comments use the Issues API path but require PR write access.
27
+ pull-requests: write
28
+ outputs:
29
+ allowed: ${{ steps.record.outputs.accepted || steps.intent.outputs.reuse }}
30
+ steps:
31
+ - name: Resolve comment author repository permission
32
+ if: github.event_name == 'issue_comment'
33
+ id: permission
34
+ shell: bash
35
+ env:
36
+ GH_TOKEN: ${{ github.token }}
37
+ GH_REPO: ${{ github.repository }}
38
+ COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
39
+ run: |
40
+ set -euo pipefail
41
+
42
+ printf '%s\n' 'allowed=false' >> "$GITHUB_OUTPUT"
43
+ permission="$(gh api --method GET "repos/${GH_REPO}/collaborators/${COMMENT_AUTHOR}/permission" --jq '.permission')"
44
+
45
+ if [[ -z "${permission}" ]]; then
46
+ echo "GitHub returned an empty repository permission." >&2
47
+ exit 1
48
+ fi
49
+
50
+ case "${permission}" in
51
+ write|maintain|admin)
52
+ printf '%s\n' 'allowed=true' >> "$GITHUB_OUTPUT"
53
+ ;;
54
+ read|triage|none)
55
+ echo "Comment author repository permission does not allow review-app commands: ${permission}"
56
+ ;;
57
+ *)
58
+ echo "Unexpected repository permission: ${permission}" >&2
59
+ exit 1
60
+ ;;
61
+ esac
62
+
63
+ - name: Allow authenticated non-comment trigger
64
+ if: github.event_name != 'issue_comment'
65
+ id: non-comment
66
+ shell: bash
67
+ run: echo "allowed=true" >> "$GITHUB_OUTPUT"
68
+
69
+ # Persist a durable accepted-event marker before queue admission. The
70
+ # original comment may later be edited/deleted, and authorization jobs can
71
+ # finish out of event order, so the marker carries the workflow run's own
72
+ # creation time and id rather than relying on marker creation order.
73
+ - name: Prepare accepted review app intent
74
+ id: intent
75
+ if: |
76
+ (steps.permission.outputs.allowed == 'true' || steps.non-comment.outputs.allowed == 'true') &&
77
+ ((github.event_name == 'issue_comment' &&
78
+ github.event.issue.pull_request &&
79
+ contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body)) ||
80
+ (github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
81
+ github.event_name == 'workflow_dispatch')
82
+ shell: bash
83
+ env:
84
+ GH_TOKEN: ${{ github.token }}
85
+ GH_REPO: ${{ github.repository }}
86
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
87
+ OPERATION: delete
88
+ EVENT_NAME: ${{ github.event_name }}
89
+ SOURCE_ACTOR: ${{ github.event.comment.user.login || github.actor }}
90
+ RUN_ID: ${{ github.run_id }}
91
+ RECONCILE_INTENT_RUN_ID: ${{ github.event.inputs.reconcile_intent_run_id }}
92
+ run: |
93
+ set -euo pipefail
94
+
95
+ printf '%s\n' 'record=false' 'reuse=false' >> "$GITHUB_OUTPUT"
96
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${RUN_ID}" =~ ^[0-9]+$ ]]; then
97
+ echo "Invalid pull request or workflow run id while recording review-app intent." >&2
98
+ exit 1
99
+ fi
100
+ case "${OPERATION}" in
101
+ deploy|delete) ;;
102
+ *) echo "Invalid review-app operation: ${OPERATION}" >&2; exit 1 ;;
103
+ esac
104
+ case "${EVENT_NAME}" in
105
+ issue_comment|workflow_dispatch|pull_request|pull_request_target) ;;
106
+ *) echo "Invalid review-app event: ${EVENT_NAME}" >&2; exit 1 ;;
107
+ esac
108
+ if [[ -z "${SOURCE_ACTOR}" ]]; then
109
+ echo "Review-app intent is missing its source actor." >&2
110
+ exit 1
111
+ fi
112
+
113
+ if [[ -n "${RECONCILE_INTENT_RUN_ID}" ]]; then
114
+ if [[ ! "${RECONCILE_INTENT_RUN_ID}" =~ ^[0-9]+$ ]]; then
115
+ echo "Invalid internal review-app sequencing token." >&2
116
+ exit 1
117
+ fi
118
+
119
+ handoff=""
120
+ for _attempt in {1..10}; do
121
+ comments_json="$(
122
+ gh api --paginate --slurp --method GET \
123
+ "repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
124
+ )"
125
+ handoff="$(
126
+ jq -c \
127
+ --argjson pr "${PR_NUMBER}" \
128
+ --arg operation "${OPERATION}" \
129
+ --argjson intent_run_id "${RECONCILE_INTENT_RUN_ID}" \
130
+ --argjson dispatch_run_id "${RUN_ID}" '
131
+ [
132
+ .[][]?
133
+ | select(.user.login == "github-actions[bot]")
134
+ | .body
135
+ | select(type == "string")
136
+ | select(startswith("<!-- cpflow-review-app-redispatch-v1 ") and endswith(" -->"))
137
+ | ltrimstr("<!-- cpflow-review-app-redispatch-v1 ")
138
+ | rtrimstr(" -->")
139
+ | fromjson?
140
+ | select(
141
+ .pr == $pr and
142
+ .operation == $operation and
143
+ .intent_run_id == $intent_run_id and
144
+ .dispatch_run_id == $dispatch_run_id
145
+ )
146
+ | select(
147
+ (.source_run_id | type) == "number" and
148
+ .source_run_id > 0 and
149
+ (.source_run_id | floor) == .source_run_id
150
+ )
151
+ | select(
152
+ (.created_at | type) == "string" and
153
+ (.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
154
+ )
155
+ ]
156
+ | if length == 1 then .[0] else empty end
157
+ ' <<< "${comments_json}"
158
+ )"
159
+ [[ -n "${handoff}" ]] && break
160
+ sleep 1
161
+ done
162
+ if [[ -z "${handoff}" ]]; then
163
+ echo "Internal review-app sequencing token is not bound to this workflow run." >&2
164
+ exit 1
165
+ fi
166
+
167
+ source_run_id="$(jq -r '.source_run_id' <<< "${handoff}")"
168
+ source_created_at="$(jq -r '.created_at' <<< "${handoff}")"
169
+ case "${OPERATION}" in
170
+ deploy)
171
+ source_operation="delete"
172
+ expected_run_name="Delete Review App - PR #${PR_NUMBER}"
173
+ expected_job_name="delete-review-app / delete-review-app"
174
+ ;;
175
+ delete)
176
+ source_operation="deploy"
177
+ expected_run_name="Deploy Review App - PR #${PR_NUMBER}"
178
+ expected_job_name="deploy / deploy"
179
+ ;;
180
+ esac
181
+ expected_workflow_path=".github/workflows/cpflow-${source_operation}-review-app.yml"
182
+ source_run_json="$(
183
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${source_run_id}"
184
+ )"
185
+ if ! jq -e \
186
+ --argjson run_id "${source_run_id}" \
187
+ --arg created_at "${source_created_at}" \
188
+ --arg path "${expected_workflow_path}" \
189
+ --arg title "${expected_run_name}" \
190
+ '(.id == $run_id) and
191
+ (.created_at == $created_at) and
192
+ (.path == $path) and
193
+ (.display_title == $title)' \
194
+ <<< "${source_run_json}" >/dev/null
195
+ then
196
+ echo "Internal review-app handoff does not match its source workflow run." >&2
197
+ exit 1
198
+ fi
199
+
200
+ handoff_verified=false
201
+ for _attempt in {1..10}; do
202
+ source_jobs_json="$(
203
+ gh api --paginate --slurp --method GET \
204
+ "repos/${GH_REPO}/actions/runs/${source_run_id}/jobs?filter=all&per_page=100"
205
+ )"
206
+ if jq -e --arg job_name "${expected_job_name}" '
207
+ [
208
+ .[]?.jobs[]?
209
+ | select(.name == $job_name)
210
+ | .steps[]?
211
+ | select(.name == "Dispatch latest accepted review app intent" and .conclusion == "success")
212
+ ]
213
+ | length > 0
214
+ ' <<< "${source_jobs_json}" >/dev/null
215
+ then
216
+ handoff_verified=true
217
+ break
218
+ fi
219
+ sleep 1
220
+ done
221
+ if [[ "${handoff_verified}" != "true" ]]; then
222
+ echo "Internal review-app handoff was not issued by the expected successful redispatch step." >&2
223
+ exit 1
224
+ fi
225
+
226
+ printf '%s\n' 'reuse=true' >> "$GITHUB_OUTPUT"
227
+ exit 0
228
+ fi
229
+
230
+ run_created_at="$(
231
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${RUN_ID}" --jq '.created_at'
232
+ )"
233
+ if [[ ! "${run_created_at}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
234
+ echo "GitHub returned an invalid workflow run creation time." >&2
235
+ exit 1
236
+ fi
237
+
238
+ intent_json="$(
239
+ jq -cn \
240
+ --argjson pr "${PR_NUMBER}" \
241
+ --arg operation "${OPERATION}" \
242
+ --arg event "${EVENT_NAME}" \
243
+ --arg actor "${SOURCE_ACTOR}" \
244
+ --argjson run_id "${RUN_ID}" \
245
+ --arg created_at "${run_created_at}" \
246
+ '{pr: $pr, operation: $operation, event: $event, actor: $actor, run_id: $run_id, created_at: $created_at}'
247
+ )"
248
+ intent_body="<!-- cpflow-review-app-intent-v1 ${intent_json} -->"
249
+ printf '%s\n' "body=${intent_body}" 'record=true' >> "$GITHUB_OUTPUT"
250
+
251
+ - name: Record accepted review app intent
252
+ if: steps.intent.outputs.record == 'true'
253
+ id: record
254
+ shell: bash
255
+ env:
256
+ GH_TOKEN: ${{ github.token }}
257
+ GH_REPO: ${{ github.repository }}
258
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
259
+ INTENT_BODY: ${{ steps.intent.outputs.body }}
260
+ run: |
261
+ set -euo pipefail
262
+
263
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]] ||
264
+ [[ ! "${INTENT_BODY}" =~ ^\<\!--\ cpflow-review-app-intent-v1\ \{.*\}\ --\>$ ]]; then
265
+ echo "Invalid prepared review-app intent." >&2
266
+ exit 1
267
+ fi
268
+ gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
269
+ -f "body=${INTENT_BODY}"
270
+ printf '%s\n' 'accepted=true' >> "$GITHUB_OUTPUT"
271
+
25
272
  delete-review-app:
273
+ needs: authorize-comment-command
26
274
  if: |
27
- (github.event_name == 'issue_comment' &&
28
- github.event.issue.pull_request &&
29
- contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body) &&
30
- contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) ||
31
- (github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
32
- github.event_name == 'workflow_dispatch'
275
+ needs.authorize-comment-command.outputs.allowed == 'true' &&
276
+ ((github.event_name == 'issue_comment' &&
277
+ github.event.issue.pull_request &&
278
+ contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body)) ||
279
+ (github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
280
+ github.event_name == 'workflow_dispatch')
33
281
  runs-on: ubuntu-latest
34
282
  timeout-minutes: 15
283
+ concurrency:
284
+ group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
285
+ # Enter the shared deploy/delete queue only after comment authorization. A denied
286
+ # public comment must not displace a legitimate pending review-app operation.
287
+ # Let an in-flight deploy finish before deletion so it cannot publish a later success.
288
+ cancel-in-progress: false
35
289
 
36
290
  steps:
291
+ # GitHub keeps only one pending member of a concurrency group and may replace
292
+ # it without FIFO ordering. Reconcile the newest authenticated accepted intent so
293
+ # whichever job survives applies (or redispatches) the requested final state.
294
+ - name: Reconcile latest accepted review app intent
295
+ id: intent
296
+ shell: bash
297
+ env:
298
+ GH_TOKEN: ${{ github.token }}
299
+ GH_REPO: ${{ github.repository }}
300
+ CURRENT_OPERATION: delete
301
+ run: |
302
+ set -euo pipefail
303
+
304
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
305
+ echo "Invalid pull request id while reconciling review-app intent." >&2
306
+ exit 1
307
+ fi
308
+ case "${CURRENT_OPERATION}" in
309
+ deploy|delete) ;;
310
+ *) echo "Invalid current review-app operation: ${CURRENT_OPERATION}" >&2; exit 1 ;;
311
+ esac
312
+
313
+ comments_json="$(
314
+ gh api --paginate --slurp --method GET \
315
+ "repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
316
+ )"
317
+ intents_json="$(
318
+ jq -c --argjson pr "${PR_NUMBER}" '
319
+ [
320
+ .[][]?
321
+ | select(.user.login == "github-actions[bot]")
322
+ | .body
323
+ | select(type == "string")
324
+ | select(startswith("<!-- cpflow-review-app-intent-v1 ") and endswith(" -->"))
325
+ | ltrimstr("<!-- cpflow-review-app-intent-v1 ")
326
+ | rtrimstr(" -->")
327
+ | fromjson?
328
+ | select(.pr == $pr)
329
+ | select(
330
+ (.operation == "deploy" and
331
+ (.event == "issue_comment" or
332
+ .event == "workflow_dispatch" or
333
+ .event == "pull_request")) or
334
+ (.operation == "delete" and
335
+ (.event == "issue_comment" or
336
+ .event == "workflow_dispatch" or
337
+ .event == "pull_request_target"))
338
+ )
339
+ | select((.actor | type) == "string" and (.actor | length) > 0)
340
+ | select((.run_id | type) == "number" and .run_id > 0 and (.run_id | floor) == .run_id)
341
+ | select(
342
+ (.created_at | type) == "string" and
343
+ (.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
344
+ )
345
+ ]
346
+ | sort_by([.created_at, .run_id])
347
+ | reverse
348
+ ' <<< "${comments_json}"
349
+ )"
350
+ latest_intent="$(jq -c '.[0] // empty' <<< "${intents_json}")"
351
+ if [[ -z "${latest_intent}" ]]; then
352
+ echo "No accepted review-app intent exists for PR #${PR_NUMBER}." >&2
353
+ exit 1
354
+ fi
355
+
356
+ latest_operation="$(jq -r '.operation' <<< "${latest_intent}")"
357
+ latest_event="$(jq -r '.event' <<< "${latest_intent}")"
358
+ latest_actor="$(jq -r '.actor' <<< "${latest_intent}")"
359
+ latest_run_id="$(jq -r '.run_id' <<< "${latest_intent}")"
360
+ latest_created_at="$(jq -r '.created_at' <<< "${latest_intent}")"
361
+ expected_workflow_path=".github/workflows/cpflow-${latest_operation}-review-app.yml"
362
+ case "${latest_operation}" in
363
+ deploy) expected_run_name="Deploy Review App - PR #${PR_NUMBER}" ;;
364
+ delete) expected_run_name="Delete Review App - PR #${PR_NUMBER}" ;;
365
+ esac
366
+ source_run_json="$(
367
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${latest_run_id}"
368
+ )"
369
+ if ! jq -e \
370
+ --argjson run_id "${latest_run_id}" \
371
+ --arg event "${latest_event}" \
372
+ --arg actor "${latest_actor}" \
373
+ --arg created_at "${latest_created_at}" \
374
+ --arg path "${expected_workflow_path}" \
375
+ --arg title "${expected_run_name}" \
376
+ --arg repo "${GH_REPO}" \
377
+ --argjson pr "${PR_NUMBER}" \
378
+ '(.id == $run_id) and
379
+ (.event == $event) and
380
+ (.actor.login == $actor) and
381
+ (.created_at == $created_at) and
382
+ (.path == $path) and
383
+ (.display_title == $title) and
384
+ ($event != "pull_request" or
385
+ (([.pull_requests[]?.number] | index($pr)) != null and
386
+ .head_repository.full_name == $repo))' \
387
+ <<< "${source_run_json}" >/dev/null
388
+ then
389
+ echo "Latest review-app intent does not match its originating workflow run." >&2
390
+ exit 1
391
+ fi
392
+ source_jobs_json="$(
393
+ gh api --paginate --slurp --method GET \
394
+ "repos/${GH_REPO}/actions/runs/${latest_run_id}/jobs?filter=all&per_page=100"
395
+ )"
396
+ if ! jq -e '
397
+ [
398
+ .[]?.jobs[]?
399
+ | select(
400
+ .name == "authorize-comment-command" or
401
+ (.name | endswith(" / authorize-comment-command"))
402
+ )
403
+ | .steps[]?
404
+ | select(.name == "Record accepted review app intent" and .conclusion == "success")
405
+ ]
406
+ | length > 0
407
+ ' <<< "${source_jobs_json}" >/dev/null
408
+ then
409
+ echo "Latest review-app intent was not emitted by a successful recording step." >&2
410
+ exit 1
411
+ fi
412
+ if [[ "${latest_event}" == "issue_comment" || "${latest_event}" == "workflow_dispatch" ]]; then
413
+ permission="$(
414
+ gh api --method GET \
415
+ "repos/${GH_REPO}/collaborators/${latest_actor}/permission" \
416
+ --jq '.permission'
417
+ )"
418
+ case "${permission}" in
419
+ write|maintain|admin) ;;
420
+ read|triage|none)
421
+ echo "Latest review-app intent actor no longer has permission to run it: ${permission}." >&2
422
+ exit 1
423
+ ;;
424
+ "")
425
+ echo "GitHub returned an empty permission for the latest review-app intent actor." >&2
426
+ exit 1
427
+ ;;
428
+ *)
429
+ echo "Unexpected repository permission for the latest review-app intent actor: ${permission}" >&2
430
+ exit 1
431
+ ;;
432
+ esac
433
+ fi
434
+
435
+ if [[ "${latest_operation}" != "${CURRENT_OPERATION}" ]]; then
436
+ printf '%s\n' \
437
+ 'redispatch=true' \
438
+ "operation=${latest_operation}" \
439
+ "run_id=${latest_run_id}" >> "$GITHUB_OUTPUT"
440
+ echo "Latest accepted ${latest_operation} intent requires a canonical redispatch."
441
+ exit 0
442
+ fi
443
+
444
+ printf '%s\n' \
445
+ 'redispatch=false' \
446
+ "operation=${latest_operation}" \
447
+ "run_id=${latest_run_id}" \
448
+ "event=${latest_event}" >> "$GITHUB_OUTPUT"
449
+ echo "Applying latest accepted ${latest_operation} intent from workflow run ${latest_run_id}."
450
+
451
+ - name: Dispatch latest accepted review app intent
452
+ id: redispatch
453
+ if: steps.intent.outputs.redispatch == 'true'
454
+ shell: bash
455
+ env:
456
+ GH_TOKEN: ${{ github.token }}
457
+ GH_REPO: ${{ github.repository }}
458
+ SOURCE_OPERATION: delete
459
+ TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
460
+ INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
461
+ run: |
462
+ set -euo pipefail
463
+
464
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${INTENT_RUN_ID}" =~ ^[0-9]+$ ||
465
+ ! "${GITHUB_RUN_ID}" =~ ^[0-9]+$ ]]; then
466
+ echo "Invalid run identity while redispatching review-app intent." >&2
467
+ exit 1
468
+ fi
469
+ case "${SOURCE_OPERATION}:${TARGET_OPERATION}" in
470
+ deploy:delete|delete:deploy) ;;
471
+ *) echo "Invalid review-app redispatch operation pair." >&2; exit 1 ;;
472
+ esac
473
+
474
+ default_branch="$(gh api --method GET "repos/${GH_REPO}" --jq '.default_branch')"
475
+ if [[ -z "${default_branch}" ]]; then
476
+ echo "GitHub returned an empty default branch while redispatching review-app intent." >&2
477
+ exit 1
478
+ fi
479
+ workflow_file="cpflow-${TARGET_OPERATION}-review-app.yml"
480
+ dispatch_json="$(
481
+ gh api --method POST \
482
+ -H "X-GitHub-Api-Version: 2026-03-10" \
483
+ "repos/${GH_REPO}/actions/workflows/${workflow_file}/dispatches" \
484
+ -f "ref=${default_branch}" \
485
+ -f "inputs[pr_number]=${PR_NUMBER}" \
486
+ -f "inputs[reconcile_intent_run_id]=${INTENT_RUN_ID}"
487
+ )"
488
+ dispatched_run_id="$(jq -r '.workflow_run_id // empty' <<< "${dispatch_json}")"
489
+ if [[ ! "${dispatched_run_id}" =~ ^[0-9]+$ ]]; then
490
+ echo "GitHub did not return a valid workflow run id for the review-app redispatch." >&2
491
+ exit 1
492
+ fi
493
+ source_created_at="$(
494
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at'
495
+ )"
496
+ if [[ ! "${source_created_at}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
497
+ echo "GitHub returned an invalid source run creation time for the review-app redispatch." >&2
498
+ exit 1
499
+ fi
500
+ handoff_json="$(
501
+ jq -cn \
502
+ --argjson pr "${PR_NUMBER}" \
503
+ --arg operation "${TARGET_OPERATION}" \
504
+ --argjson intent_run_id "${INTENT_RUN_ID}" \
505
+ --argjson dispatch_run_id "${dispatched_run_id}" \
506
+ --argjson source_run_id "${GITHUB_RUN_ID}" \
507
+ --arg created_at "${source_created_at}" \
508
+ '{pr: $pr, operation: $operation, intent_run_id: $intent_run_id,
509
+ dispatch_run_id: $dispatch_run_id, source_run_id: $source_run_id,
510
+ created_at: $created_at}'
511
+ )"
512
+ handoff_body="<!-- cpflow-review-app-redispatch-v1 ${handoff_json} -->"
513
+ gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
514
+ -f "body=${handoff_body}"
515
+ echo "Redispatched ${TARGET_OPERATION} for latest accepted review-app intent run ${INTENT_RUN_ID}."
516
+
517
+ - name: Stop superseded review app operation
518
+ if: steps.intent.outputs.redispatch == 'true'
519
+ shell: bash
520
+ env:
521
+ TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
522
+ INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
523
+ run: |
524
+ echo "Stopped superseded operation after redispatching ${TARGET_OPERATION} intent ${INTENT_RUN_ID}." >&2
525
+ exit 1
526
+
527
+ # Authorization runs outside the shared queue. For automatic close cleanup,
528
+ # re-read live PR state after this job acquires concurrency so a stale close
529
+ # event cannot delete an app that a later reopen deployment already restored.
530
+ - name: Verify automatic deletion still targets a closed pull request
531
+ if: steps.intent.outputs.event == 'pull_request_target'
532
+ shell: bash
533
+ env:
534
+ GH_TOKEN: ${{ github.token }}
535
+ GH_REPO: ${{ github.repository }}
536
+ run: |
537
+ set -euo pipefail
538
+
539
+ pr_state="$(gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.state')"
540
+ if [[ "${pr_state}" != "closed" ]]; then
541
+ echo "Refusing stale automatic review-app deletion because PR #${PR_NUMBER} is ${pr_state:-unknown}." >&2
542
+ exit 1
543
+ fi
544
+
37
545
  - name: React to delete command
38
546
  if: github.event_name == 'issue_comment'
39
547
  continue-on-error: true
@@ -134,6 +642,7 @@ jobs:
134
642
  core.setOutput("comment-id", comment.data.id);
135
643
 
136
644
  - name: Delete review app
645
+ id: delete-app
137
646
  if: steps.config.outputs.ready == 'true'
138
647
  uses: ./.cpflow/.github/actions/cpflow-delete-control-plane-app
139
648
  with:
@@ -142,35 +651,107 @@ jobs:
142
651
  review_app_prefix: ${{ steps.review-config.outputs.review_app_prefix }}
143
652
  working_directory: app
144
653
 
145
- # Finalizer still runs after delete failures, but only after config validation
146
- # created the initial PR comment and workflow link env vars it updates.
654
+ - name: Deactivate GitHub review deployments
655
+ id: deactivate-deployments
656
+ if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success'
657
+ continue-on-error: true
658
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
659
+ env:
660
+ APP_NAME: ${{ steps.review-config.outputs.app_name }}
661
+ with:
662
+ script: |
663
+ const environment = `review/${process.env.APP_NAME}`;
664
+ const deployments = await github.paginate(github.rest.repos.listDeployments, {
665
+ owner: context.repo.owner,
666
+ repo: context.repo.repo,
667
+ environment,
668
+ per_page: 100
669
+ });
670
+
671
+ if (deployments.length === 0) {
672
+ core.info(`No GitHub deployments found for ${environment}.`);
673
+ return;
674
+ }
675
+
676
+ let deactivatedCount = 0;
677
+ let alreadyInactiveCount = 0;
678
+
679
+ for (const deployment of deployments) {
680
+ const { data: statuses } = await github.rest.repos.listDeploymentStatuses({
681
+ owner: context.repo.owner,
682
+ repo: context.repo.repo,
683
+ deployment_id: deployment.id,
684
+ per_page: 1
685
+ });
686
+ const [latestStatus] = statuses;
687
+
688
+ if (latestStatus?.state === "inactive") {
689
+ alreadyInactiveCount += 1;
690
+ continue;
691
+ }
692
+
693
+ await github.rest.repos.createDeploymentStatus({
694
+ owner: context.repo.owner,
695
+ repo: context.repo.repo,
696
+ deployment_id: deployment.id,
697
+ state: "inactive",
698
+ environment,
699
+ description: "Review app deleted"
700
+ });
701
+ deactivatedCount += 1;
702
+ }
703
+
704
+ core.info(
705
+ `Deactivated ${deactivatedCount} GitHub deployment(s) for ${environment}; ` +
706
+ `skipped ${alreadyInactiveCount} already inactive deployment(s).`
707
+ );
708
+
709
+ # Finalizer still runs after delete or deployment-cleanup failures, but only after
710
+ # config validation created the initial PR comment and workflow link env vars it updates.
147
711
  - name: Finalize delete status
148
712
  if: always() && steps.config.outputs.ready == 'true'
149
713
  uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
150
714
  env:
715
+ APP_NAME: ${{ steps.review-config.outputs.app_name }}
151
716
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
152
- JOB_STATUS: ${{ job.status }}
717
+ DELETE_OUTCOME: ${{ steps.delete-app.outcome }}
718
+ DEACTIVATION_OUTCOME: ${{ steps.deactivate-deployments.outcome }}
153
719
  with:
154
720
  script: |
155
721
  const commentId = Number(process.env.COMMENT_ID);
156
- const success = process.env.JOB_STATUS === "success";
157
- const body = success
158
- ? [
159
- "## ✅ Review App Deleted",
160
- "",
161
- `_Review app for PR #${process.env.PR_NUMBER} is deleted_`,
162
- "",
163
- `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
164
- `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
165
- ].join("\n")
166
- : [
167
- "## Failed to Delete Review App",
168
- "",
169
- `_Failed to delete review app for PR #${process.env.PR_NUMBER}_`,
170
- "",
171
- `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
172
- `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
173
- ].join("\n");
722
+ const deleteSucceeded = process.env.DELETE_OUTCOME === "success";
723
+ const deactivationSucceeded = process.env.DEACTIVATION_OUTCOME === "success";
724
+ let body;
725
+
726
+ if (!deleteSucceeded) {
727
+ body = [
728
+ "## ❌ Failed to Delete Review App",
729
+ "",
730
+ `_Failed to delete review app for PR #${process.env.PR_NUMBER}_`,
731
+ "",
732
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
733
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
734
+ ].join("\n");
735
+ } else if (!deactivationSucceeded) {
736
+ body = [
737
+ "## ⚠️ Review App Deleted, GitHub Deployment Cleanup Failed",
738
+ "",
739
+ `_Review app for PR #${process.env.PR_NUMBER} was deleted from Control Plane, but GitHub deployments for \`review/${process.env.APP_NAME}\` could not be marked inactive_`,
740
+ "",
741
+ "Re-run this deletion workflow to retry the GitHub deployment cleanup.",
742
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
743
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
744
+ ].join("\n");
745
+ } else {
746
+ body = [
747
+ "## ✅ Review App Deleted",
748
+ "",
749
+ `_Review app for PR #${process.env.PR_NUMBER} is deleted_`,
750
+ "",
751
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
752
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
753
+ ].join("\n");
754
+ }
174
755
 
175
756
  if (!Number.isFinite(commentId) || commentId <= 0) {
176
757
  core.warning("Skipping delete status comment update because no comment id was created.");
@@ -183,3 +764,10 @@ jobs:
183
764
  comment_id: commentId,
184
765
  body
185
766
  });
767
+
768
+ - name: Fail when GitHub deployment cleanup failed
769
+ if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success' && steps.deactivate-deployments.outcome == 'failure'
770
+ shell: bash
771
+ run: |
772
+ echo "Review app was deleted, but GitHub deployments could not be marked inactive." >&2
773
+ exit 1