cpflow 5.1.0 → 5.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44479e287fa1f7366a4df86ee7f68176c00f2b6cd2fe59c786e5de6f1143d2b3
4
- data.tar.gz: acbf5149907b43cc628d2af8c19572d0ffb5de6d1bae630cb98c14c5ca46d4cc
3
+ metadata.gz: 183da85ac156c39e59af60c42727a8144e9b23bcc44fcacb3eb6a0498a3ab831
4
+ data.tar.gz: c801e2e1c97114fbd405494600ad9c637b757256331dd59a4069cc56e59b3934
5
5
  SHA512:
6
- metadata.gz: 1bf64792213761b8e5af2f44bf18c90c32c2ceeef36c23082afbc34def70d7059bb36747dc696e7bb4236538f3670dc177c9d2b06de68459d1e80634604e088a
7
- data.tar.gz: 3c789100969a47e6d7b29efa75fb1b99b799074b89e8cf276dbdf505bab36adec20e010700366e36fc12063426ca80cacf58db3f17253f3dbc241a6533485104
6
+ metadata.gz: d9a96ff2bafc56fa5d735780295c2b100f43bacc39c5726b9171e2f4390799168ed6d08286147d3fda284484488687dd98869d69b3eb1de41b87ca8e211c48ec
7
+ data.tar.gz: affc08be1954d87d78a3284ba44c44a66617bdd8ddf3d1987f661fbb2751d7897b53feb82c8a1bfa4085e649f6f8dd5222d04f9142ed73b65f1c4b8af3b1eb2d
@@ -1,8 +1,9 @@
1
1
  name: Wait for Control Plane workload health
2
2
  description: >-
3
- Polls the workload's status endpoint with curl and exits success when the
4
- HTTP response status is in the accepted list. Fails non-zero (and reports
5
- `healthy=false`) once retries are exhausted.
3
+ Polls Control Plane until the latest workload version is ready, then checks
4
+ the workload endpoint with curl. Exits success when the HTTP response status
5
+ is in the accepted list. Fails non-zero (and reports `healthy=false`) once
6
+ retries are exhausted.
6
7
 
7
8
  inputs:
8
9
  workload_name:
@@ -68,8 +69,14 @@ runs:
68
69
  exit 1
69
70
  fi
70
71
 
72
+ workload_ready="$(echo "${workload_json}" | jq -r '.status.ready // false')"
73
+ latest_ready="$(echo "${workload_json}" | jq -r '.status.readyLatest // false')"
74
+ readiness_status="$(echo "${workload_json}" | jq -r '.health.readiness // "unknown"')"
71
75
  endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')"
72
- if [[ -n "${endpoint}" ]]; then
76
+
77
+ if [[ "${workload_ready}" != "true" || "${latest_ready}" != "true" ]]; then
78
+ echo "Workload status: ready=${workload_ready}, readyLatest=${latest_ready}, readiness=${readiness_status}; waiting for latest deployment."
79
+ elif [[ -n "${endpoint}" ]]; then
73
80
  http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || echo 000)"
74
81
  echo "Endpoint: ${endpoint}, HTTP status: ${http_status}"
75
82
 
@@ -110,11 +110,58 @@ jobs:
110
110
  variable:STAGING_APP_NAME
111
111
  variable:PRODUCTION_APP_NAME
112
112
 
113
+ - name: Normalize Control Plane org names
114
+ id: cpln-orgs
115
+ env:
116
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
117
+ CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
118
+ shell: bash
119
+ run: |
120
+ set -euo pipefail
121
+
122
+ sanitize_control_plane_name() {
123
+ local label="$1"
124
+ local value="$2"
125
+
126
+ value="${value#"${value%%[![:space:]]*}"}"
127
+ value="${value%"${value##*[![:space:]]}"}"
128
+
129
+ if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then
130
+ echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2
131
+ exit 1
132
+ fi
133
+
134
+ printf '%s' "${value}"
135
+ }
136
+
137
+ validate_control_plane_org() {
138
+ local label="$1"
139
+ local value="$2"
140
+
141
+ if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
142
+ local display_value
143
+ display_value="$(printf '%q' "${value}")"
144
+ echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen." >&2
145
+ exit 1
146
+ fi
147
+ }
148
+
149
+ staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")"
150
+ production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")"
151
+
152
+ validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}"
153
+ validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}"
154
+
155
+ {
156
+ echo "staging=${staging_org}"
157
+ echo "production=${production_org}"
158
+ } >> "$GITHUB_OUTPUT"
159
+
113
160
  - name: Setup production environment
