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
@@ -4,6 +4,10 @@ run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || githu
4
4
 
5
5
  on:
6
6
  workflow_call:
7
+ outputs:
8
+ image_built:
9
+ description: Whether this run successfully built the application image.
10
+ value: ${{ jobs.deploy.outputs.image_built }}
7
11
  secrets:
8
12
  CPLN_TOKEN_STAGING:
9
13
  description: Control Plane token for the staging org that owns review apps.
@@ -13,40 +17,576 @@ on:
13
17
  required: false
14
18
 
15
19
  permissions:
20
+ actions: write
16
21
  contents: read
17
22
  deployments: write
18
23
  issues: write
19
24
  pull-requests: write
20
25
 
21
- concurrency:
22
- group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
23
- # Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the
24
- # review app in a partially-deployed state (workload update in progress, rollout not
25
- # settled). Let an in-flight deploy finish before the next push starts a new run.
26
- cancel-in-progress: false
27
-
28
26
  env:
29
27
  PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
30
28
  PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }}
31
29
 
32
30
  jobs:
31
+ authorize-comment-command:
32
+ runs-on: ubuntu-latest
33
+ timeout-minutes: 5
34
+ permissions:
35
+ actions: read
36
+ # PR issue comments use the Issues API path but require PR write access.
37
+ pull-requests: write
38
+ outputs:
39
+ allowed: ${{ steps.record.outputs.accepted || steps.intent.outputs.reuse }}
40
+ steps:
41
+ - name: Resolve comment author repository permission
42
+ if: github.event_name == 'issue_comment'
43
+ id: permission
44
+ shell: bash
45
+ env:
46
+ GH_TOKEN: ${{ github.token }}
47
+ GH_REPO: ${{ github.repository }}
48
+ COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
49
+ run: |
50
+ set -euo pipefail
51
+
52
+ printf '%s\n' 'allowed=false' >> "$GITHUB_OUTPUT"
53
+ permission="$(gh api --method GET "repos/${GH_REPO}/collaborators/${COMMENT_AUTHOR}/permission" --jq '.permission')"
54
+
55
+ if [[ -z "${permission}" ]]; then
56
+ echo "GitHub returned an empty repository permission." >&2
57
+ exit 1
58
+ fi
59
+
60
+ case "${permission}" in
61
+ write|maintain|admin)
62
+ printf '%s\n' 'allowed=true' >> "$GITHUB_OUTPUT"
63
+ ;;
64
+ read|triage|none)
65
+ echo "Comment author repository permission does not allow review-app commands: ${permission}"
66
+ ;;
67
+ *)
68
+ echo "Unexpected repository permission: ${permission}" >&2
69
+ exit 1
70
+ ;;
71
+ esac
72
+
73
+ - name: Allow authenticated non-comment trigger
74
+ if: github.event_name != 'issue_comment'
75
+ id: non-comment
76
+ shell: bash
77
+ run: echo "allowed=true" >> "$GITHUB_OUTPUT"
78
+
79
+ # Persist a durable accepted-event marker before queue admission. The
80
+ # original comment may later be edited/deleted, and authorization jobs can
81
+ # finish out of event order, so the marker carries the workflow run's own
82
+ # creation time and id rather than relying on marker creation order.
83
+ - name: Prepare accepted review app intent
84
+ id: intent
85
+ if: |
86
+ (steps.permission.outputs.allowed == 'true' || steps.non-comment.outputs.allowed == 'true') &&
87
+ ((github.event_name == 'issue_comment' &&
88
+ github.event.issue.pull_request &&
89
+ contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body)) ||
90
+ (github.event_name == 'pull_request' &&
91
+ contains(fromJson('["opened","synchronize","reopened"]'), github.event.action)) ||
92
+ github.event_name == 'workflow_dispatch')
93
+ shell: bash
94
+ env:
95
+ GH_TOKEN: ${{ github.token }}
96
+ GH_REPO: ${{ github.repository }}
97
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
98
+ OPERATION: deploy
99
+ EVENT_NAME: ${{ github.event_name }}
100
+ SOURCE_ACTOR: ${{ github.event.comment.user.login || github.actor }}
101
+ RUN_ID: ${{ github.run_id }}
102
+ RECONCILE_INTENT_RUN_ID: ${{ github.event.inputs.reconcile_intent_run_id }}
103
+ run: |
104
+ set -euo pipefail
105
+
106
+ printf '%s\n' 'record=false' 'reuse=false' >> "$GITHUB_OUTPUT"
107
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${RUN_ID}" =~ ^[0-9]+$ ]]; then
108
+ echo "Invalid pull request or workflow run id while recording review-app intent." >&2
109
+ exit 1
110
+ fi
111
+ case "${OPERATION}" in
112
+ deploy|delete) ;;
113
+ *) echo "Invalid review-app operation: ${OPERATION}" >&2; exit 1 ;;
114
+ esac
115
+ case "${EVENT_NAME}" in
116
+ issue_comment|workflow_dispatch|pull_request|pull_request_target) ;;
117
+ *) echo "Invalid review-app event: ${EVENT_NAME}" >&2; exit 1 ;;
118
+ esac
119
+ if [[ -z "${SOURCE_ACTOR}" ]]; then
120
+ echo "Review-app intent is missing its source actor." >&2
121
+ exit 1
122
+ fi
123
+
124
+ pull_request_json="$(
125
+ gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}"
126
+ )"
127
+ head_repository="$(jq -r '.head.repo.full_name // empty' <<< "${pull_request_json}")"
128
+ pull_request_state="$(jq -r '.state // empty' <<< "${pull_request_json}")"
129
+ case "${pull_request_state}" in
130
+ open) ;;
131
+ closed)
132
+ echo "Refusing review-app deploy intent because PR #${PR_NUMBER} is closed."
133
+ exit 0
134
+ ;;
135
+ "")
136
+ echo "GitHub returned an empty pull request state while preparing review-app deploy intent." >&2
137
+ exit 1
138
+ ;;
139
+ *)
140
+ echo "Unexpected pull request state while preparing review-app deploy intent: ${pull_request_state}" >&2
141
+ exit 1
142
+ ;;
143
+ esac
144
+ if [[ -z "${head_repository}" ]]; then
145
+ echo "GitHub returned an empty head repository while preparing review-app deploy intent." >&2
146
+ exit 1
147
+ fi
148
+ if [[ "${head_repository}" != "${GH_REPO}" ]]; then
149
+ echo "Review app deploys are skipped for fork pull requests."
150
+ exit 0
151
+ fi
152
+
153
+ if [[ -n "${RECONCILE_INTENT_RUN_ID}" ]]; then
154
+ if [[ ! "${RECONCILE_INTENT_RUN_ID}" =~ ^[0-9]+$ ]]; then
155
+ echo "Invalid internal review-app sequencing token." >&2
156
+ exit 1
157
+ fi
158
+
159
+ handoff=""
160
+ for _attempt in {1..10}; do
161
+ comments_json="$(
162
+ gh api --paginate --slurp --method GET \
163
+ "repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
164
+ )"
165
+ handoff="$(
166
+ jq -c \
167
+ --argjson pr "${PR_NUMBER}" \
168
+ --arg operation "${OPERATION}" \
169
+ --argjson intent_run_id "${RECONCILE_INTENT_RUN_ID}" \
170
+ --argjson dispatch_run_id "${RUN_ID}" '
171
+ [
172
+ .[][]?
173
+ | select(.user.login == "github-actions[bot]")
174
+ | .body
175
+ | select(type == "string")
176
+ | select(startswith("<!-- cpflow-review-app-redispatch-v1 ") and endswith(" -->"))
177
+ | ltrimstr("<!-- cpflow-review-app-redispatch-v1 ")
178
+ | rtrimstr(" -->")
179
+ | fromjson?
180
+ | select(
181
+ .pr == $pr and
182
+ .operation == $operation and
183
+ .intent_run_id == $intent_run_id and
184
+ .dispatch_run_id == $dispatch_run_id
185
+ )
186
+ | select(
187
+ (.source_run_id | type) == "number" and
188
+ .source_run_id > 0 and
189
+ (.source_run_id | floor) == .source_run_id
190
+ )
191
+ | select(
192
+ (.created_at | type) == "string" and
193
+ (.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
194
+ )
195
+ ]
196
+ | if length == 1 then .[0] else empty end
197
+ ' <<< "${comments_json}"
198
+ )"
199
+ [[ -n "${handoff}" ]] && break
200
+ sleep 1
201
+ done
202
+ if [[ -z "${handoff}" ]]; then
203
+ echo "Internal review-app sequencing token is not bound to this workflow run." >&2
204
+ exit 1
205
+ fi
206
+
207
+ source_run_id="$(jq -r '.source_run_id' <<< "${handoff}")"
208
+ source_created_at="$(jq -r '.created_at' <<< "${handoff}")"
209
+ case "${OPERATION}" in
210
+ deploy)
211
+ source_operation="delete"
212
+ expected_run_name="Delete Review App - PR #${PR_NUMBER}"
213
+ expected_job_name="delete-review-app / delete-review-app"
214
+ ;;
215
+ delete)
216
+ source_operation="deploy"
217
+ expected_run_name="Deploy Review App - PR #${PR_NUMBER}"
218
+ expected_job_name="deploy / deploy"
219
+ ;;
220
+ esac
221
+ expected_workflow_path=".github/workflows/cpflow-${source_operation}-review-app.yml"
222
+ source_run_json="$(
223
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${source_run_id}"
224
+ )"
225
+ if ! jq -e \
226
+ --argjson run_id "${source_run_id}" \
227
+ --arg created_at "${source_created_at}" \
228
+ --arg path "${expected_workflow_path}" \
229
+ --arg title "${expected_run_name}" \
230
+ '(.id == $run_id) and
231
+ (.created_at == $created_at) and
232
+ (.path == $path) and
233
+ (.display_title == $title)' \
234
+ <<< "${source_run_json}" >/dev/null
235
+ then
236
+ echo "Internal review-app handoff does not match its source workflow run." >&2
237
+ exit 1
238
+ fi
239
+
240
+ handoff_verified=false
241
+ for _attempt in {1..10}; do
242
+ source_jobs_json="$(
243
+ gh api --paginate --slurp --method GET \
244
+ "repos/${GH_REPO}/actions/runs/${source_run_id}/jobs?filter=all&per_page=100"
245
+ )"
246
+ if jq -e --arg job_name "${expected_job_name}" '
247
+ [
248
+ .[]?.jobs[]?
249
+ | select(.name == $job_name)
250
+ | .steps[]?
251
+ | select(.name == "Dispatch latest accepted review app intent" and .conclusion == "success")
252
+ ]
253
+ | length > 0
254
+ ' <<< "${source_jobs_json}" >/dev/null
255
+ then
256
+ handoff_verified=true
257
+ break
258
+ fi
259
+ sleep 1
260
+ done
261
+ if [[ "${handoff_verified}" != "true" ]]; then
262
+ echo "Internal review-app handoff was not issued by the expected successful redispatch step." >&2
263
+ exit 1
264
+ fi
265
+
266
+ printf '%s\n' 'reuse=true' >> "$GITHUB_OUTPUT"
267
+ exit 0
268
+ fi
269
+
270
+ run_created_at="$(
271
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${RUN_ID}" --jq '.created_at'
272
+ )"
273
+ 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
274
+ echo "GitHub returned an invalid workflow run creation time." >&2
275
+ exit 1
276
+ fi
277
+
278
+ intent_json="$(
279
+ jq -cn \
280
+ --argjson pr "${PR_NUMBER}" \
281
+ --arg operation "${OPERATION}" \
282
+ --arg event "${EVENT_NAME}" \
283
+ --arg actor "${SOURCE_ACTOR}" \
284
+ --argjson run_id "${RUN_ID}" \
285
+ --arg created_at "${run_created_at}" \
286
+ '{pr: $pr, operation: $operation, event: $event, actor: $actor, run_id: $run_id, created_at: $created_at}'
287
+ )"
288
+ intent_body="<!-- cpflow-review-app-intent-v1 ${intent_json} -->"
289
+ printf '%s\n' "body=${intent_body}" 'record=true' >> "$GITHUB_OUTPUT"
290
+
291
+ - name: Record accepted review app intent
292
+ if: steps.intent.outputs.record == 'true'
293
+ id: record
294
+ shell: bash
295
+ env:
296
+ GH_TOKEN: ${{ github.token }}
297
+ GH_REPO: ${{ github.repository }}
298
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
299
+ INTENT_BODY: ${{ steps.intent.outputs.body }}
300
+ run: |
301
+ set -euo pipefail
302
+
303
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]] ||
304
+ [[ ! "${INTENT_BODY}" =~ ^\<\!--\ cpflow-review-app-intent-v1\ \{.*\}\ --\>$ ]]; then
305
+ echo "Invalid prepared review-app intent." >&2
306
+ exit 1
307
+ fi
308
+ gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
309
+ -f "body=${INTENT_BODY}"
310
+ printf '%s\n' 'accepted=true' >> "$GITHUB_OUTPUT"
311
+
33
312
  deploy:
