cpflow 4.2.0 → 5.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude.yml +5 -0
  4. data/.overcommit.yml +43 -3
  5. data/.rubocop.yml +3 -3
  6. data/CHANGELOG.md +28 -4
  7. data/CONTRIBUTING.md +6 -0
  8. data/Gemfile +8 -7
  9. data/Gemfile.lock +92 -72
  10. data/README.md +43 -15
  11. data/cpflow.gemspec +5 -5
  12. data/docs/ai-github-flow-prompt.md +61 -0
  13. data/docs/ci-automation.md +335 -28
  14. data/docs/commands.md +65 -4
  15. data/docs/releasing.md +153 -0
  16. data/lib/command/ai_github_flow_prompt.rb +47 -0
  17. data/lib/command/base.rb +14 -0
  18. data/lib/command/cleanup_images.rb +1 -1
  19. data/lib/command/cleanup_stale_apps.rb +1 -1
  20. data/lib/command/copy_image_from_upstream.rb +14 -3
  21. data/lib/command/exists.rb +13 -2
  22. data/lib/command/generate.rb +153 -4
  23. data/lib/command/generate_github_actions.rb +170 -0
  24. data/lib/command/generator_helpers.rb +31 -0
  25. data/lib/command/github_flow_readiness.rb +37 -0
  26. data/lib/command/run.rb +1 -1
  27. data/lib/command/terraform/generate.rb +1 -0
  28. data/lib/command/version.rb +1 -0
  29. data/lib/constants/exit_code.rb +1 -0
  30. data/lib/core/controlplane.rb +9 -7
  31. data/lib/core/controlplane_api_direct.rb +3 -3
  32. data/lib/core/github_flow_readiness/checks.rb +143 -0
  33. data/lib/core/github_flow_readiness_service.rb +453 -0
  34. data/lib/core/repo_introspection.rb +118 -0
  35. data/lib/core/terraform_config/dsl.rb +1 -1
  36. data/lib/core/terraform_config/local_variable.rb +1 -1
  37. data/lib/cpflow/version.rb +1 -1
  38. data/lib/cpflow.rb +65 -3
  39. data/lib/generator_templates/Dockerfile +59 -3
  40. data/lib/generator_templates/controlplane.yml +27 -39
  41. data/lib/generator_templates/entrypoint.sh +1 -1
  42. data/lib/generator_templates/release_script.sh +23 -0
  43. data/lib/generator_templates/templates/app.yml +5 -8
  44. data/lib/generator_templates/templates/rails.yml +2 -11
  45. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  46. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  47. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  48. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  49. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  50. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  51. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  52. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  53. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  54. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  58. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  61. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  62. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  66. data/rakelib/create_release.rake +662 -37
  67. data/script/check_command_docs +4 -2
  68. data/script/check_cpln_links +25 -11
  69. data/script/precommit/check_command_docs +22 -0
  70. data/script/precommit/check_cpln_links +21 -0
  71. data/script/precommit/check_trailing_newlines +68 -0
  72. data/script/precommit/get_changed_files +49 -0
  73. data/script/precommit/ruby_autofix +52 -0
  74. data/script/precommit/ruby_lint +33 -0
  75. metadata +52 -14