114
161
  uses: ./.cpflow/.github/actions/cpflow-setup-environment
115
162
  with:
116
163
  token: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
117
- org: ${{ vars.CPLN_ORG_PRODUCTION }}
164
+ org: ${{ steps.cpln-orgs.outputs.production }}
118
165
  working_directory: .cpflow
119
166
  cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
120
167
  cpflow_version: ${{ vars.CPFLOW_VERSION }}
@@ -183,42 +230,100 @@ jobs:
183
230
  CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
184
231
  STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }}
185
232
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
186
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
187
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
233
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
234
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
235
+ WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
188
236
  shell: bash
189
237
  run: |
190
238
  set -euo pipefail
191
239
 
192
- staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json | jq -r '.spec.env // [] | .[].name' | sort)"
193
- production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.spec.env // [] | .[].name' | sort)"
240
+ list_gvc_env_names() {
241
+ local token="$1"
242
+ local org="$2"
243
+ local app="$3"
244
+
245
+ CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json |
246
+ jq -r '.spec.env // [] | .[] | .name // empty' |
247
+ sort -u
248
+ }
249
+
250
+ list_workload_env_names() {
251
+ local token="$1"
252
+ local org="$2"
253
+ local app="$3"
254
+ local workload="$4"
255
+
256
+ CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json |
257
+ jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' |
258
+ sort -u
259
+ }
260
+
261
+ check_required_vars() {
262
+ local staging_scope="$1"
263
+ local production_scope="$2"
264
+ local missing_message="$3"
265
+ local staging_vars="$4"
266
+ local production_vars="$5"
267
+ local missing_vars
268
+ local production_only_vars
269
+
270
+ if [[ -z "${staging_vars}" ]]; then
271
+ echo "Staging ${staging_scope} exposes no environment variables; skipping parity check."
272
+ return
273
+ fi
194
274
 
195
- if [[ -z "${staging_vars}" ]]; then
196
- echo "Staging GVC exposes no environment variables; skipping parity check."
197
- exit 0
198
- fi
275
+ # Treat staging as the promotion source of truth: fail when a variable
276
+ # present in staging is missing in production. Production-only variables
277
+ # are allowed, but surface them so teams can spot drift.
278
+ missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
279
+ production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
199
280
 
200
- # Treat staging as the promotion source of truth: fail when a variable
201
- # present in staging is missing in production. Production-only variables
202
- # are allowed, but surface them so teams can spot drift.
203
- missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
204
- production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
281
+ if [[ -n "${production_only_vars}" ]]; then
282
+ echo "::warning::Production ${production_scope} has environment variables that are not present in staging:"
283
+ echo "${production_only_vars}"
284
+ fi
205
285
 
206
- if [[ -n "${production_only_vars}" ]]; then
207
- echo "::warning::Production has environment variables that are not present in staging:"
208
- echo "${production_only_vars}"
209
- fi
286
+ if [[ -n "${missing_vars}" ]]; then
287
+ echo "::error::${missing_message}"
288
+ echo "${missing_vars}"
289
+ env_check_failed=1
290
+ fi
291
+ }
292
+
293
+ # check_required_vars intentionally mutates env_check_failed in this
294
+ # shell; keep calls outside subshells so failures aggregate before the
295
+ # final exit.
296
+ env_check_failed=0
297
+
298
+ staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")"
299
+ production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")"
300
+ check_required_vars \
301
+ "GVC '${STAGING_APP_NAME}'" \
302
+ "GVC '${PRODUCTION_APP_NAME}'" \
303
+ "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \
304
+ "${staging_vars}" \
305
+ "${production_vars}"
210
306
 