313
+ needs: authorize-comment-command
34
314
  # Skip synchronize/opened events from fork PRs at the job level — they cannot access
35
315
  # repository secrets anyway, so running any steps just burns billable minutes. Users
36
- # can still manually deploy a fork PR via `+review-app-deploy` (gated below by
37
- # author_association) or workflow_dispatch.
316
+ # can still request a fork PR deploy via `+review-app-deploy` (gated above by
317
+ # repository permission) or workflow_dispatch, but the source guard below prevents it.
38
318
  if: |
39
- (github.event_name == 'pull_request' &&
40
- github.event.pull_request.head.repo.full_name == github.repository) ||
41
- github.event_name == 'workflow_dispatch' ||
42
- (github.event_name == 'issue_comment' &&
43
- github.event.issue.pull_request &&
44
- contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) &&
45
- contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
319
+ needs.authorize-comment-command.outputs.allowed == 'true' &&
320
+ ((github.event_name == 'pull_request' &&
321
+ github.event.pull_request.head.repo.full_name == github.repository) ||
322
+ github.event_name == 'workflow_dispatch' ||
323
+ (github.event_name == 'issue_comment' &&
324
+ github.event.issue.pull_request &&
325
+ contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body)))
46
326
  runs-on: ubuntu-latest