@@ -0,0 +1,142 @@
1
+ name: Delete Review App
2
+
3
+ on:
4
+ pull_request_target:
5
+ types: [closed]
6
+ issue_comment:
7
+ types: [created]
8
+ workflow_dispatch:
9
+ inputs:
10
+ pr_number:
11
+ description: Pull request number targeted for deletion
12
+ required: true
13
+ type: number
14
+
15
+ permissions:
16
+ contents: read
17
+ issues: write
18
+ pull-requests: write
19
+
20
+ concurrency:
21
+ group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
22
+ # Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave
23
+ # partial state behind. Let the in-progress deletion finish before the next run starts.
24
+ cancel-in-progress: false
25
+
26
+ env:
27
+ APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
28
+ CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }}
29
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
30
+
31
+ jobs:
32
+ delete-review-app:
33
+ if: |
34
+ (github.event_name == 'issue_comment' &&
35
+ github.event.issue.pull_request &&
36
+ github.event.comment.body == '/delete-review-app' &&
37
+ contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) ||
38
+ (github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
39
+ github.event_name == 'workflow_dispatch'
40
+ runs-on: ubuntu-latest
41
+ timeout-minutes: 15
42
+
43
+ steps:
44
+ # pull_request_target is intentional: PR-close events from forks need access
45
+ # to staging secrets so this workflow can delete review apps and update PR
46
+ # comments. This checkout is safe because it does not set `ref:`; GitHub checks
47
+ # out the base branch's trusted workflow code, not the fork head. Do not add
48
+ # `ref: ${{ github.event.pull_request.head.sha }}` here without re-evaluating
49
+ # the trust boundary. All local composite actions below are therefore loaded from
50
+ # trusted base-branch code; keep them that way when changing this workflow.
51
+ - name: Checkout repository
52
+ uses: actions/checkout@v4
53
+ with:
54
+ # Delete only invokes `cpln`/`cpflow`; no git push happens, so drop the
55
+ # GITHUB_TOKEN credential helper to keep the token out of .git/config under
56
+ # `pull_request_target`, which has access to repository secrets.
57
+ persist-credentials: false
58
+
59
+ - name: Validate required secrets and variables
60
+ uses: ./.github/actions/cpflow-validate-config
61
+ env:
62
+ CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
63
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
64
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
65
+ with:
66
+ required: |
67
+ secret:CPLN_TOKEN_STAGING
68
+ variable:CPLN_ORG_STAGING
69
+ variable:REVIEW_APP_PREFIX
70
+ pull_request_friendly: "true"
71
+
72
+ - name: Setup environment
73
+ uses: ./.github/actions/cpflow-setup-environment
74
+ with:
75
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
76
+ org: ${{ vars.CPLN_ORG_STAGING }}
77
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
78
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
79
+
80
+ - name: Set workflow links
81
+ uses: actions/github-script@v7
82
+ with:
83
+ script: |
84
+ const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
85
+ core.exportVariable("WORKFLOW_URL", workflowUrl);
86
+ core.exportVariable(
87
+ "CONSOLE_URL",
88
+ `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/-info`
89
+ );
90
+
91
+ - name: Create initial PR comment
92
+ id: create-comment
93
+ uses: actions/github-script@v7
94
+ with:
95
+ script: |
96
+ const comment = await github.rest.issues.createComment({
97
+ owner: context.repo.owner,
98
+ repo: context.repo.repo,
99
+ issue_number: Number(process.env.PR_NUMBER),
100
+ body: "🗑️ Deleting Control Plane review app..."
101
+ });
102
+ core.setOutput("comment-id", comment.data.id);
103
+
104
+ - name: Delete review app
105
+ uses: ./.github/actions/cpflow-delete-control-plane-app
106
+ with:
107
+ app_name: ${{ env.APP_NAME }}
108
+ cpln_org: ${{ vars.CPLN_ORG_STAGING }}
109
+ review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }}
110
+
111
+ - name: Finalize delete status
112
+ if: always()
113
+ uses: actions/github-script@v7
114
+ with:
115
+ script: |
116
+ const commentId = Number("${{ steps.create-comment.outputs.comment-id }}");
117
+ const success = "${{ job.status }}" === "success";
118
+ const body = success
119
+ ? [
120
+ `✅ Review app for PR #${process.env.PR_NUMBER} is deleted`,
121
+ "",
122
+ `[Open organization console](${process.env.CONSOLE_URL})`,
123
+ `[View workflow logs](${process.env.WORKFLOW_URL})`
124
+ ].join("\n")
125
+ : [
126
+ `❌ Failed to delete review app for PR #${process.env.PR_NUMBER}`,
127
+ "",
128
+ `[Open organization console](${process.env.CONSOLE_URL})`,
129
+ `[View workflow logs](${process.env.WORKFLOW_URL})`
130
+ ].join("\n");
131
+
132
+ if (!Number.isFinite(commentId) || commentId <= 0) {
133
+ core.warning("Skipping delete status comment update because no comment id was created.");
134
+ return;
135
+ }
136
+
137
+ await github.rest.issues.updateComment({
138
+ owner: context.repo.owner,
139
+ repo: context.repo.repo,
140
+ comment_id: commentId,
141
+ body
142
+ });
@@ -0,0 +1,445 @@
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
+ pull_request:
7
+ types: [opened, synchronize, reopened]
8
+ issue_comment:
9
+ types: [created]
10
+ workflow_dispatch:
11
+ inputs:
12
+ pr_number:
13
+ description: Pull request number to deploy
14
+ required: true
15
+ type: number
16
+
17
+ permissions:
18
+ contents: read
19
+ deployments: write
20
+ issues: write
21
+ pull-requests: write
22
+
23
+ concurrency:
24
+ group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
25
+ # Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the
26
+ # review app in a partially-deployed state (workload update in progress, rollout not
27
+ # settled). Let an in-flight deploy finish before the next push starts a new run.
28
+ cancel-in-progress: false
29
+
30
+ env:
31
+ APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
32
+ CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }}
33
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
34
+ PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }}
35
+
36
+ jobs:
37
+ deploy:
38
+ # Skip synchronize/opened events from fork PRs at the job level — they cannot access
39
+ # repository secrets anyway, so running any steps just burns billable minutes. Users
40
+ # can still manually deploy a fork PR via `/deploy-review-app` (gated below by
41
+ # author_association) or workflow_dispatch.
42
+ if: |
43
+ (github.event_name == 'pull_request' &&
44
+ github.event.pull_request.head.repo.full_name == github.repository) ||
45
+ github.event_name == 'workflow_dispatch' ||
46
+ (github.event_name == 'issue_comment' &&
47
+ github.event.issue.pull_request &&
48
+ github.event.comment.body == '/deploy-review-app' &&
49
+ contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
50
+ runs-on: ubuntu-latest
51
+ timeout-minutes: 45
52
+
53
+ steps:
54
+ - name: Checkout trusted workflow sources
55
+ uses: actions/checkout@v4
56
+ with:
57
+ # Keep generated composite actions on the trusted base branch. The PR
58
+ # application code is checked out separately under ./app after source
59
+ # validation so same-repo PRs cannot replace local actions before
60
+ # staging secrets are passed to them.
61
+ ref: ${{ github.event.repository.default_branch }}
62
+ persist-credentials: false
63
+
64
+ - name: Validate required secrets and variables
65
+ id: config
66
+ uses: ./.github/actions/cpflow-validate-config
67
+ env:
68
+ CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
69
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
70
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
71
+ with:
72
+ required: |
73
+ secret:CPLN_TOKEN_STAGING
74
+ variable:CPLN_ORG_STAGING
75
+ variable:REVIEW_APP_PREFIX
76
+ pull_request_friendly: "true"
77
+
78
+ - name: Resolve PR ref and commit
79
+ if: steps.config.outputs.ready == 'true'
80
+ id: resolve-pr
81
+ env:
82
+ # Route every GitHub-controlled input through env so the run script never
83
+ # interpolates ${{ ... }} into shell. All values here are GitHub-controlled
84
+ # (not user-influenced), so this is for consistency with the rest of the
85
+ # workflow and to quiet actionlint/StepSecurity, not a fix for an
86
+ # exploitable injection.
87
+ EVENT_NAME: ${{ github.event_name }}
88
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89
+ DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }}
90
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
91
+ PR_EVENT_NUMBER: ${{ github.event.pull_request.number }}
92
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
93
+ shell: bash
94
+ run: |
95
+ set -euo pipefail
96
+
97
+ case "${EVENT_NAME}" in
98
+ workflow_dispatch)
99
+ pr_number="${DISPATCH_PR_NUMBER}"
100
+ ;;
101
+ issue_comment)
102
+ pr_number="${ISSUE_NUMBER}"
103
+ ;;
104
+ pull_request)
105
+ pr_number="${PR_EVENT_NUMBER}"
106
+ ;;
107
+ *)
108
+ echo "Unsupported event type: ${EVENT_NAME}" >&2
109
+ exit 1
110
+ ;;
111
+ esac
112
+
113
+ pr_data="$(gh pr view "$pr_number" --json headRefOid,headRepository,headRepositoryOwner)"
114
+ pr_sha="$(echo "$pr_data" | jq -r '.headRefOid')"
115
+ pr_repository="$(echo "$pr_data" | jq -r '[.headRepositoryOwner.login, .headRepository.name] | join("/")')"
116
+ same_repo="false"
117
+
118
+ if [[ "$pr_repository" == "$GITHUB_REPOSITORY" ]]; then
119
+ same_repo="true"
120
+ fi
121
+
122
+ echo "PR_NUMBER=$pr_number" >> "$GITHUB_ENV"
123
+ echo "APP_NAME=${REVIEW_APP_PREFIX}-$pr_number" >> "$GITHUB_ENV"
124
+ echo "PR_SHA=$pr_sha" >> "$GITHUB_ENV"
125
+ echo "same_repo=${same_repo}" >> "$GITHUB_OUTPUT"
126
+
127
+ - name: Validate review app deployment source
128
+ if: steps.config.outputs.ready == 'true'
129
+ id: source
130
+ env:
131
+ EVENT_NAME: ${{ github.event_name }}
132
+ # Same env-routing pattern as Resolve PR ref and commit above: keep all
133
+ # ${{ ... }} values out of the run script.
134
+ SAME_REPO: ${{ steps.resolve-pr.outputs.same_repo }}
135
+ shell: bash
136
+ run: |
137
+ set -euo pipefail
138
+
139
+ if [[ "${SAME_REPO}" == "true" ]]; then
140
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
141
+ exit 0
142
+ fi
143
+
144
+ if [[ "${EVENT_NAME}" == "pull_request" ]]; then
145
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
146
+ {
147
+ echo "Review app deploys are skipped for fork pull requests."
148
+ echo "This workflow builds Docker images with repository secrets, so review app deploys only run for branches in the base repository."
149
+ } >> "$GITHUB_STEP_SUMMARY"
150
+ exit 0
151
+ fi
152
+
153
+ if [[ "${EVENT_NAME}" == "issue_comment" ]]; then
154
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
155
+ {
156
+ echo "Review app deploys from fork pull requests require a branch in ${GITHUB_REPOSITORY}."
157
+ echo "This workflow builds Docker images with repository secrets, so comment-triggered deploys only run for branches in the base repository."
158
+ } >> "$GITHUB_STEP_SUMMARY"
159
+ exit 0
160
+ fi
161
+
162
+ echo "Review app deploys from fork pull requests are not allowed for workflow_dispatch because this workflow uses repository secrets." >&2
163
+ exit 1
164
+
165
+ - name: Checkout PR commit
166
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
167
+ uses: actions/checkout@v4
168
+ with:
169
+ ref: ${{ env.PR_SHA }}
170
+ path: app
171
+ persist-credentials: false
172
+
173
+ - name: Remove PR checkout Git metadata
174
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
175
+ shell: bash
176
+ run: |
177
+ set -euo pipefail
178
+ rm -rf app/.git
179
+
180
+ - name: Setup environment
181
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
182
+ uses: ./.github/actions/cpflow-setup-environment
183
+ with:
184
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
185
+ org: ${{ vars.CPLN_ORG_STAGING }}
186
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
187
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
188
+
189
+ - name: Detect release phase support
190
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
191
+ id: release-phase
192
+ uses: ./.github/actions/cpflow-detect-release-phase
193
+ with:
194
+ app_name: ${{ env.APP_NAME }}
195
+ working_directory: app
196
+
197
+ - name: Check if review app exists
198
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
199
+ id: check-app
200
+ working-directory: app
201
+ shell: bash
202
+ run: |
203
+ set -euo pipefail
204
+
205
+ exists_output=""
206
+ set +e
207
+ exists_output="$(cpflow exists -a "${APP_NAME}" --org "${CPLN_ORG}" 2>&1)"
208
+ exists_status=$?
209
+ set -e
210
+
211
+ case "${exists_status}" in
212
+ 0)
213
+ if [[ -n "${exists_output}" ]]; then
214
+ printf '%s\n' "${exists_output}"
215
+ fi
216
+ echo "exists=true" >> "$GITHUB_OUTPUT"
217
+ ;;
218
+ 3)
219
+ if [[ -n "${exists_output}" ]]; then
220
+ printf '%s\n' "${exists_output}"
221
+ fi
222
+ echo "exists=false" >> "$GITHUB_OUTPUT"
223
+ ;;
224
+ *)
225
+ echo "::error::cpflow exists returned unexpected exit code ${exists_status} for ${APP_NAME}" >&2
226
+ if [[ -n "${exists_output}" ]]; then
227
+ printf '%s\n' "${exists_output}" >&2
228
+ fi
229
+ exit "${exists_status}"
230
+ ;;
231
+ esac
232
+
233
+ - name: Skip auto deploy until a review app is created
234
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request'
235
+ shell: bash
236
+ run: |
237
+ {
238
+ echo "Review app ${APP_NAME} does not exist yet."
239
+ echo "Create it with a PR comment that is exactly /deploy-review-app."
240
+ } >> "$GITHUB_STEP_SUMMARY"
241
+
242
+ - name: Setup review app if it does not exist yet
243
+ id: setup-review-app
244
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request'
245
+ working-directory: app
246
+ shell: bash
247
+ run: |
248
+ set -euo pipefail
249
+ cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}"
250
+
251
+ - name: Create initial PR comment
252
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
253
+ id: create-comment
254
+ uses: actions/github-script@v7
255
+ with:
256
+ script: |
257
+ const result = await github.rest.issues.createComment({
258
+ owner: context.repo.owner,
259
+ repo: context.repo.repo,
260
+ issue_number: Number(process.env.PR_NUMBER),
261
+ body: "🚀 Starting Control Plane review app deployment..."
262
+ });
263
+ core.setOutput("comment-id", result.data.id);
264
+
265
+ - name: Set deployment links
266
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
267
+ uses: actions/github-script@v7
268
+ with:
269
+ script: |
270
+ const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
271
+ core.exportVariable("WORKFLOW_URL", workflowUrl);
272
+ core.exportVariable(
273
+ "CONSOLE_URL",
274
+ `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/gvc/${process.env.APP_NAME}/-info`
275
+ );
276
+
277
+ - name: Initialize GitHub deployment
278
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
279
+ id: init-deployment
280
+ uses: actions/github-script@v7
281
+ with:
282
+ script: |
283
+ const deployment = await github.rest.repos.createDeployment({
284
+ owner: context.repo.owner,
285
+ repo: context.repo.repo,
286
+ ref: process.env.PR_SHA,
287
+ environment: `review/${process.env.APP_NAME}`,
288
+ auto_merge: false,
289
+ required_contexts: [], // intentional: review apps deploy regardless of required status checks
290
+ description: `Control Plane review app for PR #${process.env.PR_NUMBER}`
291
+ });
292
+
293
+ await github.rest.repos.createDeploymentStatus({
294
+ owner: context.repo.owner,
295
+ repo: context.repo.repo,
296
+ deployment_id: deployment.data.id,
297
+ state: "in_progress",
298
+ description: "Deployment started"
299
+ });
300
+
301
+ return deployment.data.id;
302
+
303
+ - name: Update PR comment with build status
304
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
305
+ uses: actions/github-script@v7
306
+ with:
307
+ script: |
308
+ const commentId = Number("${{ steps.create-comment.outputs.comment-id }}");
309
+ if (!Number.isFinite(commentId) || commentId <= 0) {
310
+ core.warning("Skipping PR comment update because no comment id was created.");
311
+ return;
312
+ }
313
+
314
+ const body = [
315
+ `🏗️ Building Docker image for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}`,
316
+ "",
317
+ `[View build logs](${process.env.WORKFLOW_URL})`,
318
+ "",
319
+ `[Open Control Plane console](${process.env.CONSOLE_URL})`
320
+ ].join("\n");
321
+
322
+ await github.rest.issues.updateComment({
323
+ owner: context.repo.owner,
324
+ repo: context.repo.repo,
325
+ comment_id: commentId,
326
+ body
327
+ });
328
+
329
+ - name: Build Docker image
330
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
331
+ uses: ./.github/actions/cpflow-build-docker-image
332
+ with:
333
+ app_name: ${{ env.APP_NAME }}
334
+ org: ${{ vars.CPLN_ORG_STAGING }}
335
+ commit: ${{ env.PR_SHA }}
336
+ pr_number: ${{ env.PR_NUMBER }}
337
+ docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }}
338
+ docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
339
+ docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }}
340
+ working_directory: app
341
+
342
+ - name: Update PR comment with deploy status
343
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
344
+ uses: actions/github-script@v7
345
+ with:
346
+ script: |
347
+ const commentId = Number("${{ steps.create-comment.outputs.comment-id }}");
348
+ if (!Number.isFinite(commentId) || commentId <= 0) {
349
+ core.warning("Skipping PR comment update because no comment id was created.");
350
+ return;
351
+ }
352
+
353
+ const body = [
354
+ "🚀 Deploying review app to Control Plane...",
355
+ "",
356
+ `[View deploy logs](${process.env.WORKFLOW_URL})`,
357
+ "",
358
+ `[Open Control Plane console](${process.env.CONSOLE_URL})`
359
+ ].join("\n");
360
+
361
+ await github.rest.issues.updateComment({
362
+ owner: context.repo.owner,
363
+ repo: context.repo.repo,
364
+ comment_id: commentId,
365
+ body
366
+ });
367
+
368
+ - name: Deploy to Control Plane
369
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
370
+ working-directory: app
371
+ env:
372
+ RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }}
373
+ shell: bash
374
+ run: |
375
+ set -euo pipefail
376
+
377
+ deploy_args=(-a "${APP_NAME}")
378
+ if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
379
+ deploy_args+=("${RELEASE_PHASE_FLAG}")
380
+ fi
381
+ deploy_args+=(--org "${CPLN_ORG}" --verbose)
382
+
383
+ cpflow deploy-image "${deploy_args[@]}"
384
+
385
+ - name: Retrieve app URL
386
+ if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
387
+ id: workload
388
+ working-directory: app
389
+ shell: bash
390
+ run: |
391
+ set -euo pipefail
392
+ workload_name="${PRIMARY_WORKLOAD:-rails}"
393
+ workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')"
394
+ echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT"
395
+
396
+ - name: Finalize deployment status
397
+ if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
398
+ uses: actions/github-script@v7
399
+ with:
400
+ script: |
401
+ const commentId = Number("${{ steps.create-comment.outputs.comment-id }}");
402
+ const deploymentId = "${{ steps.init-deployment.outputs.result }}";
403
+ const appUrl = "${{ steps.workload.outputs.workload_url }}";
404
+ const success = "${{ job.status }}" === "success";
405
+
406
+ if (deploymentId) {
407
+ await github.rest.repos.createDeploymentStatus({
408
+ owner: context.repo.owner,
409
+ repo: context.repo.repo,
410
+ deployment_id: Number(deploymentId),
411
+ state: success ? "success" : "failure",
412
+ environment: `review/${process.env.APP_NAME}`,
413
+ environment_url: success && appUrl ? appUrl : undefined,
414
+ log_url: process.env.WORKFLOW_URL,
415
+ description: success ? "Review app ready" : "Review app deployment failed"
416
+ });
417
+ }
418
+
419
+ const successBody = [
420
+ "## Review app ready",
421
+ "",
422
+ appUrl ? `[Open review app](${appUrl})` : "Review app deployed, but no endpoint URL was detected.",
423
+ "",
424
+ `[Open Control Plane console](${process.env.CONSOLE_URL})`,
425
+ `[View workflow logs](${process.env.WORKFLOW_URL})`
426
+ ].join("\n");
427
+
428
+ const failureBody = [
429
+ `❌ Review app deployment failed for PR #${process.env.PR_NUMBER}`,
430
+ "",
431
+ `[Open Control Plane console](${process.env.CONSOLE_URL})`,
432
+ `[View workflow logs](${process.env.WORKFLOW_URL})`
433
+ ].join("\n");
434
+
435
+ if (!Number.isFinite(commentId) || commentId <= 0) {
436
+ core.warning("Skipping PR comment update because no comment id was created.");
437
+ return;
438
+ }
439
+
440
+ await github.rest.issues.updateComment({
441
+ owner: context.repo.owner,
442
+ repo: context.repo.repo,
443
+ comment_id: commentId,
444
+ body: success ? successBody : failureBody
445
+ });