211
- if [[ -n "${missing_vars}" ]]; then
212
- echo "::error::Production is missing environment variables that exist in staging"
213
- echo "${missing_vars}"
214
- exit 1
215
- fi
307
+ while IFS= read -r workload_name; do
308
+ [[ -n "${workload_name}" ]] || continue
309
+
310
+ staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")"
311
+ production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")"
312
+ check_required_vars \
313
+ "workload '${workload_name}'" \
314
+ "workload '${workload_name}'" \
315
+ "Production workload '${workload_name}' is missing environment variables that exist in staging" \
316
+ "${staging_workload_vars}" \
317
+ "${production_workload_vars}"
318
+ done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}")
319
+
320
+ exit "${env_check_failed}"
216
321
 
217
322
  - name: Capture current production image
218
323
  id: capture-current
219
324
  env:
220
325
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
221
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
326
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
222
327
  WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
223
328
  PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
224
329
  shell: bash
@@ -274,7 +379,7 @@ jobs:
274
379
  env:
275
380
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
276
381
  STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }}
277
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
382
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
278
383
  WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
279
384
  PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
280
385
  shell: bash
@@ -316,14 +421,17 @@ jobs:
316
421
 
317
422
  echo "image=${staging_image}" >> "$GITHUB_OUTPUT"
318
423
 
424
+ - name: Set up Docker Buildx
425
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
426
+
319
427
  - name: Copy image from staging
428
+ id: copy-image
320
429
  env:
321
- # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc/<pid>/cmdline.
322
430
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
323
- CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }}
431
+ CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
324
432
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
325
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
326
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
433
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
434
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
327
435
  STAGING_IMAGE: ${{ steps.staging-image.outputs.image }}
328
436
  shell: bash
329
437
  run: |
@@ -343,14 +451,82 @@ jobs:
343
451
  copy_image_attempts=$((copy_image_retries + 1))
344
452
  copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL}))
345
453
 
346
- if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
454
+ staging_image="${STAGING_IMAGE}"
455
+ if [[ -z "${staging_image}" ]]; then
456
+ echo "::error::STAGING_IMAGE is not set or is empty."
457
+ exit 1
458
+ fi
459
+
460
+ if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
347
461
  echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion."
348
462
  exit 1
349
463
  fi
350
464
 
465
+ staging_tag=""
466
+ if [[ "${staging_image}" == *@* ]]; then
467
+ staging_tag="${staging_image##*@}"
468
+ elif [[ "${staging_image}" == *:* ]]; then
469
+ staging_tag="${staging_image##*:}"
470
+ fi
471
+ staging_commit=""
472
+ if [[ "${staging_tag}" == *_* ]]; then
473
+ staging_commit="${staging_tag##*_}"
474
+ else
475
+ echo "::warning::Staging image '${staging_image}' did not include a '_<commit>' suffix; production image tag will omit the commit suffix."
476
+ fi
477
+
478
+ # The workflow-level concurrency group serializes this sequence so two
479
+ # production promotions cannot derive and publish the same next tag.
480
+ # See the top-level concurrency group: cpflow-promote-staging-to-production.
481
+ latest_number="$(
482
+ cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |
483
+ jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
484
+ '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
485
+ )"
486
+ if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
487
+ echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'."
488
+ exit 1
489
+ fi
490
+
491
+ production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))"
492
+ if [[ -n "${staging_commit}" ]]; then
493
+ production_image="${production_image}_${staging_commit}"
494
+ fi
495
+
496
+ staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"
497
+ production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"
498
+ source_image_ref="${staging_registry}/${STAGING_IMAGE}"
499
+ production_image_ref="${production_registry}/${production_image}"
500
+
501
+ docker_config_dir="$(mktemp -d)"
502
+ cleanup_copy_credentials() {
503
+ rm -rf "${docker_config_dir}"
504
+ }
505
+ trap cleanup_copy_credentials EXIT
506
+
507
+ export DOCKER_CONFIG="${docker_config_dir}"
508
+
509
+ if ! printf '%s' "${CPLN_TOKEN_STAGING}" |
510
+ docker login "${staging_registry}" -u '<token>' --password-stdin >/dev/null; then
511
+ echo "::error::Failed to authenticate to staging registry '${staging_registry}'."
512
+ exit 1
513
+ fi
514
+
515
+ if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" |
516
+ docker login "${production_registry}" -u '<token>' --password-stdin >/dev/null; then
517
+ echo "::error::Failed to authenticate to production registry '${production_registry}'."
518
+ exit 1
519
+ fi
520
+
521
+ if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then
522
+ echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it."
523
+ exit 1
524
+ fi
525
+
351
526
  copy_status=1