47
327
  timeout-minutes: 45
328
+ outputs:
329
+ image_built: ${{ steps.build-image.outcome == 'success' }}
330
+ concurrency:
331
+ group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
332
+ # Enter the shared deploy/delete queue only after comment authorization. A denied
333
+ # public comment must not displace a legitimate pending review-app operation.
334
+ # Let an in-flight deploy finish because cancellation can leave a partial rollout.
335
+ cancel-in-progress: false
48
336
 
49
337
  steps:
338
+ # GitHub keeps only one pending member of a concurrency group and may replace
339
+ # it without FIFO ordering. Reconcile the newest authenticated accepted intent so
340
+ # whichever job survives applies (or redispatches) the requested final state.
341
+ - name: Reconcile latest accepted review app intent
342
+ id: intent
343
+ shell: bash
344
+ env:
345
+ GH_TOKEN: ${{ github.token }}
346
+ GH_REPO: ${{ github.repository }}
347
+ CURRENT_OPERATION: deploy
348
+ run: |
349
+ set -euo pipefail
350
+
351
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
352
+ echo "Invalid pull request id while reconciling review-app intent." >&2
353
+ exit 1
354
+ fi
355
+ case "${CURRENT_OPERATION}" in
356
+ deploy|delete) ;;
357
+ *) echo "Invalid current review-app operation: ${CURRENT_OPERATION}" >&2; exit 1 ;;
358
+ esac
359
+
360
+ comments_json="$(
361
+ gh api --paginate --slurp --method GET \
362
+ "repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
363
+ )"
364
+ intents_json="$(
365
+ jq -c --argjson pr "${PR_NUMBER}" '
366
+ [
367
+ .[][]?
368
+ | select(.user.login == "github-actions[bot]")
369
+ | .body
370
+ | select(type == "string")
371
+ | select(startswith("<!-- cpflow-review-app-intent-v1 ") and endswith(" -->"))
372
+ | ltrimstr("<!-- cpflow-review-app-intent-v1 ")
373
+ | rtrimstr(" -->")
374
+ | fromjson?
375
+ | select(.pr == $pr)
376
+ | select(
377
+ (.operation == "deploy" and
378
+ (.event == "issue_comment" or
379
+ .event == "workflow_dispatch" or
380
+ .event == "pull_request")) or
381
+ (.operation == "delete" and
382
+ (.event == "issue_comment" or
383
+ .event == "workflow_dispatch" or
384
+ .event == "pull_request_target"))
385
+ )
386
+ | select((.actor | type) == "string" and (.actor | length) > 0)
387
+ | select((.run_id | type) == "number" and .run_id > 0 and (.run_id | floor) == .run_id)
388
+ | select(
389
+ (.created_at | type) == "string" and
390
+ (.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
391
+ )
392
+ ]
393
+ | sort_by([.created_at, .run_id])
394
+ | reverse
395
+ ' <<< "${comments_json}"
396
+ )"
397
+ latest_intent="$(jq -c '.[0] // empty' <<< "${intents_json}")"
398
+ if [[ -z "${latest_intent}" ]]; then
399
+ echo "No accepted review-app intent exists for PR #${PR_NUMBER}." >&2
400
+ exit 1
401
+ fi
402
+
403
+ latest_operation="$(jq -r '.operation' <<< "${latest_intent}")"
404
+ latest_event="$(jq -r '.event' <<< "${latest_intent}")"
405
+ latest_actor="$(jq -r '.actor' <<< "${latest_intent}")"
406
+ latest_run_id="$(jq -r '.run_id' <<< "${latest_intent}")"
407
+ latest_created_at="$(jq -r '.created_at' <<< "${latest_intent}")"
408
+ expected_workflow_path=".github/workflows/cpflow-${latest_operation}-review-app.yml"
409
+ case "${latest_operation}" in
410
+ deploy) expected_run_name="Deploy Review App - PR #${PR_NUMBER}" ;;
411
+ delete) expected_run_name="Delete Review App - PR #${PR_NUMBER}" ;;
412
+ esac
413
+ source_run_json="$(
414
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${latest_run_id}"
415
+ )"
416
+ if ! jq -e \
417
+ --argjson run_id "${latest_run_id}" \
418
+ --arg event "${latest_event}" \
419
+ --arg actor "${latest_actor}" \
420
+ --arg created_at "${latest_created_at}" \
421
+ --arg path "${expected_workflow_path}" \
422
+ --arg title "${expected_run_name}" \
423
+ --arg repo "${GH_REPO}" \
424
+ --argjson pr "${PR_NUMBER}" \
425
+ '(.id == $run_id) and
426
+ (.event == $event) and
427
+ (.actor.login == $actor) and
428
+ (.created_at == $created_at) and
429
+ (.path == $path) and
430
+ (.display_title == $title) and
431
+ ($event != "pull_request" or
432
+ (([.pull_requests[]?.number] | index($pr)) != null and
433
+ .head_repository.full_name == $repo))' \
434
+ <<< "${source_run_json}" >/dev/null
435
+ then
436
+ echo "Latest review-app intent does not match its originating workflow run." >&2
437
+ exit 1
438
+ fi
439
+ source_jobs_json="$(
440
+ gh api --paginate --slurp --method GET \
441
+ "repos/${GH_REPO}/actions/runs/${latest_run_id}/jobs?filter=all&per_page=100"
442
+ )"
443
+ if ! jq -e '
444
+ [
445
+ .[]?.jobs[]?
446
+ | select(
447
+ .name == "authorize-comment-command" or
448
+ (.name | endswith(" / authorize-comment-command"))
449
+ )
450
+ | .steps[]?
451
+ | select(.name == "Record accepted review app intent" and .conclusion == "success")
452
+ ]
453
+ | length > 0
454
+ ' <<< "${source_jobs_json}" >/dev/null
455
+ then
456
+ echo "Latest review-app intent was not emitted by a successful recording step." >&2
457
+ exit 1
458
+ fi
459
+ if [[ "${latest_event}" == "issue_comment" || "${latest_event}" == "workflow_dispatch" ]]; then
460
+ permission="$(
461
+ gh api --method GET \
462
+ "repos/${GH_REPO}/collaborators/${latest_actor}/permission" \
463
+ --jq '.permission'
464
+ )"
465
+ case "${permission}" in
466
+ write|maintain|admin) ;;
467
+ read|triage|none)
468
+ echo "Latest review-app intent actor no longer has permission to run it: ${permission}." >&2
469
+ exit 1
470
+ ;;
471
+ "")
472
+ echo "GitHub returned an empty permission for the latest review-app intent actor." >&2
473
+ exit 1
474
+ ;;
475
+ *)
476
+ echo "Unexpected repository permission for the latest review-app intent actor: ${permission}" >&2
477
+ exit 1
478
+ ;;
479
+ esac
480
+ fi
481
+
482
+ if [[ "${latest_operation}" != "${CURRENT_OPERATION}" ]]; then
483
+ printf '%s\n' \
484
+ 'redispatch=true' \
485
+ "operation=${latest_operation}" \
486
+ "run_id=${latest_run_id}" >> "$GITHUB_OUTPUT"
487
+ echo "Latest accepted ${latest_operation} intent requires a canonical redispatch."
488
+ exit 0
489
+ fi
490
+
491
+ printf '%s\n' \
492
+ 'redispatch=false' \
493
+ "operation=${latest_operation}" \
494
+ "run_id=${latest_run_id}" \
495
+ "event=${latest_event}" >> "$GITHUB_OUTPUT"
496
+ echo "Applying latest accepted ${latest_operation} intent from workflow run ${latest_run_id}."
497
+
498
+ - name: Dispatch latest accepted review app intent
499
+ id: redispatch
500
+ if: steps.intent.outputs.redispatch == 'true'
501
+ shell: bash
502
+ env:
503
+ GH_TOKEN: ${{ github.token }}
504
+ GH_REPO: ${{ github.repository }}
505
+ SOURCE_OPERATION: deploy
506
+ TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
507
+ INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
508
+ run: |
509
+ set -euo pipefail
510
+
511
+ if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${INTENT_RUN_ID}" =~ ^[0-9]+$ ||
512
+ ! "${GITHUB_RUN_ID}" =~ ^[0-9]+$ ]]; then
513
+ echo "Invalid run identity while redispatching review-app intent." >&2
514
+ exit 1
515
+ fi
516
+ case "${SOURCE_OPERATION}:${TARGET_OPERATION}" in
517
+ deploy:delete|delete:deploy) ;;
518
+ *) echo "Invalid review-app redispatch operation pair." >&2; exit 1 ;;
519
+ esac
520
+
521
+ default_branch="$(gh api --method GET "repos/${GH_REPO}" --jq '.default_branch')"
522
+ if [[ -z "${default_branch}" ]]; then
523
+ echo "GitHub returned an empty default branch while redispatching review-app intent." >&2
524
+ exit 1
525
+ fi
526
+ workflow_file="cpflow-${TARGET_OPERATION}-review-app.yml"
527
+ dispatch_json="$(
528
+ gh api --method POST \
529
+ -H "X-GitHub-Api-Version: 2026-03-10" \
530
+ "repos/${GH_REPO}/actions/workflows/${workflow_file}/dispatches" \
531
+ -f "ref=${default_branch}" \
532
+ -f "inputs[pr_number]=${PR_NUMBER}" \
533
+ -f "inputs[reconcile_intent_run_id]=${INTENT_RUN_ID}"
534
+ )"
535
+ dispatched_run_id="$(jq -r '.workflow_run_id // empty' <<< "${dispatch_json}")"
536
+ if [[ ! "${dispatched_run_id}" =~ ^[0-9]+$ ]]; then
537
+ echo "GitHub did not return a valid workflow run id for the review-app redispatch." >&2
538
+ exit 1
539
+ fi
540
+ source_created_at="$(
541
+ gh api --method GET "repos/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at'
542
+ )"
543
+ 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
544
+ echo "GitHub returned an invalid source run creation time for the review-app redispatch." >&2
545
+ exit 1
546
+ fi
547
+ handoff_json="$(
548
+ jq -cn \
549
+ --argjson pr "${PR_NUMBER}" \
550
+ --arg operation "${TARGET_OPERATION}" \
551
+ --argjson intent_run_id "${INTENT_RUN_ID}" \
552
+ --argjson dispatch_run_id "${dispatched_run_id}" \
553
+ --argjson source_run_id "${GITHUB_RUN_ID}" \
554
+ --arg created_at "${source_created_at}" \
555
+ '{pr: $pr, operation: $operation, intent_run_id: $intent_run_id,
556
+ dispatch_run_id: $dispatch_run_id, source_run_id: $source_run_id,
557
+ created_at: $created_at}'
558
+ )"
559
+ handoff_body="<!-- cpflow-review-app-redispatch-v1 ${handoff_json} -->"
560
+ gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
561
+ -f "body=${handoff_body}"
562
+ echo "Redispatched ${TARGET_OPERATION} for latest accepted review-app intent run ${INTENT_RUN_ID}."
563
+
564
+ - name: Stop superseded review app operation
565
+ if: steps.intent.outputs.redispatch == 'true'
566
+ shell: bash
567
+ env:
568
+ TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
569
+ INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
570
+ run: |
571
+ echo "Stopped superseded operation after redispatching ${TARGET_OPERATION} intent ${INTENT_RUN_ID}." >&2
572
+ exit 1
573
+
574
+ # Re-read live PR state after intent reconciliation so a close-triggered
575
+ # delete that ran first cannot be followed by a stale deploy recreation.
576
+ - name: Verify pull request is still open
577
+ shell: bash
578
+ env:
579
+ GH_TOKEN: ${{ github.token }}
580
+ GH_REPO: ${{ github.repository }}
581
+ run: |
582
+ set -euo pipefail
583
+
584
+ pr_state="$(gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.state')"
585
+ if [[ "${pr_state}" != "open" ]]; then
586
+ echo "Refusing stale review-app deploy because PR #${PR_NUMBER} is ${pr_state:-unknown}." >&2
587
+ exit 1
588
+ fi
589
+
50
590
  - name: React to deploy command
