cpflow 5.0.0.rc.1 → 5.0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/action.yml +5 -0
  3. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-detect-release-phase/action.yml +7 -0
  4. data/.github/actions/cpflow-setup-environment/action.yml +161 -0
  5. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +69 -0
  6. data/.github/workflows/cpflow-delete-review-app.yml +182 -0
  7. data/.github/workflows/cpflow-deploy-review-app.yml +507 -0
  8. data/.github/workflows/cpflow-deploy-staging.yml +168 -0
  9. data/.github/workflows/cpflow-help-command.yml +78 -0
  10. data/.github/workflows/cpflow-promote-staging-to-production.yml +510 -0
  11. data/.github/workflows/cpflow-review-app-help.yml +51 -0
  12. data/.github/workflows/rspec-shared.yml +3 -0
  13. data/.github/workflows/trigger-docs-site.yml +90 -0
  14. data/.rubocop.yml +14 -1
  15. data/CHANGELOG.md +43 -1
  16. data/CONTRIBUTING.md +27 -0
  17. data/Gemfile.lock +2 -2
  18. data/README.md +7 -3
  19. data/cpflow.gemspec +1 -1
  20. data/docs/ai-github-flow-prompt.md +1 -1
  21. data/docs/assets/cpflow-deploying.svg +46 -0
  22. data/docs/ci-automation.md +111 -8
  23. data/docs/commands.md +11 -5
  24. data/docs/thruster.md +149 -0
  25. data/docs/troubleshooting.md +8 -0
  26. data/lib/command/apply_template.rb +6 -2
  27. data/lib/command/base.rb +1 -0
  28. data/lib/command/cleanup_stale_apps.rb +53 -14
  29. data/lib/command/delete.rb +3 -1
  30. data/lib/command/deploy_image.rb +5 -2
  31. data/lib/command/generate.rb +7 -3
  32. data/lib/command/generate_github_actions.rb +21 -9
  33. data/lib/command/generator_helpers.rb +5 -1
  34. data/lib/command/info.rb +3 -1
  35. data/lib/command/run.rb +16 -1
  36. data/lib/command/test.rb +1 -3
  37. data/lib/core/controlplane.rb +17 -6
  38. data/lib/core/controlplane_api.rb +3 -1
  39. data/lib/core/controlplane_api_direct.rb +50 -27
  40. data/lib/core/doctor_service.rb +2 -2
  41. data/lib/core/github_flow_readiness_service.rb +26 -2
  42. data/lib/core/repo_introspection.rb +41 -3
  43. data/lib/core/shell.rb +3 -1
  44. data/lib/core/terraform_config/policy.rb +1 -1
  45. data/lib/cpflow/version.rb +1 -1
  46. data/lib/cpflow.rb +27 -13
  47. data/lib/generator_templates/templates/rails.yml +4 -0
  48. data/lib/generator_templates_sqlite/templates/rails.yml +4 -0
  49. data/lib/github_flow_templates/.github/cpflow-help.md +30 -1
  50. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +10 -44
  51. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +15 -114
  52. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +10 -413
  53. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +12 -123
  54. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +10 -33
  55. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +13 -475
  56. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +12 -30
  57. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +72 -0
  58. data/lib/github_flow_templates/bin/test-cpflow-github-flow +89 -0
  59. data/rakelib/create_release.rake +4 -4
  60. metadata +26 -17
  61. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +0 -98
  62. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-build-docker-image/action.yml +0 -0
  63. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/delete-app.sh +0 -0
  64. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-validate-config/action.yml +0 -0
  65. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-wait-for-health/action.yml +0 -0