352
527
  for attempt in $(seq 1 "${copy_image_attempts}"); do
353
- if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
528
+ if docker buildx imagetools inspect "${source_image_ref}" >/dev/null &&
529
+ docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then
354
530
  copy_status=0
355
531
  break
356
532
  else
@@ -370,10 +546,12 @@ jobs:
370
546
  exit "${copy_status}"
371
547
  fi
372
548
 
549
+ echo "image=${production_image}" >> "$GITHUB_OUTPUT"
550
+
373
551
  - name: Deploy image to production
374
552
  env:
375
553
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
376
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
554
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
377
555
  RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }}
378
556
  shell: bash
379
557
  run: |
@@ -383,6 +561,9 @@ jobs:
383
561
  if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
384
562
  deploy_args+=("${RELEASE_PHASE_FLAG}")
385
563
  fi
564
+ # `cpflow deploy-image` deploys the latest image for the app. The
565
+ # workflow-level concurrency group keeps production promotion copy and
566
+ # deploy steps coupled across workflow runs.
386
567
  deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose)
387
568
 
388
569
  cpflow deploy-image "${deploy_args[@]}"
@@ -393,7 +574,7 @@ jobs:
393
574
  with:
394
575
  workload_name: ${{ steps.workloads.outputs.primary }}
395
576
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
396
- org: ${{ vars.CPLN_ORG_PRODUCTION }}
577
+ org: ${{ steps.cpln-orgs.outputs.production }}
397
578
  max_retries: ${{ env.HEALTH_CHECK_RETRIES }}
398
579
  interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }}
399
580
  accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }}
@@ -403,7 +584,7 @@ jobs:
403
584
  env:
404
585
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
405
586
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
406
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
587
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
407
588
  shell: bash
408
589
  run: |
409
590
  # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end
@@ -464,7 +645,7 @@ jobs:
464
645
  env:
465
646
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
466
647
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
467
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
648
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
468
649
  shell: bash
469
650
  run: |
470
651
  set -euo pipefail
@@ -489,8 +670,10 @@ jobs:
489
670
  set -euo pipefail
490
671
  ready=false
491
672
  for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do
492
- deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')"
493
- if [[ "${deployment_ready}" == "true" ]]; then
673
+ workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)"
674
+ deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')"
675
+ latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')"
676
+ if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then
494
677
  ready=true
495
678
  break
496
679
  fi
@@ -531,6 +714,7 @@ jobs:
531
714
  HEALTHY: ${{ steps.health-check.outputs.healthy }}
532
715
  PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }}
533
716
  PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }}
717
+ COPIED_IMAGE: ${{ steps.copy-image.outputs.image }}
534
718
  shell: bash
535
719
  run: |
536
720
  {
@@ -538,12 +722,15 @@ jobs:
538
722
  echo
539
723
  if [[ "${HEALTHY}" == "true" ]]; then
540
724
  echo "✅ Status: deployment successful"
725
+ deployed_image="${COPIED_IMAGE}"
541
726
  else
542
727
  echo "❌ Status: deployment failed"
728
+ deployed_image="${PREVIOUS_IMAGE}"
543
729
  fi
544
730
  echo
545
731
  echo "Previous image: \`${PREVIOUS_IMAGE}\`"
546
732
  echo "Previous version: ${PREVIOUS_VERSION}"
733
+ echo "Deployed image: \`${deployed_image}\`"
547
734
  } >> "$GITHUB_STEP_SUMMARY"
548
735
 
549
736
  create-github-release:
@@ -19,8 +19,15 @@ on:
19
19
  jobs:
20
20
  rspec:
21
21
  runs-on: ${{ inputs.os_version }}
22
+ # Scope the live Control Plane org queue per PR (or ref) instead of globally:
23
+ # each run uses its own random app suffix (SecureRandom.hex(2)) on a fresh
24
+ # runner, so concurrent PRs don't collide on app names or CLI profiles. PRs
25
+ # run only the fast (~slow) suite, which doesn't switch the shared domain's
26
+ # route; domain-mutating specs are :slow and dispatched manually, keyed by
27
+ # github.ref so same-ref dispatches still serialize. cancel-in-progress is
28
+ # false, so queued runs wait their turn rather than being cancelled.
22
29
  concurrency:
