cpflow 5.2.0 → 6.0.0.rc.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -2
  3. data/.agents/bin/README.md +16 -4
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.coderabbit.yaml +13 -0
  10. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  11. data/.github/actions/cpflow-setup-environment/action.yml +9 -7
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/dependabot.yml +10 -0
  14. data/.github/pull_request_template.md +18 -0
  15. data/.github/workflows/check_cpln_links.yml +6 -1
  16. data/.github/workflows/claude-code-review.yml +5 -2
  17. data/.github/workflows/claude.yml +97 -4
  18. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  19. data/.github/workflows/command_docs.yml +7 -2
  20. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  21. data/.github/workflows/cpflow-delete-review-app.yml +638 -43
  22. data/.github/workflows/cpflow-deploy-review-app.yml +665 -36
  23. data/.github/workflows/cpflow-deploy-staging.yml +16 -10
  24. data/.github/workflows/cpflow-help-command.yml +3 -3
  25. data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  26. data/.github/workflows/cpflow-review-app-help.yml +6 -14
  27. data/.github/workflows/rspec-shared.yml +34 -26
  28. data/.github/workflows/rspec-specific.yml +10 -2
  29. data/.github/workflows/rspec.yml +71 -4
  30. data/.github/workflows/rubocop.yml +9 -2
  31. data/.github/workflows/trigger-docs-site.yml +2 -0
  32. data/.rubocop.yml +6 -1
  33. data/AGENTS.md +6 -49
  34. data/CHANGELOG.md +56 -1
  35. data/CONTRIBUTING.md +48 -3
  36. data/Gemfile.lock +1 -1
  37. data/README.md +6 -1
  38. data/cpflow.gemspec +3 -15
  39. data/docs/ai-github-flow-prompt.md +2 -2
  40. data/docs/ci-automation.md +216 -99
  41. data/docs/commands.md +36 -9
  42. data/docs/rds-private-networking.md +12 -12
  43. data/docs/releasing.md +15 -4
  44. data/docs/secrets-and-env-values.md +8 -0
  45. data/docs/tips.md +18 -0
  46. data/examples/controlplane.yml +5 -0
  47. data/lib/command/ai_github_flow_prompt.rb +1 -1
  48. data/lib/command/apply_template.rb +104 -2
  49. data/lib/command/base.rb +52 -3
  50. data/lib/command/cleanup_stale_apps.rb +28 -4
  51. data/lib/command/copy_image_from_upstream.rb +3 -0
  52. data/lib/command/deploy_image.rb +54 -3
  53. data/lib/command/generate_github_actions.rb +36 -12
  54. data/lib/command/run.rb +357 -44
  55. data/lib/command/setup_app.rb +10 -5
  56. data/lib/command/update_github_actions.rb +7 -6
  57. data/lib/core/controlplane.rb +179 -79
  58. data/lib/core/controlplane_api.rb +8 -0
  59. data/lib/core/controlplane_api_direct.rb +257 -63
  60. data/lib/core/shell.rb +39 -7
  61. data/lib/core/timed_command.rb +111 -0
  62. data/lib/cpflow/version.rb +1 -1
  63. data/lib/cpflow.rb +1 -1
  64. data/lib/github_flow_templates/.github/cpflow-help.md +24 -10
  65. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  66. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  68. data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
  69. data/lib/patches/hash.rb +2 -2
  70. data/rakelib/create_release.rake +14 -14
  71. data/script/check_shell_scripts +66 -0
  72. metadata +12 -18
@@ -8,36 +8,544 @@ 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
40
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
548
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
41
549
  with:
42
550
  script: |
43
551
  try {
@@ -55,8 +563,15 @@ jobs:
55
563
  }
56
564
  }
57
565
 
566
+ # No explicit ref: checkout's default resolves to this event's recorded commit
567
+ # (GITHUB_SHA; the base-branch tip under pull_request_target) and is exempt from checkout v7's fork-PR guard.
568
+ - name: Checkout generated cpflow actions
569
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
570
+ with:
571
+ persist-credentials: false
572
+
58
573
  - name: Checkout control-plane-flow actions
59
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
574
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
60
575
  with:
61
576
  repository: ${{ job.workflow_repository }}
62
577
  ref: ${{ job.workflow_sha }}
@@ -65,7 +580,7 @@ jobs:
65
580
 
66
581
  - name: Validate required secrets and variables
67
582
  id: config
68
- uses: ./.cpflow/.github/actions/cpflow-validate-config
583
+ uses: ./.github/actions/cpflow-validate-config
69
584
  env:
70
585
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
71
586
  with:
@@ -75,7 +590,7 @@ jobs:
75
590
 
76
591
  - name: Checkout repository
77
592
  if: steps.config.outputs.ready == 'true'
78
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
593
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
79
594
  with:
80
595
  path: app
81
596
  persist-credentials: false
@@ -83,7 +598,7 @@ jobs:
83
598
  - name: Resolve review app config
84
599
  if: steps.config.outputs.ready == 'true'
85
600
  id: review-config
86
- uses: ./.cpflow/.github/actions/cpflow-resolve-review-config
601
+ uses: ./.github/actions/cpflow-resolve-review-config
87
602
  with:
88
603
  working_directory: app
89
604
  configured_cpln_org_staging: ${{ vars.CPLN_ORG_STAGING }}
@@ -92,7 +607,7 @@ jobs:
92
607
 
93
608
  - name: Setup environment
94
609
  if: steps.config.outputs.ready == 'true'
95
- uses: ./.cpflow/.github/actions/cpflow-setup-environment
610
+ uses: ./.github/actions/cpflow-setup-environment
96
611
  with:
97
612
  token: ${{ secrets.CPLN_TOKEN_STAGING }}
