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
@@ -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,44 +17,580 @@ 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
53
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
593
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
54
594
  with:
55
595
  script: |
56
596
  try {
@@ -68,8 +608,15 @@ jobs:
68
608
  }
69
609
  }
70
610
 
611
+ # No explicit ref: checkout's default resolves to this event's recorded commit
612
+ # (GITHUB_SHA), which is trusted base-repository code and is exempt from checkout v7's fork-PR guard.
613
+ - name: Checkout generated cpflow actions
614
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
615
+ with:
616
+ persist-credentials: false
617
+
71
618
  - name: Checkout control-plane-flow actions
72
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
619
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
73
620
  with:
74
621
  repository: ${{ job.workflow_repository }}
75
622
  ref: ${{ job.workflow_sha }}
@@ -78,7 +625,7 @@ jobs:
78
625
 
79
626
  - name: Validate required secrets and variables
80
627
  id: config
81
- uses: ./.cpflow/.github/actions/cpflow-validate-config
628
+ uses: ./.github/actions/cpflow-validate-config
82
629
  env:
83
630
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
84
631
  with:
@@ -151,7 +698,9 @@ jobs:
151
698
  exit 0
152
699
  fi
153
700
 
154
- if [[ "${EVENT_NAME}" == "pull_request" ]]; then
701
+ # pull_request_target is in this workflow's event allowlist, so it needs the same
702
+ # fork skip as pull_request instead of falling through to the workflow_dispatch error.
703
+ if [[ "${EVENT_NAME}" == "pull_request" || "${EVENT_NAME}" == "pull_request_target" ]]; then
155
704
  echo "allowed=false" >> "$GITHUB_OUTPUT"
156
705
  {
157
706
  echo "Review app deploys are skipped for fork pull requests."
@@ -174,7 +723,7 @@ jobs:
174
723
 
175
724
  - name: Checkout PR commit
176
725
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
177
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
726
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
178
727
  with:
179
728
  ref: ${{ env.PR_SHA }}
180
729
  path: app
@@ -190,7 +739,7 @@ jobs:
190
739
  - name: Resolve review app config
191
740
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
192
741
  id: review-config
193
- uses: ./.cpflow/.github/actions/cpflow-resolve-review-config
742
+ uses: ./.github/actions/cpflow-resolve-review-config
194
743
  with:
195
744
  working_directory: app
196
745
  configured_cpln_org_staging: ${{ vars.CPLN_ORG_STAGING }}
@@ -199,7 +748,7 @@ jobs:
199
748
 
200
749
  - name: Setup environment
201
750
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
202
- uses: ./.cpflow/.github/actions/cpflow-setup-environment
751
+ uses: ./.github/actions/cpflow-setup-environment
203
752
  with:
204
753
  token: ${{ secrets.CPLN_TOKEN_STAGING }}
205
754
  org: ${{ steps.review-config.outputs.cpln_org }}
@@ -211,7 +760,7 @@ jobs:
211
760
  - name: Detect release phase support
212
761
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
213
762
  id: release-phase
214
- uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
763
+ uses: ./.github/actions/cpflow-detect-release-phase
215
764
  with:
216
765
  app_name: ${{ steps.review-config.outputs.app_name }}
217
766
  working_directory: app
@@ -253,27 +802,40 @@ jobs:
253
802
  esac
254
803
 
255
804
  - 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'
805
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event == 'pull_request'
257
806
  shell: bash
258
807
  run: |
259
808
  {
809
+ echo "## Docker image not built"
810
+ echo
811
+ echo "This successful review-app check did not build or deploy an image."
812
+ echo "Outcome: \`image_built=false\`."
813
+ echo
260
814
  echo "Review app ${APP_NAME} does not exist yet."
261
815
  echo "Create it with +review-app-deploy as the PR comment body."
262
816
  } >> "$GITHUB_STEP_SUMMARY"
263
817
 
264
818
  - name: Setup review app if it does not exist yet
265
819
  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'
820
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event != 'pull_request'
267
821
  working-directory: app
268
822
  shell: bash
269
823
  run: |
270
824
  set -euo pipefail
271
825
  cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}"
272
826
 
827
+ - name: Refresh existing review app templates
828
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists == 'true'
829
+ working-directory: app
830
+ shell: bash
831
+ run: |
832
+ set -euo pipefail
833
+ cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" --refresh-templates
834
+
273
835
  - name: Create initial PR comment
274
836
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
275
837
  id: create-comment
276
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
838
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
277
839
  with:
278
840
  script: |