23
- group: cpln-shared-org-${{ vars.CPLN_ORG || github.run_id }}
30
+ group: cpln-shared-org-${{ vars.CPLN_ORG || github.run_id }}-${{ github.event.pull_request.number || github.ref }}
24
31
  cancel-in-progress: false
25
32
  env:
26
33
  RAILS_ENV: test
data/CHANGELOG.md CHANGED
@@ -12,6 +12,19 @@ In addition to the standard keepachangelog.com categories, this project uses a l
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [5.1.1] - 2026-06-03
16
+
17
+ ### Changed
18
+
19
+ - **Changed `cpflow maintenance:on` and `cpflow maintenance:off` to confirm the domain route has switched by polling the Control Plane API (bounded retry, 30 attempts, 1 second apart) instead of sleeping a fixed 30 seconds.** [PR 337](https://github.com/shakacode/control-plane-flow/pull/337) by [Justin Gordon](https://github.com/justin808). Fixes [issue 157](https://github.com/shakacode/control-plane-flow/issues/157). If the route never updates within the poll window, the command aborts before stopping workloads so traffic stays on the current workload, and transient API errors during polling are retried rather than aborting the switch. Because the route switch and the workload stop run as separate steps, re-running the command also finishes a switch whose poll timed out after the route had already updated.
20
+ - **Reworked generated production-promotion image copy to authenticate directly to the staging and production Docker registries and copy via `docker buildx imagetools create`, handling digest-pinned, plain numeric, commit-suffixed, and multi-arch image refs.** [PR 356](https://github.com/shakacode/control-plane-flow/pull/356) by [Justin Gordon](https://github.com/justin808). Promotion now normalizes Control Plane org variables before each step, preflights environment-variable parity between staging and production at the GVC and app-workload container level (failing before the copy when production is missing names that exist in staging), and requires both `status.ready` and `status.readyLatest` before endpoint health checks and rollback polling so a stale ready replica cannot mask a failed latest revision.
21
+ - **Generated production promotion now emits a workflow warning when a staging image tag lacks a `_<commit>` suffix**, so production tags without commit traceability are visible in logs, and documents the `cpflow-promote-staging-to-production` concurrency group in the copy step. [PR 360](https://github.com/shakacode/control-plane-flow/pull/360) by [Justin Gordon](https://github.com/justin808).
22
+ - **Restored review-app security guidance in generated `.github/cpflow-help.md`** (public-repo staging-token scoping, fork-PR deploy limits, secret exposure via `cpln://secret/...`, and read-only deploy keys for `DOCKER_BUILD_SSH_KEY`), and simplified the promotion workflow's staging image assignment while preserving digest refs. [PR 359](https://github.com/shakacode/control-plane-flow/pull/359) by [Justin Gordon](https://github.com/justin808).
23
+
24
+ ### Fixed
25
+
26
+ - **Fixed `cpflow run` so short non-interactive runner jobs no longer hang when the Control Plane cron job finishes before a runner replica is visible.** [PR 361](https://github.com/shakacode/control-plane-flow/pull/361) by [Justin Gordon](https://github.com/justin808). This prevents generated deploy workflows with release-phase commands from waiting until the GitHub Actions job timeout even though the release job already completed successfully.
27
+
15
28
  ## [5.1.0] - 2026-06-02
16
29
 
17
30
  ### Added
@@ -410,7 +423,8 @@ Deprecated `cpl` gem. New gem is `cpflow`.
410
423
 
411
424
  First release.
412
425
 
413
- [Unreleased]: https://github.com/shakacode/control-plane-flow/compare/v5.1.0...HEAD
426
+ [Unreleased]: https://github.com/shakacode/control-plane-flow/compare/v5.1.1...HEAD
427
+ [5.1.1]: https://github.com/shakacode/control-plane-flow/compare/v5.1.0...v5.1.1
414
428
  [5.1.0]: https://github.com/shakacode/control-plane-flow/compare/v5.0.4...v5.1.0
415
429
  [5.0.4]: https://github.com/shakacode/control-plane-flow/compare/v5.0.3...v5.0.4
416
430
  [5.0.3]: https://github.com/shakacode/control-plane-flow/compare/v5.0.2...v5.0.3
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cpflow (5.1.0)
4
+ cpflow (5.1.1)
5
5
  dotenv (~> 3.1)
6
6
  jwt (~> 3.1)
7
7
  psych (~> 5.2)
data/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="./docs/assets/logo/icon-tile.svg" alt="Control Plane Flow (cpflow) logo" width="160" height="160" />
3
+ </p>
4
+
1
5
  # The power of Kubernetes with the ease of Heroku!
2
6
 
3
7
  <meta name="author" content="Justin Gordon and Sergey Tarasov" />
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Control Plane Flow icon">
2
+ <title>Control Plane Flow icon</title>
3
+ <rect x="64" y="64" width="896" height="896" rx="192" fill="#0B1118" stroke="#26313D" stroke-width="12"/>
4
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round">
5
+ <path d="M290 650 C330 500 435 430 512 512 C600 606 704 560 746 390" stroke="#2BD7FF" stroke-width="68"/>
6
+ <path d="M746 390 L688 397" stroke="#2BD7FF" stroke-width="68"/>
7
+ <path d="M746 390 L731 446" stroke="#2BD7FF" stroke-width="68"/>
8
+ </g>
9
+ <circle cx="290" cy="650" r="92" fill="#8973FF"/>
10
+ <circle cx="512" cy="512" r="92" fill="#70D187"/>
11
+ <circle cx="746" cy="390" r="92" fill="#FFB000"/>
12
+ <circle cx="290" cy="650" r="32" fill="#0B1118" opacity="0.88"/>
13
+ <circle cx="512" cy="512" r="32" fill="#0B1118" opacity="0.88"/>
14
+ <circle cx="746" cy="390" r="32" fill="#0B1118" opacity="0.88"/>
15
+ <path d="M390 730 H612" stroke="#2BD7FF" stroke-width="40" stroke-linecap="round" opacity="0.86"/>
16
+ <path d="M612 730 L555 692 M612 730 L555 768" stroke="#2BD7FF" stroke-width="40" stroke-linecap="round" stroke-linejoin="round" opacity="0.86"/>
17
+ </svg>
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Control Plane Flow icon transparent mark">
2
+ <title>Control Plane Flow icon transparent mark</title>
3
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M290 650 C330 500 435 430 512 512 C600 606 704 560 746 390" stroke="#2BD7FF" stroke-width="68"/>
5
+ <path d="M746 390 L688 397" stroke="#2BD7FF" stroke-width="68"/>
6
+ <path d="M746 390 L731 446" stroke="#2BD7FF" stroke-width="68"/>
7
+ </g>
8
+ <circle cx="290" cy="650" r="92" fill="#8973FF"/>
9
+ <circle cx="512" cy="512" r="92" fill="#70D187"/>
10
+ <circle cx="746" cy="390" r="92" fill="#FFB000"/>
11
+ <circle cx="290" cy="650" r="32" fill="#0B1118" opacity="0.88"/>
12
+ <circle cx="512" cy="512" r="32" fill="#0B1118" opacity="0.88"/>
13
+ <circle cx="746" cy="390" r="32" fill="#0B1118" opacity="0.88"/>
14
+ <path d="M390 730 H612" stroke="#2BD7FF" stroke-width="40" stroke-linecap="round" opacity="0.86"/>
15
+ <path d="M612 730 L555 692 M612 730 L555 768" stroke="#2BD7FF" stroke-width="40" stroke-linecap="round" stroke-linejoin="round" opacity="0.86"/>
16
+ </svg>
@@ -196,6 +196,30 @@ For production promotion, also configure:
196
196
  - `CPLN_ORG_PRODUCTION` as a production environment variable, for example `company-production`
197
197
  - `PRODUCTION_APP_NAME` as a production environment variable, for example `my-app-production`
198
198
 
199
+ Enter GitHub variables such as `CPLN_ORG_STAGING`,
200
+ `CPLN_ORG_PRODUCTION`, `STAGING_APP_NAME`, and `PRODUCTION_APP_NAME`
201
+ as plain single-line values. The generated production promotion workflow trims
202
+ accidental leading/trailing whitespace and line endings from Control Plane org
203
+ names before building registry URLs, but embedded line breaks are rejected
204
+ because they could change the target org name after normalization.
205
+
206
+ Production promotion copies the exact image currently deployed on the selected
207
+ staging workload. If that staging image is digest-pinned, the digest is used for
208
+ the source copy while the production tag is derived from the tag portion. Tags
209
+ with a `_<commit>` suffix keep that suffix in production; plain numeric tags are
210
+ also valid and promote to the next plain production tag. The copy step uses
211
+ `docker buildx imagetools create --prefer-index=false --tag` with isolated
212
+ Docker credentials, which preserves multi-architecture manifests, preserves
213
+ single-platform manifest format when supported, and avoids pulling image layers
214
+ onto the GitHub Actions runner.
215
+
216
+ Before copying the image, production promotion compares the environment variable
217
+ names exposed by staging and production at both the GVC level and each configured
218
+ app workload's container level. Variables present in staging are treated as
219
+ required for production, while production-only variables emit warnings. A missing
220
+ production workload variable such as a renderer password or runtime secret fails
221
+ the promotion before the image copy starts.
222
+
199
223
  Do not put `CPLN_TOKEN_PRODUCTION` in repository or organization secrets for
200
224
  sensitive production systems. Production promotion intentionally runs as a
201
225
  normal caller-repo workflow job with `environment: production`, then checks out
@@ -271,6 +295,12 @@ cpflow setup-app -a my-app-production --org my-org-production --skip-post-creati
271
295
  Use production-only runtime secrets and values for the production app. The
272
296
  protected GitHub Environment controls who can run the promotion workflow, but
273
297
  the production app resources still need to exist before the first promotion.
298
+ After bootstrap, populate the production app secret dictionary with the values
299
+ referenced by `.controlplane/templates`, then run `cpflow apply-template` against
300
+ production when templates change so the workload env references remain persisted.
301
+ Production promotion checks for missing GVC and workload container env names
302
+ before copying the staging image, so a staging-only runtime variable will stop
303
+ the run early instead of deploying an image that cannot boot.
274
304
 
275
305
  Review apps are different: the generated `+review-app-deploy` workflow creates
276
306
  temporary PR apps as needed, including the identity and secret policy binding.
@@ -316,6 +346,17 @@ The standard path is:
316
346
  7. Store `CPLN_ORG_PRODUCTION` and `PRODUCTION_APP_NAME` as `production`
317
347
  environment variables, or as repository variables only when those names are
318
348
  intentionally non-sensitive.
349
+ 8. Keep GitHub variable values single-line; a pasted trailing newline is trimmed
350
+ for Control Plane org names, but embedded line breaks are rejected before
351
+ deployment, copy, health-check, or rollback steps run.
352
+ 9. Bootstrap or re-apply the persistent production app templates before first
353
+ promotion so app workload container env references and Control Plane secret
354
+ dictionaries exist in production.
355
+ 10. Expect promotion to preserve the selected staging image reference. Digest
356
+ references are copied by digest, commit-suffixed tags keep the commit suffix,
357
+ and plain numeric tags remain valid.
358
+ 11. Expect production health and rollback readiness polling to require Control
359
+ Plane `status.ready` and `status.readyLatest` before checking the endpoint.
319
360
 
320
361
  GitHub only exposes environment secrets to jobs that reference the environment
321
362
  after configured protection rules pass. GitHub does not allow a caller job that
@@ -415,8 +456,8 @@ The action will start an SSH agent, add the key, write `known_hosts`, and pass `
415
456
 
416
457
  - Manually promotes the staging artifact to production with a confirmation input.
417
458
  - Runs the production job in the `production` GitHub Environment, so configured reviewers approve the job before production environment secrets are available.
418
- - Verifies that production has the env var names staging expects.
419
- - Runs a health check against `PRIMARY_WORKLOAD`.
459
+ - Verifies that production has the GVC and app workload container env var names staging expects.
460
+ - Runs a health check against `PRIMARY_WORKLOAD` only after Control Plane reports the latest workload version ready.
420
461
  - Attempts a rollback of every configured application workload if the new production image does not come up healthy.
421
462
  - Creates a GitHub release after a successful promotion.
422
463