51
591
  if: github.event_name == 'issue_comment'
52
592
  continue-on-error: true
@@ -253,23 +793,36 @@ jobs:
253
793
  esac
254
794
 
255
795
  - name: Skip auto deploy until a review app is created
256
- if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request'
796
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event == 'pull_request'
257
797
  shell: bash
258
798
  run: |
259
799
  {
800
+ echo "## Docker image not built"
801
+ echo
802
+ echo "This successful review-app check did not build or deploy an image."
803
+ echo "Outcome: \`image_built=false\`."
804
+ echo
260
805
  echo "Review app ${APP_NAME} does not exist yet."
261
806
  echo "Create it with +review-app-deploy as the PR comment body."
262
807
  } >> "$GITHUB_STEP_SUMMARY"
263
808
 
264
809
  - name: Setup review app if it does not exist yet
265
810
  id: setup-review-app
266
- if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request'
811
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event != 'pull_request'
267
812
  working-directory: app
268
813
  shell: bash
269
814
  run: |
270
815
  set -euo pipefail
271
816
  cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}"
272
817
 
818
+ - name: Refresh existing review app templates
819
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists == 'true'
820
+ working-directory: app
821
+ shell: bash
822
+ run: |
823
+ set -euo pipefail
824
+ cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" --refresh-templates
825
+
273
826
  - name: Create initial PR comment