279
841
  const body = [
@@ -292,7 +854,7 @@ jobs:
292
854
 
293
855
  - name: Set deployment links
294
856
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
295
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
857
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
296
858
  with:
297
859
  script: |
298
860
  const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
@@ -305,7 +867,7 @@ jobs:
305
867
  - name: Initialize GitHub deployment
306
868
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
307
869
  id: init-deployment
308
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
870
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
309
871
  with:
310
872
  script: |
311
873
  const deployment = await github.rest.repos.createDeployment({
@@ -313,6 +875,8 @@ jobs:
313
875
  repo: context.repo.repo,
314
876
  ref: process.env.PR_SHA,
315
877
  environment: `review/${process.env.APP_NAME}`,
878
+ transient_environment: true,
879
+ production_environment: false,
316
880
  auto_merge: false,
317
881
  required_contexts: [], // intentional: review apps deploy regardless of required status checks
318
882
  description: `Control Plane review app for PR #${process.env.PR_NUMBER}`
@@ -330,7 +894,7 @@ jobs:
330
894
 
331
895
  - name: Update PR comment with build status
332
896
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
333
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
897
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
334
898
  env:
335
899
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
336
900
  with:
@@ -356,8 +920,9 @@ jobs:
356
920
  });
357
921
 
358
922
  - name: Build Docker image
923
+ id: build-image
359
924
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
360
- uses: ./.cpflow/.github/actions/cpflow-build-docker-image
925
+ uses: ./.github/actions/cpflow-build-docker-image
361
926
  with:
362
927
  app_name: ${{ steps.review-config.outputs.app_name }}
363
928
  org: ${{ steps.review-config.outputs.cpln_org }}
@@ -370,7 +935,7 @@ jobs:
370
935
 
371
936
  - name: Update PR comment with deploy status
372
937
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
373
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
938
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
374
939
  env:
375
940
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
376
941
  DEPLOYING_ICON_URL: ${{ vars.REVIEW_APP_DEPLOYING_ICON_URL }}
@@ -442,7 +1007,7 @@ jobs:
442
1007
  - name: Wait for deployment health
443
1008
  if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
444
1009
  id: health-check
445
- uses: ./.cpflow/.github/actions/cpflow-wait-for-health
1010
+ uses: ./.github/actions/cpflow-wait-for-health
446
1011
  with:
447
1012
  workload_name: ${{ env.PRIMARY_WORKLOAD || 'rails' }}
448
1013
  app_name: ${{ steps.review-config.outputs.app_name }}
@@ -459,19 +1024,83 @@ jobs:
459
1024
  id: workload
460
1025
  working-directory: app
461
1026
  shell: bash
1027
+ env:
1028
+ VERIFIED_WORKLOAD_ENDPOINT: ${{ steps.health-check.outputs.endpoint }}
462
1029
  run: |
463
1030
  set -euo pipefail
464
1031
  workload_name="${PRIMARY_WORKLOAD:-rails}"
465
1032
  workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')"
466
- echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT"
1033
+ app_url="${VERIFIED_WORKLOAD_ENDPOINT:-${workload_url}}"
1034
+
1035
+ if [[ -n "${workload_url}" ]]; then
1036
+ gvc_stderr="$(mktemp)"
1037
+ if ! gvc_json="$(cpln gvc get "${APP_NAME}" --org "${CPLN_ORG}" -o json 2>"${gvc_stderr}")"; then
1038
+ echo "::warning::Could not read GVC app_domain; falling back to verified workload endpoint."
1039
+ sed 's/^/cpln gvc get: /' "${gvc_stderr}" >&2
1040
+ gvc_json=""
1041
+ fi
1042
+ rm -f "${gvc_stderr}"
1043
+
1044
+ app_domain_template="$(
1045
+ jq -r '.spec.env[]? | select(.name == "app_domain") | .value // empty' <<< "${gvc_json}" | head -n 1
1046
+ )"
1047
+
1048
+ if [[ -n "${app_domain_template}" ]]; then
1049
+ derived_app_url="$(
1050
+ ruby -ruri -e '
1051
+ workload_name, workload_url, app_domain_template, app_name = ARGV
1052
+ begin
1053
+ uri = URI(workload_url)
1054
+ rescue URI::InvalidURIError
1055
+ exit
1056
+ end
1057
+
1058
+ host_prefix = "#{workload_name}-"
1059
+ host_suffix = ".cpln.app"
1060
+
1061
+ unless uri.host&.start_with?(host_prefix) && uri.host&.end_with?(host_suffix)
1062
+ exit
1063
+ end
1064
+
1065
+ raw_cpln_gvc_alias = uri.host.delete_prefix(host_prefix).delete_suffix(host_suffix)
1066
+ cpln_gvc_alias = raw_cpln_gvc_alias.split(".").first
1067
+ if cpln_gvc_alias.to_s.empty?
1068
+ exit
1069
+ end
1070
+ # Avoid a literal command-substitution token here because actionlint flags it inside single-quoted Ruby.
1071
+ cpln_gvc_alias_token = "$" + "(CPLN_GVC_ALIAS)"
1072
+ app_url = app_domain_template
1073
+ .gsub(cpln_gvc_alias_token, cpln_gvc_alias)
1074
+ .gsub("{{APP_NAME}}", app_name)
1075
+
1076
+ begin
1077
+ app_uri = URI(app_url)
1078
+ if app_uri.path.to_s.empty? && app_uri.query.nil? && app_uri.fragment.nil?
1079
+ app_url = "#{app_url}/"
1080
+ end
1081
+ rescue URI::InvalidURIError
1082
+ exit
1083
+ end
1084
+
1085
+ puts app_url
1086
+ ' "${workload_name}" "${workload_url}" "${app_domain_template}" "${APP_NAME}"
1087
+ )"
1088
+ app_url="${derived_app_url:-${app_url}}"
1089
+ fi
1090
+ fi
1091
+
1092
+ {
1093
+ echo "workload_url=${workload_url}"
1094
+ echo "app_url=${app_url}"
1095
+ } >> "$GITHUB_OUTPUT"
467
1096
 
468
1097
  - name: Finalize deployment status
469
1098
  if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
470
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
1099
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
471
1100
  env:
472
1101
  COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
473
1102
  DEPLOYMENT_ID: ${{ steps.init-deployment.outputs.result }}
474
- APP_URL: ${{ steps.workload.outputs.workload_url }}
1103
+ APP_URL: ${{ steps.workload.outputs.app_url }}
475
1104
  JOB_STATUS: ${{ job.status }}
476
1105
  with:
477
1106
  script: |