@@ -0,0 +1,507 @@
1
+ name: Deploy Review App to Control Plane
2
+
3
+ run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}"
4
+
5
+ on:
6
+ workflow_call:
7
+ inputs:
8
+ control_plane_flow_ref:
9
+ description: Git ref used to load shared cpflow composite actions.
10
+ required: false
11
+ type: string
12
+ default: main
13
+
14
+ permissions:
15
+ contents: read
16
+ deployments: write
17
+ issues: write
18
+ pull-requests: write
19
+
20
+ concurrency:
21
+ group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
22
+ # Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the
23
+ # review app in a partially-deployed state (workload update in progress, rollout not
24
+ # settled). Let an in-flight deploy finish before the next push starts a new run.
25
+ cancel-in-progress: false
26
+
27
+ env:
28
+ APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
29
+ CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }}
30
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
31
+ PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }}
32
+
33
+ jobs:
34
+ deploy:
35
+ # Skip synchronize/opened events from fork PRs at the job level — they cannot access
36
+ # repository secrets anyway, so running any steps just burns billable minutes. Users
37
+ # can still manually deploy a fork PR via `+review-app-deploy` (gated below by
38
+ # author_association) or workflow_dispatch.
39
+ if: |
40
+ (github.event_name == 'pull_request' &&
41
+ github.event.pull_request.head.repo.full_name == github.repository) ||
42
+ github.event_name == 'workflow_dispatch' ||
43
+ (github.event_name == 'issue_comment' &&
44
+ github.event.issue.pull_request &&
45
+ contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) &&
46
+ contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
47
+ runs-on: ubuntu-latest
48
+ timeout-minutes: 45
49
+
50
+ steps:
51
+ - name: React to deploy command
52
+ if: github.event_name == 'issue_comment'
53
+ continue-on-error: true
54
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
55
+ with:
56
+ script: |
57
+ try {
58
+ await github.rest.reactions.createForIssueComment({
59
+ owner: context.repo.owner,
60
+ repo: context.repo.repo,
61
+ comment_id: context.payload.comment.id,
62
+ content: "rocket"
63
+ });
64
+ } catch (error) {
65
+ if (error.status === 422) {
66
+ core.info("Deploy command reaction already exists.");
67
+ } else {
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ - name: Checkout control-plane-flow actions
73
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
74
+ with:
75
+ repository: shakacode/control-plane-flow
76
+ ref: ${{ inputs.control_plane_flow_ref }}
77
+ path: .cpflow
78
+ persist-credentials: false
79
+
80
+ - name: Validate required secrets and variables
81
+ id: config
82
+ uses: ./.cpflow/.github/actions/cpflow-validate-config
83
+ env:
84
+ CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
85
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
86
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
87
+ with:
88
+ required: |
89
+ secret:CPLN_TOKEN_STAGING
90
+ variable:CPLN_ORG_STAGING
91
+ variable:REVIEW_APP_PREFIX
92
+ pull_request_friendly: "true"
93
+
94
+ - name: Resolve PR ref and commit
95
+ if: steps.config.outputs.ready == 'true'
96
+ id: resolve-pr
97
+ env:
98
+ # Route every GitHub-controlled input through env so the run script never
99
+ # interpolates ${{ ... }} into shell. All values here are GitHub-controlled
100
+ # (not user-influenced), so this is for consistency with the rest of the
101
+ # workflow and to quiet actionlint/StepSecurity, not a fix for an
102
+ # exploitable injection.
103
+ EVENT_NAME: ${{ github.event_name }}
104
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
105
+ GH_REPO: ${{ github.repository }}
106
+ DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }}
107
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
108
+ PR_EVENT_NUMBER: ${{ github.event.pull_request.number }}
109
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
110
+ shell: bash
111
+ run: |
112
+ set -euo pipefail
113
+
114
+ case "${EVENT_NAME}" in
115
+ workflow_dispatch)
116
+ pr_number="${DISPATCH_PR_NUMBER}"
117
+ ;;
118
+ issue_comment)
119
+ pr_number="${ISSUE_NUMBER}"
120
+ ;;
121
+ pull_request)
122
+ pr_number="${PR_EVENT_NUMBER}"
123
+ ;;
124
+ *)
125
+ echo "Unsupported event type: ${EVENT_NAME}" >&2
126
+ exit 1
127
+ ;;
128
+ esac
129
+
130
+ pr_data="$(gh pr view "$pr_number" --json headRefOid,headRepository,headRepositoryOwner)"
131
+ pr_sha="$(echo "$pr_data" | jq -r '.headRefOid')"
132
+ pr_repository="$(echo "$pr_data" | jq -r '[.headRepositoryOwner.login, .headRepository.name] | join("/")')"
133
+ same_repo="false"
134
+
135
+ if [[ "$pr_repository" == "$GITHUB_REPOSITORY" ]]; then
136
+ same_repo="true"
137
+ fi
138
+
139
+ echo "PR_NUMBER=$pr_number" >> "$GITHUB_ENV"
140
+ echo "APP_NAME=${REVIEW_APP_PREFIX}-$pr_number" >> "$GITHUB_ENV"
141
+ echo "PR_SHA=$pr_sha" >> "$GITHUB_ENV"
142
+ echo "same_repo=${same_repo}" >> "$GITHUB_OUTPUT"
143
+
144
+ - name: Validate review app deployment source
145
+ if: steps.config.outputs.ready == 'true'
146
+ id: source
147
+ env:
148
+ EVENT_NAME: ${{ github.event_name }}
149
+ # Same env-routing pattern as Resolve PR ref and commit above: keep all
150
+ # ${{ ... }} values out of the run script.
151
+ SAME_REPO: ${{ steps.resolve-pr.outputs.same_repo }}
152
+ shell: bash
153
+ run: |
154
+ set -euo pipefail
155
+
156
+ if [[ "${SAME_REPO}" == "true" ]]; then
157
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
158
+ exit 0
159
+ fi
160
+
161
+ if [[ "${EVENT_NAME}" == "pull_request" ]]; then
162
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
163
+ {
164
+ echo "Review app deploys are skipped for fork pull requests."
165
+ echo "This workflow builds Docker images with repository secrets, so review app deploys only run for branches in the base repository."
166
+ } >> "$GITHUB_STEP_SUMMARY"
167
+ exit 0
168
+ fi
169
+
170
+ if [[ "${EVENT_NAME}" == "issue_comment" ]]; then
171
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
172
+ {
173
+ echo "Review app deploys from fork pull requests require a branch in ${GITHUB_REPOSITORY}."
174
+ echo "This workflow builds Docker images with repository secrets, so comment-triggered deploys only run for branches in the base repository."
175
+ } >> "$GITHUB_STEP_SUMMARY"
176
+ exit 0
177
+ fi
178
+
179
+ echo "Review app deploys from fork pull requests are not allowed for workflow_dispatch because this workflow uses repository secrets." >&2
180
+ exit 1
181
+
182
+ - name: Checkout PR commit
183
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
184
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
185
+ with:
186
+ ref: ${{ env.PR_SHA }}
187
+ path: app
188
+ persist-credentials: false
189
+
190
+ - name: Remove PR checkout Git metadata
191
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
192
+ shell: bash
193
+ run: |
194
+ set -euo pipefail
195
+ rm -rf app/.git
196
+
197
+ - name: Setup environment
198
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
199
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
200
+ with:
201
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
202
+ org: ${{ vars.CPLN_ORG_STAGING }}
203
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
204
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
205
+ working_directory: app
206
+
207
+ - name: Detect release phase support
208
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
209
+ id: release-phase
210
+ uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
211
+ with:
212
+ app_name: ${{ env.APP_NAME }}
213
+ working_directory: app
214
+
215
+ - name: Check if review app exists
216
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
217
+ id: check-app
218
+ working-directory: app
219
+ shell: bash
220
+ run: |
221
+ set -euo pipefail
222
+
223
+ exists_output=""
224
+ set +e
225
+ exists_output="$(cpflow exists -a "${APP_NAME}" --org "${CPLN_ORG}" 2>&1)"
226
+ exists_status=$?
227
+ set -e
228
+
229
+ case "${exists_status}" in
230
+ 0)
231
+ if [[ -n "${exists_output}" ]]; then
232
+ printf '%s\n' "${exists_output}"
233
+ fi
234
+ echo "exists=true" >> "$GITHUB_OUTPUT"
235
+ ;;
236
+ 3)
237
+ if [[ -n "${exists_output}" ]]; then
238
+ printf '%s\n' "${exists_output}"
239
+ fi
240
+ echo "exists=false" >> "$GITHUB_OUTPUT"
241
+ ;;
242
+ *)
243
+ echo "::error::cpflow exists returned unexpected exit code ${exists_status} for ${APP_NAME}" >&2
244
+ if [[ -n "${exists_output}" ]]; then
245
+ printf '%s\n' "${exists_output}" >&2
246
+ fi
247
+ exit "${exists_status}"
248
+ ;;
249
+ esac
250
+
251
+ - name: Skip auto deploy until a review app is created
252
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request'
253
+ shell: bash
254
+ run: |
255
+ {
256
+ echo "Review app ${APP_NAME} does not exist yet."
257
+ echo "Create it with +review-app-deploy as the PR comment body."
258
+ } >> "$GITHUB_STEP_SUMMARY"
259
+
260
+ - name: Setup review app if it does not exist yet
261
+ id: setup-review-app
262
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request'
263
+ working-directory: app
264
+ shell: bash
265
+ run: |
266
+ set -euo pipefail
267
+ cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}"
268
+
269
+ - name: Create initial PR comment
270
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
271
+ id: create-comment
272
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
273
+ with:
274
+ script: |
275
+ const body = [
276
+ "## 🚀 Starting deployment process...",
277
+ "",
278
+ `_Preparing review app deployment for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}_`
279
+ ].join("\n");
280
+
281
+ const result = await github.rest.issues.createComment({
282
+ owner: context.repo.owner,
283
+ repo: context.repo.repo,
284
+ issue_number: Number(process.env.PR_NUMBER),
285
+ body
286
+ });
287
+ core.setOutput("comment-id", result.data.id);
288
+
289
+ - name: Set deployment links
290
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
291
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
292
+ with:
293
+ script: |
294
+ const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
295
+ core.exportVariable("WORKFLOW_URL", workflowUrl);
296
+ core.exportVariable(
297
+ "CONSOLE_URL",
298
+ `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/gvc/${process.env.APP_NAME}/-info`
299
+ );
300
+
301
+ - name: Initialize GitHub deployment
302
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
303
+ id: init-deployment
304
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
305
+ with:
306
+ script: |
307
+ const deployment = await github.rest.repos.createDeployment({
308
+ owner: context.repo.owner,
309
+ repo: context.repo.repo,
310
+ ref: process.env.PR_SHA,
311
+ environment: `review/${process.env.APP_NAME}`,
312
+ auto_merge: false,
313
+ required_contexts: [], // intentional: review apps deploy regardless of required status checks
314
+ description: `Control Plane review app for PR #${process.env.PR_NUMBER}`
315
+ });
316
+
317
+ await github.rest.repos.createDeploymentStatus({
318
+ owner: context.repo.owner,
319
+ repo: context.repo.repo,
320
+ deployment_id: deployment.data.id,
321
+ state: "in_progress",
322
+ description: "Deployment started"
323
+ });
324
+
325
+ return deployment.data.id;
326
+
327
+ - name: Update PR comment with build status
328
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
329
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
330
+ env:
331
+ COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
332
+ with:
333
+ script: |
334
+ const commentId = Number(process.env.COMMENT_ID);
335
+ if (!Number.isFinite(commentId) || commentId <= 0) {
336
+ core.warning("Skipping PR comment update because no comment id was created.");
337
+ return;
338
+ }
339
+
340
+ const body = [
341
+ `🏗️ Building Docker image for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}`,
342
+ "",
343
+ `📝 [View Build Logs](${process.env.WORKFLOW_URL})`,
344
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`
345
+ ].join("\n");
346
+
347
+ await github.rest.issues.updateComment({
348
+ owner: context.repo.owner,
349
+ repo: context.repo.repo,
350
+ comment_id: commentId,
351
+ body
352
+ });
353
+
354
+ - name: Build Docker image
355
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
356
+ uses: ./.cpflow/.github/actions/cpflow-build-docker-image
357
+ with:
358
+ app_name: ${{ env.APP_NAME }}
359
+ org: ${{ vars.CPLN_ORG_STAGING }}
360
+ commit: ${{ env.PR_SHA }}
361
+ pr_number: ${{ env.PR_NUMBER }}
362
+ docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }}
363
+ docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
364
+ docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }}
365
+ working_directory: app
366
+
367
+ - name: Update PR comment with deploy status
368
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
369
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
370
+ env:
371
+ COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
372
+ DEPLOYING_ICON_URL: ${{ vars.REVIEW_APP_DEPLOYING_ICON_URL }}
373
+ with:
374
+ script: |
375
+ const commentId = Number(process.env.COMMENT_ID);
376
+ if (!Number.isFinite(commentId) || commentId <= 0) {
377
+ core.warning("Skipping PR comment update because no comment id was created.");
378
+ return;
379
+ }
380
+
381
+ // Pinned to the commit that introduced this SVG for immutability.
382
+ // To update the icon: update the SVG, replace this SHA, and regenerate user workflows.
383
+ const DEFAULT_DEPLOYING_ICON_URL = "https://raw.githubusercontent.com/shakacode/control-plane-flow/7632313232b751aaa0bc55a122bf0615ff490345/docs/assets/cpflow-deploying.svg";
384
+ const configuredDeployingIconUrl = (process.env.DEPLOYING_ICON_URL || "").trim();
385
+ const isNone = configuredDeployingIconUrl.toLowerCase() === "none";
386
+ let deployingIconUrl = DEFAULT_DEPLOYING_ICON_URL;
387
+
388
+ if (configuredDeployingIconUrl && !isNone) {
389
+ try {
390
+ const parsedUrl = new URL(configuredDeployingIconUrl);
391
+ if (parsedUrl.protocol === "https:") {
392
+ deployingIconUrl = parsedUrl.href;
393
+ } else {
394
+ core.warning("Ignoring REVIEW_APP_DEPLOYING_ICON_URL because it must use https://.");
395
+ }
396
+ } catch {
397
+ core.warning("Ignoring REVIEW_APP_DEPLOYING_ICON_URL because it is not a valid URL.");
398
+ }
399
+ }
400
+
401
+ const deployingIcon = isNone
402
+ ? "⏳"
403
+ : `<img src="${deployingIconUrl}" alt="Deploying" width="20" height="20" />`;
404
+
405
+ const body = [
406
+ "## 🚀 Deploying to Control Plane...",
407
+ "",
408
+ `${deployingIcon} **Waiting for deployment to be ready...**`,
409
+ "",
410
+ `📝 [View Deploy Logs](${process.env.WORKFLOW_URL})`,
411
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`
412
+ ].join("\n");
413
+
414
+ await github.rest.issues.updateComment({
415
+ owner: context.repo.owner,
416
+ repo: context.repo.repo,
417
+ comment_id: commentId,
418
+ body
419
+ });
420
+
421
+ - name: Deploy to Control Plane
422
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
423
+ working-directory: app
424
+ env:
425
+ RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }}
426
+ shell: bash
427
+ run: |
428
+ set -euo pipefail
429
+
430
+ deploy_args=(-a "${APP_NAME}")
431
+ if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
432
+ deploy_args+=("${RELEASE_PHASE_FLAG}")
433
+ fi
434
+ deploy_args+=(--org "${CPLN_ORG}" --verbose)
435
+
436
+ cpflow deploy-image "${deploy_args[@]}"
437
+
438
+ - name: Retrieve app URL
439
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
440
+ id: workload
441
+ working-directory: app
442
+ shell: bash
443
+ run: |
444
+ set -euo pipefail
445
+ workload_name="${PRIMARY_WORKLOAD:-rails}"
446
+ workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')"
447
+ echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT"
448
+
449
+ - name: Finalize deployment status
450
+ if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
451
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
452
+ env:
453
+ COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
454
+ DEPLOYMENT_ID: ${{ steps.init-deployment.outputs.result }}
455
+ APP_URL: ${{ steps.workload.outputs.workload_url }}
456
+ JOB_STATUS: ${{ job.status }}
457
+ with:
458
+ script: |
459
+ const commentId = Number(process.env.COMMENT_ID);
460
+ const deploymentId = process.env.DEPLOYMENT_ID;
461
+ const appUrl = process.env.APP_URL;
462
+ const success = process.env.JOB_STATUS === "success";
463
+
464
+ if (deploymentId) {
465
+ await github.rest.repos.createDeploymentStatus({
466
+ owner: context.repo.owner,
467
+ repo: context.repo.repo,
468
+ deployment_id: Number(deploymentId),
469
+ state: success ? "success" : "failure",
470
+ environment: `review/${process.env.APP_NAME}`,
471
+ environment_url: success && appUrl ? appUrl : undefined,
472
+ log_url: process.env.WORKFLOW_URL,
473
+ description: success ? "Review app ready" : "Review app deployment failed"
474
+ });
475
+ }
476
+
477
+ const successBody = [
478
+ "## 🎉 Deploy Complete!",
479
+ "",
480
+ appUrl ? `### [Open Review App](${appUrl})` : "Review app deployed, but no endpoint URL was detected.",
481
+ "",
482
+ `_Deployment successful for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}_`,
483
+ "",
484
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
485
+ `📋 [View Completed Action Build and Deploy Logs](${process.env.WORKFLOW_URL})`
486
+ ].join("\n");
487
+
488
+ const failureBody = [
489
+ "## ❌ Review App Deployment Failed",
490
+ "",
491
+ `_Deployment failed for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}_`,
492
+ "",
493
+ `🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
494
+ `📋 [View Failed Action Build and Deploy Logs](${process.env.WORKFLOW_URL})`
495
+ ].join("\n");
496
+
497
+ if (!Number.isFinite(commentId) || commentId <= 0) {
498
+ core.warning("Skipping PR comment update because no comment id was created.");
499
+ return;
500
+ }
501
+
502
+ await github.rest.issues.updateComment({
503
+ owner: context.repo.owner,
504
+ repo: context.repo.repo,
505
+ comment_id: commentId,
506
+ body: success ? successBody : failureBody
507
+ });
@@ -0,0 +1,168 @@
1
+ name: Deploy Staging to Control Plane
2
+
3
+ run-name: Deploy Control Plane staging app
4
+
5
+ on:
6
+ workflow_call:
7
+ inputs:
8
+ control_plane_flow_ref:
9
+ description: Git ref used to load shared cpflow composite actions.
10
+ required: false
11
+ type: string
12
+ default: main
13
+ staging_app_branch_default:
14
+ description: Fallback branch name baked into the generated caller workflow.
15
+ required: false
16
+ type: string
17
+ default: ""
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ env:
23
+ APP_NAME: ${{ vars.STAGING_APP_NAME }}
24
+ CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }}
25
+ STAGING_APP_BRANCH: ${{ vars.STAGING_APP_BRANCH || inputs.staging_app_branch_default }}
26
+
27
+ concurrency:
28
+ group: cpflow-deploy-staging-${{ github.ref_name }}
29
+ # Match the review-app and delete workflows: a cancelled `cpflow deploy-image` mid-rollout
30
+ # can leave the staging GVC in a partially-deployed state (some workloads on the new image,
31
+ # others on the old). Let an in-flight deploy finish before the next push starts a new run.
32
+ cancel-in-progress: false
33
+
34
+ jobs:
35
+ validate-branch:
36
+ runs-on: ubuntu-latest
37
+ timeout-minutes: 5
38
+ outputs:
39
+ is_deployable: ${{ steps.check-branch.outputs.is_deployable }}
40
+ steps:
41
+ - name: Check whether this branch should deploy staging
42
+ id: check-branch
43
+ shell: bash
44
+ run: |
45
+ set -euo pipefail
46
+
47
+ if [[ -n "${STAGING_APP_BRANCH}" ]]; then
48
+ if [[ "${GITHUB_REF_NAME}" == "${STAGING_APP_BRANCH}" ]]; then
49
+ echo "is_deployable=true" >> "$GITHUB_OUTPUT"
50
+ else
51
+ echo "Branch '${GITHUB_REF_NAME}' does not match STAGING_APP_BRANCH='${STAGING_APP_BRANCH}'"
52
+ echo "is_deployable=false" >> "$GITHUB_OUTPUT"
53
+ fi
54
+ elif [[ "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "master" ]]; then
55
+ echo "is_deployable=true" >> "$GITHUB_OUTPUT"
56
+ else
57
+ echo "Branch '${GITHUB_REF_NAME}' is not main/master and no STAGING_APP_BRANCH is configured"
58
+ echo "is_deployable=false" >> "$GITHUB_OUTPUT"
59
+ fi
60
+
61
+ - name: Checkout control-plane-flow actions
62
+ if: steps.check-branch.outputs.is_deployable == 'true'
63
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
64
+ with:
65
+ repository: shakacode/control-plane-flow
66
+ ref: ${{ inputs.control_plane_flow_ref }}
67
+ path: .cpflow
68
+ persist-credentials: false
69
+
70
+ - name: Validate required secrets and variables
71
+ if: steps.check-branch.outputs.is_deployable == 'true'
72
+ uses: ./.cpflow/.github/actions/cpflow-validate-config
73
+ env:
74
+ CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
75
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
76
+ STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }}
77
+ with:
78
+ required: |
79
+ secret:CPLN_TOKEN_STAGING
80
+ variable:CPLN_ORG_STAGING
81
+ variable:STAGING_APP_NAME
82
+
83
+ build:
84
+ needs: validate-branch
85
+ if: needs.validate-branch.outputs.is_deployable == 'true'
86
+ runs-on: ubuntu-latest
87
+ timeout-minutes: 30
88
+ steps:
89
+ - name: Checkout repository
90
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
91
+ with:
92
+ persist-credentials: false
93
+
94
+ - name: Checkout control-plane-flow actions
95
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
96
+ with:
97
+ repository: shakacode/control-plane-flow
98
+ ref: ${{ inputs.control_plane_flow_ref }}
99
+ path: .cpflow
100
+ persist-credentials: false
101
+
102
+ - name: Setup environment
103
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
104
+ with:
105
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
106
+ org: ${{ vars.CPLN_ORG_STAGING }}
107
+ working_directory: .cpflow
108
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
109
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
110
+
111
+ - name: Build Docker image
112
+ uses: ./.cpflow/.github/actions/cpflow-build-docker-image
113
+ with:
114
+ app_name: ${{ env.APP_NAME }}
115
+ org: ${{ vars.CPLN_ORG_STAGING }}
116
+ commit: ${{ github.sha }}
117
+ docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }}
118
+ docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
119
+ docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }}
120
+
121
+ deploy:
122
+ needs: [validate-branch, build]
123
+ if: needs.validate-branch.outputs.is_deployable == 'true'
124
+ runs-on: ubuntu-latest
125
+ timeout-minutes: 30
126
+ steps:
127
+ - name: Checkout repository
128
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
129
+ with:
130
+ persist-credentials: false
131
+
132
+ - name: Checkout control-plane-flow actions
133
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
134
+ with:
135
+ repository: shakacode/control-plane-flow
136
+ ref: ${{ inputs.control_plane_flow_ref }}
137
+ path: .cpflow
138
+ persist-credentials: false
139
+
140
+ - name: Setup environment
141
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
142
+ with:
143
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
144
+ org: ${{ vars.CPLN_ORG_STAGING }}
145
+ working_directory: .cpflow
146
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
147
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
148
+
149
+ - name: Detect release phase support
150
+ id: release-phase
151
+ uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
152
+ with:
153
+ app_name: ${{ env.APP_NAME }}
154
+
155
+ - name: Deploy staging image
156
+ env:
157
+ RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }}
158
+ shell: bash
159
+ run: |
160
+ set -euo pipefail
161
+
162
+ deploy_args=(-a "${APP_NAME}")
163
+ if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
164
+ deploy_args+=("${RELEASE_PHASE_FLAG}")
165
+ fi
166
+ deploy_args+=(--org "${CPLN_ORG}" --verbose)
167
+
168
+ cpflow deploy-image "${deploy_args[@]}"