274
827
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
275
828
  id: create-comment
@@ -313,6 +866,8 @@ jobs:
313
866
  repo: context.repo.repo,
314
867
  ref: process.env.PR_SHA,
315
868
  environment: `review/${process.env.APP_NAME}`,
869
+ transient_environment: true,
870
+ production_environment: false,
316
871
  auto_merge: false,
317
872
  required_contexts: [], // intentional: review apps deploy regardless of required status checks
318
873
  description: `Control Plane review app for PR #${process.env.PR_NUMBER}`
@@ -356,6 +911,7 @@ jobs:
356
911
  });
357
912
 
358
913
  - name: Build Docker image
914
+ id: build-image
359
915
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
360
916
  uses: ./.cpflow/.github/actions/cpflow-build-docker-image
361
917
  with:
@@ -439,16 +995,95 @@ jobs:
439
995
 
440
996
  cpflow deploy-image "${deploy_args[@]}"
441
997
 
442
- - name: Retrieve app URL
998
+ - name: Wait for deployment health
443
999
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
1000
+ id: health-check
1001
+ uses: ./.cpflow/.github/actions/cpflow-wait-for-health
1002
+ with:
1003
+ workload_name: ${{ env.PRIMARY_WORKLOAD || 'rails' }}
1004
+ app_name: ${{ steps.review-config.outputs.app_name }}
1005
+ org: ${{ steps.review-config.outputs.cpln_org }}
1006
+ # Review apps use their own health knobs so teams can keep production
1007
+ # promotion stricter or slower without changing PR feedback behavior.
1008
+ max_retries: ${{ vars.REVIEW_APP_HEALTH_CHECK_RETRIES || '24' }}
1009
+ interval_seconds: ${{ vars.REVIEW_APP_HEALTH_CHECK_INTERVAL || '15' }}
1010
+ accepted_statuses: ${{ vars.REVIEW_APP_HEALTH_CHECK_ACCEPTED_STATUSES || '200 301 302' }}
1011
+ curl_max_time: ${{ vars.REVIEW_APP_HEALTH_CHECK_CURL_MAX_TIME || '10' }}
1012
+
1013
+ - name: Retrieve app URL
1014
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') && steps.health-check.outcome == 'success'
444
1015
  id: workload