98
613
  org: ${{ steps.review-config.outputs.cpln_org }}
@@ -103,7 +618,7 @@ jobs:
103
618
 
104
619
  - name: Set workflow links
105
620
  if: steps.config.outputs.ready == 'true'
106
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
621
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
107
622
  with:
108
623
  script: |
109
624
  const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
@@ -116,7 +631,7 @@ jobs:
116
631
  - name: Create initial PR comment
117
632
  if: steps.config.outputs.ready == 'true'
118
633
  id: create-comment
119
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
634
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
120
635
  with:
121
636
  script: |
122
637
  const body = [
@@ -134,43 +649,116 @@ jobs:
134
649
  core.setOutput("comment-id", comment.data.id);
135
650
 
136
651
  - name: Delete review app
652
+ id: delete-app
137
653
  if: steps.config.outputs.ready == 'true'
138
- uses: ./.cpflow/.github/actions/cpflow-delete-control-plane-app
654
+ uses: ./.github/actions/cpflow-delete-control-plane-app
139
655
  with:
140
656
  app_name: ${{ steps.review-config.outputs.app_name }}
141
657
  cpln_org: ${{ steps.review-config.outputs.cpln_org }}
142
658
  review_app_prefix: ${{ steps.review-config.outputs.review_app_prefix }}
143
659
  working_directory: app
144
660
 
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.
661
+ - name: Deactivate GitHub review deployments
662
+ id: deactivate-deployments
663
+ if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success'
664
+ continue-on-error: true
665
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
666
+ env:
667
+ APP_NAME: ${{ steps.review-config.outputs.app_name }}
668
+ with:
669
+ script: |
670
+ const environment = `review/${process.env.APP_NAME}`;
671
+ const deployments = await github.paginate(github.rest.repos.listDeployments, {
672
+ owner: context.repo.owner,
673
+ repo: context.repo.repo,
674
+ environment,
675
+ per_page: 100
676
+ });
677
+
678
+ if (deployments.length === 0) {
679
+ core.info(`No GitHub deployments found for ${environment}.`);
680
+ return;
681
+ }
682
+
683
+ let deactivatedCount = 0;
684
+ let alreadyInactiveCount = 0;
685
+
686
+ for (const deployment of deployments) {
687
+ const { data: statuses } = await github.rest.repos.listDeploymentStatuses({
688
+ owner: context.repo.owner,
689
+ repo: context.repo.repo,
690
+ deployment_id: deployment.id,
691
+ per_page: 1
692
+ });
693
+ const [latestStatus] = statuses;
694
+
695
+ if (latestStatus?.state === "inactive") {
696
+ alreadyInactiveCount += 1;
697
+ continue;
698
+ }
699
+
700
+ await github.rest.repos.createDeploymentStatus({
701
+ owner: context.repo.owner,
702
+ repo: context.repo.repo,
703
+ deployment_id: deployment.id,
704
+ state: "inactive",
705
+ environment,
706
+ description: "Review app deleted"
707
+ });
708
+ deactivatedCount += 1;
709
+ }
710
+
711
+ core.info(
712
+ `Deactivated ${deactivatedCount} GitHub deployment(s) for ${environment}; ` +
713
+ `skipped ${alreadyInactiveCount} already inactive deployment(s).`
714
+ );
715
+
716
+ # Finalizer still runs after delete or deployment-cleanup failures, but only after
717
+ # config validation created the initial PR comment and workflow link env vars it updates.
147
718
  - name: Finalize delete status
148
719
  if: always() && steps.config.outputs.ready == 'true'
149
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
720
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
150
721
  env:
722
+ APP_NAME: ${{ steps.review-config.outputs.app_name }}
151
723
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
152
- JOB_STATUS: ${{ job.status }}
724
+ DELETE_OUTCOME: ${{ steps.delete-app.outcome }}
725
+ DEACTIVATION_OUTCOME: ${{ steps.deactivate-deployments.outcome }}
153
726
  with:
154
727
  script: |
155
728
  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");
729
+ const deleteSucceeded = process.env.DELETE_OUTCOME === "success";
730
+ const deactivationSucceeded = process.env.DEACTIVATION_OUTCOME === "success";
731
+ let body;
732
+
733
+ if (!deleteSucceeded) {
734
+ body = [
735
+ "## ❌ Failed to Delete Review App",
736
+ "",
737
+ `_Failed to delete review app for PR #${process.env.PR_NUMBER}_`,
738
+ "",
739
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
740
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
741
+ ].join("\n");
742
+ } else if (!deactivationSucceeded) {
743
+ body = [
744
+ "## ⚠️ Review App Deleted, GitHub Deployment Cleanup Failed",
745
+ "",
746
+ `_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_`,
747
+ "",
748
+ "Re-run this deletion workflow to retry the GitHub deployment cleanup.",
749
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
750
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
751
+ ].join("\n");
752
+ } else {
753
+ body = [
754
+ "## ✅ Review App Deleted",
755
+ "",
756
+ `_Review app for PR #${process.env.PR_NUMBER} is deleted_`,
757
+ "",
758
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
759
+ `📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
760
+ ].join("\n");
761
+ }
174
762
 
175
763
  if (!Number.isFinite(commentId) || commentId <= 0) {
176
764
  core.warning("Skipping delete status comment update because no comment id was created.");
@@ -183,3 +771,10 @@ jobs:
183
771
  comment_id: commentId,
184
772
  body
185
773
  });
774
+
775
+ - name: Fail when GitHub deployment cleanup failed
776
+ if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success' && steps.deactivate-deployments.outcome == 'failure'
777
+ shell: bash
778
+ run: |
779
+ echo "Review app was deleted, but GitHub deployments could not be marked inactive." >&2
780
+ exit 1