445
1016
  working-directory: app
446
1017
  shell: bash
1018
+ env:
1019
+ VERIFIED_WORKLOAD_ENDPOINT: ${{ steps.health-check.outputs.endpoint }}
447
1020
  run: |
448
1021
  set -euo pipefail
449
1022
  workload_name="${PRIMARY_WORKLOAD:-rails}"
450
1023
  workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')"
451
- echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT"
1024
+ app_url="${VERIFIED_WORKLOAD_ENDPOINT:-${workload_url}}"
1025
+
1026
+ if [[ -n "${workload_url}" ]]; then
1027
+ gvc_stderr="$(mktemp)"
1028
+ if ! gvc_json="$(cpln gvc get "${APP_NAME}" --org "${CPLN_ORG}" -o json 2>"${gvc_stderr}")"; then
1029
+ echo "::warning::Could not read GVC app_domain; falling back to verified workload endpoint."
1030
+ sed 's/^/cpln gvc get: /' "${gvc_stderr}" >&2
1031
+ gvc_json=""
1032
+ fi
1033
+ rm -f "${gvc_stderr}"
1034
+
1035
+ app_domain_template="$(
1036
+ jq -r '.spec.env[]? | select(.name == "app_domain") | .value // empty' <<< "${gvc_json}" | head -n 1
1037
+ )"
1038
+
1039
+ if [[ -n "${app_domain_template}" ]]; then
1040
+ derived_app_url="$(
1041
+ ruby -ruri -e '
1042
+ workload_name, workload_url, app_domain_template, app_name = ARGV
1043
+ begin
1044
+ uri = URI(workload_url)
1045
+ rescue URI::InvalidURIError
1046
+ exit
1047
+ end
1048
+
1049
+ host_prefix = "#{workload_name}-"
1050
+ host_suffix = ".cpln.app"
1051
+
1052
+ unless uri.host&.start_with?(host_prefix) && uri.host&.end_with?(host_suffix)
1053
+ exit
1054
+ end
1055
+
1056
+ raw_cpln_gvc_alias = uri.host.delete_prefix(host_prefix).delete_suffix(host_suffix)
1057
+ cpln_gvc_alias = raw_cpln_gvc_alias.split(".").first
1058
+ if cpln_gvc_alias.to_s.empty?
1059
+ exit
1060
+ end
1061
+ # Avoid a literal command-substitution token here because actionlint flags it inside single-quoted Ruby.
1062
+ cpln_gvc_alias_token = "$" + "(CPLN_GVC_ALIAS)"
1063
+ app_url = app_domain_template
1064
+ .gsub(cpln_gvc_alias_token, cpln_gvc_alias)
1065
+ .gsub("{{APP_NAME}}", app_name)
1066
+
1067
+ begin
1068
+ app_uri = URI(app_url)
1069
+ if app_uri.path.to_s.empty? && app_uri.query.nil? && app_uri.fragment.nil?
1070
+ app_url = "#{app_url}/"
1071
+ end
1072
+ rescue URI::InvalidURIError
1073
+ exit
1074
+ end
1075
+
1076
+ puts app_url
1077
+ ' "${workload_name}" "${workload_url}" "${app_domain_template}" "${APP_NAME}"
1078
+ )"
1079
+ app_url="${derived_app_url:-${app_url}}"
1080
+ fi
1081
+ fi
1082
+
1083
+ {
1084
+ echo "workload_url=${workload_url}"
1085
+ echo "app_url=${app_url}"
1086
+ } >> "$GITHUB_OUTPUT"
452
1087
 
453
1088
  - name: Finalize deployment status
454
1089
  if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
@@ -456,7 +1091,7 @@ jobs:
456
1091
  env:
457
1092
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
458
1093
  DEPLOYMENT_ID: ${{ steps.init-deployment.outputs.result }}
459
- APP_URL: ${{ steps.workload.outputs.workload_url }}
1094
+ APP_URL: ${{ steps.workload.outputs.app_url }}
460
1095
  JOB_STATUS: ${{ job.status }}
461
1096
  with:
462
1097
  script: |