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.
@@ -109,6 +109,53 @@ jobs:
109
109
  variable:STAGING_APP_NAME
110
110
  variable:PRODUCTION_APP_NAME
111
111
 
112
+ - name: Normalize Control Plane org names
113
+ id: cpln-orgs
114
+ env:
115
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
116
+ CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
117
+ shell: bash
118
+ run: |
119
+ set -euo pipefail
120
+
121
+ sanitize_control_plane_name() {
122
+ local label="$1"
123
+ local value="$2"
124
+
125
+ value="${value#"${value%%[![:space:]]*}"}"
126
+ value="${value%"${value##*[![:space:]]}"}"
127
+
128
+ if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then
129
+ echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2
130
+ exit 1
131
+ fi
132
+
133
+ printf '%s' "${value}"
134
+ }
135
+
136
+ validate_control_plane_org() {
137
+ local label="$1"
138
+ local value="$2"
139
+
140
+ if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
141
+ local display_value
142
+ display_value="$(printf '%q' "${value}")"
143
+ 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
144
+ exit 1
145
+ fi
146
+ }
147
+
148
+ staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")"
149
+ production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")"
150
+
151
+ validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}"
152
+ validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}"
153
+
154
+ {
155
+ echo "staging=${staging_org}"
156
+ echo "production=${production_org}"
157
+ } >> "$GITHUB_OUTPUT"
158
+
112
159
  - name: Capture release context
113
160
  id: release-context
114
161
  env:
@@ -127,7 +174,7 @@ jobs:
127
174
  uses: ./.cpflow/.github/actions/cpflow-setup-environment
128
175
  with:
129
176
  token: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
130
- org: ${{ vars.CPLN_ORG_PRODUCTION }}
177
+ org: ${{ steps.cpln-orgs.outputs.production }}
131
178
  working_directory: .cpflow
132
179
  cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
133
180
  cpflow_version: ${{ vars.CPFLOW_VERSION }}
@@ -200,42 +247,100 @@ jobs:
200
247
  CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
201
248
  STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }}
202
249
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
203
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
204
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
250
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
251
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
252
+ WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
205
253
  shell: bash
206
254
  run: |
207
255
  set -euo pipefail
208
256
 
209
- 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)"
210
- 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)"
257
+ list_gvc_env_names() {
258
+ local token="$1"
259
+ local org="$2"
260
+ local app="$3"
261
+
262
+ CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json |
263
+ jq -r '.spec.env // [] | .[] | .name // empty' |
264
+ sort -u
265
+ }
266
+
267
+ list_workload_env_names() {
268
+ local token="$1"
269
+ local org="$2"
270
+ local app="$3"
271
+ local workload="$4"
272
+
273
+ CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json |
274
+ jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' |
275
+ sort -u
276
+ }
277
+
278
+ check_required_vars() {
279
+ local staging_scope="$1"
280
+ local production_scope="$2"
281
+ local missing_message="$3"
282
+ local staging_vars="$4"
283
+ local production_vars="$5"
284
+ local missing_vars
285
+ local production_only_vars
286
+
287
+ if [[ -z "${staging_vars}" ]]; then
288
+ echo "Staging ${staging_scope} exposes no environment variables; skipping parity check."
289
+ return
290
+ fi
211
291
 
212
- if [[ -z "${staging_vars}" ]]; then
213
- echo "Staging GVC exposes no environment variables; skipping parity check."
214
- exit 0
215
- fi
292
+ # Treat staging as the promotion source of truth: fail when a variable
293
+ # present in staging is missing in production. Production-only variables
294
+ # are allowed, but surface them so teams can spot drift.
295
+ missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
296
+ production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
216
297
 
217
- # Treat staging as the promotion source of truth: fail when a variable
218
- # present in staging is missing in production. Production-only variables
219
- # are allowed, but surface them so teams can spot drift.
220
- missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
221
- production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))"
298
+ if [[ -n "${production_only_vars}" ]]; then
299
+ echo "::warning::Production ${production_scope} has environment variables that are not present in staging:"
300
+ echo "${production_only_vars}"
301
+ fi
222
302
 
223
- if [[ -n "${production_only_vars}" ]]; then
224
- echo "::warning::Production has environment variables that are not present in staging:"
225
- echo "${production_only_vars}"
226
- fi
303
+ if [[ -n "${missing_vars}" ]]; then
304
+ echo "::error::${missing_message}"
305
+ echo "${missing_vars}"
306
+ env_check_failed=1
307
+ fi
308
+ }
309
+
310
+ # check_required_vars intentionally mutates env_check_failed in this
311
+ # shell; keep calls outside subshells so failures aggregate before the
312
+ # final exit.
313
+ env_check_failed=0
314
+
315
+ staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")"
316
+ production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")"
317
+ check_required_vars \
318
+ "GVC '${STAGING_APP_NAME}'" \
319
+ "GVC '${PRODUCTION_APP_NAME}'" \
320
+ "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \
321
+ "${staging_vars}" \
322
+ "${production_vars}"
227
323
 
228
- if [[ -n "${missing_vars}" ]]; then
229
- echo "::error::Production is missing environment variables that exist in staging"
230
- echo "${missing_vars}"
231
- exit 1
232
- fi
324
+ while IFS= read -r workload_name; do
325
+ [[ -n "${workload_name}" ]] || continue
326
+
327
+ staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")"
328
+ production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")"
329
+ check_required_vars \
330
+ "workload '${workload_name}'" \
331
+ "workload '${workload_name}'" \
332
+ "Production workload '${workload_name}' is missing environment variables that exist in staging" \
333
+ "${staging_workload_vars}" \
334
+ "${production_workload_vars}"
335
+ done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}")
336
+
337
+ exit "${env_check_failed}"
233
338
 
234
339
  - name: Capture current production image
235
340
  id: capture-current
236
341
  env:
237
342
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
238
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
343
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
239
344
  WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
240
345
  PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
241
346
  shell: bash
@@ -293,7 +398,7 @@ jobs:
293
398
  env:
294
399
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
295
400
  STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }}
296
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
401
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
297
402
  WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}
298
403
  PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
299
404
  shell: bash
@@ -335,14 +440,17 @@ jobs:
335
440
 
336
441
  echo "image=${staging_image}" >> "$GITHUB_OUTPUT"
337
442
 
443
+ - name: Set up Docker Buildx
444
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
445
+
338
446
  - name: Copy image from staging
447
+ id: copy-image
339
448
  env:
340
- # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc/<pid>/cmdline.
341
449
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
342
- CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }}
450
+ CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
343
451
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
344
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
345
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
452
+ CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}
453
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
346
454
  STAGING_IMAGE: ${{ steps.staging-image.outputs.image }}
347
455
  shell: bash
348
456
  run: |
@@ -362,14 +470,82 @@ jobs:
362
470
  copy_image_attempts=$((copy_image_retries + 1))
363
471
  copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL}))
364
472
 
365
- if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
473
+ staging_image="${STAGING_IMAGE}"
474
+ if [[ -z "${staging_image}" ]]; then
475
+ echo "::error::STAGING_IMAGE is not set or is empty."
476
+ exit 1
477
+ fi
478
+
479
+ if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
366
480
  echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion."
367
481
  exit 1
368
482
  fi
369
483
 
484
+ staging_tag=""
485
+ if [[ "${staging_image}" == *@* ]]; then
486
+ staging_tag="${staging_image##*@}"
487
+ elif [[ "${staging_image}" == *:* ]]; then
488
+ staging_tag="${staging_image##*:}"
489
+ fi
490
+ staging_commit=""
491
+ if [[ "${staging_tag}" == *_* ]]; then
492
+ staging_commit="${staging_tag##*_}"
493
+ else
494
+ echo "::warning::Staging image '${staging_image}' did not include a '_<commit>' suffix; production image tag will omit the commit suffix."
495
+ fi
496
+
497
+ # The workflow-level concurrency group serializes this sequence so two
498
+ # production promotions cannot derive and publish the same next tag.
499
+ # See the top-level concurrency group: cpflow-promote-staging-to-production.
500
+ latest_number="$(
501
+ cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json |
502
+ jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \
503
+ '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?<number>[0-9]+)") catch empty) | .number | tonumber] | max // 0'
504
+ )"
505
+ if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then
506
+ echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'."
507
+ exit 1
508
+ fi
509
+
510
+ production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))"
511
+ if [[ -n "${staging_commit}" ]]; then
512
+ production_image="${production_image}_${staging_commit}"
513
+ fi
514
+
515
+ staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"
516
+ production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"
517
+ source_image_ref="${staging_registry}/${STAGING_IMAGE}"
518
+ production_image_ref="${production_registry}/${production_image}"
519
+
520
+ docker_config_dir="$(mktemp -d)"
521
+ cleanup_copy_credentials() {
522
+ rm -rf "${docker_config_dir}"
523
+ }
524
+ trap cleanup_copy_credentials EXIT
525
+
526
+ export DOCKER_CONFIG="${docker_config_dir}"
527
+
528
+ if ! printf '%s' "${CPLN_TOKEN_STAGING}" |
529
+ docker login "${staging_registry}" -u '<token>' --password-stdin >/dev/null; then
530
+ echo "::error::Failed to authenticate to staging registry '${staging_registry}'."
531
+ exit 1
532
+ fi
533
+
534
+ if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" |
535
+ docker login "${production_registry}" -u '<token>' --password-stdin >/dev/null; then
536
+ echo "::error::Failed to authenticate to production registry '${production_registry}'."
537
+ exit 1
538
+ fi
539
+
540
+ if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then
541
+ echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it."
542
+ exit 1
543
+ fi
544
+
370
545
  copy_status=1
371
546
  for attempt in $(seq 1 "${copy_image_attempts}"); do
372
- if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then
547
+ if docker buildx imagetools inspect "${source_image_ref}" >/dev/null &&
548
+ docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then
373
549
  copy_status=0
374
550
  break
375
551
  else
@@ -389,10 +565,12 @@ jobs:
389
565
  exit "${copy_status}"
390
566
  fi
391
567
 
568
+ echo "image=${production_image}" >> "$GITHUB_OUTPUT"
569
+
392
570
  - name: Deploy image to production
393
571
  env:
394
572
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
395
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
573
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
396
574
  RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }}
397
575
  shell: bash
398
576
  run: |
@@ -402,6 +580,9 @@ jobs:
402
580
  if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
403
581
  deploy_args+=("${RELEASE_PHASE_FLAG}")
404
582
  fi
583
+ # `cpflow deploy-image` deploys the latest image for the app. The
584
+ # workflow-level concurrency group keeps production promotion copy and
585
+ # deploy steps coupled across workflow runs.
405
586
  deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose)
406
587
 
407
588
  cpflow deploy-image "${deploy_args[@]}"
@@ -412,7 +593,7 @@ jobs:
412
593
  with:
413
594
  workload_name: ${{ steps.workloads.outputs.primary }}
414
595
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
415
- org: ${{ vars.CPLN_ORG_PRODUCTION }}
596
+ org: ${{ steps.cpln-orgs.outputs.production }}
416
597
  max_retries: ${{ env.HEALTH_CHECK_RETRIES }}
417
598
  interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }}
418
599
  accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }}
@@ -422,7 +603,7 @@ jobs:
422
603
  env:
423
604
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
424
605
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
425
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
606
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
426
607
  shell: bash
427
608
  run: |
428
609
  # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end
@@ -484,7 +665,7 @@ jobs:
484
665
  env:
485
666
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
486
667
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
487
- CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }}
668
+ CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
488
669
  shell: bash
489
670
  run: |
490
671
  set -euo pipefail
@@ -510,8 +691,10 @@ jobs:
510
691
  set -euo pipefail
511
692
  ready=false
512
693
  for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do
513
- deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')"
514
- if [[ "${deployment_ready}" == "true" ]]; then
694
+ workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)"
695
+ deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')"
696
+ latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')"
697
+ if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then
515
698
  ready=true
516
699
  break
517
700
  fi
@@ -553,7 +736,7 @@ jobs:
553
736
  HEALTHY: ${{ steps.health-check.outputs.healthy }}
554
737
  PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }}
555
738
  PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }}
556
- DEPLOYED_IMAGE: ${{ steps.staging-image.outputs.image }}
739
+ COPIED_IMAGE: ${{ steps.copy-image.outputs.image }}
557
740
  shell: bash
558
741
  run: |
559
742
  {
@@ -561,13 +744,15 @@ jobs:
561
744
  echo
562
745
  if [[ "${HEALTHY}" == "true" ]]; then
563
746
  echo "✅ Status: deployment successful"
747
+ deployed_image="${COPIED_IMAGE}"
564
748
  else
565
749
  echo "❌ Status: deployment failed"
750
+ deployed_image="${PREVIOUS_IMAGE}"
566
751
  fi
567
752
  echo
568
753
  echo "Previous image: \`${PREVIOUS_IMAGE}\`"
569
754
  echo "Previous version: ${PREVIOUS_VERSION}"
570
- echo "Deployed image: \`${DEPLOYED_IMAGE}\`"
755
+ echo "Deployed image: \`${deployed_image}\`"
571
756
  } >> "$GITHUB_STEP_SUMMARY"
572
757
 
573
758
  create-github-release:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cpflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -120,6 +120,18 @@ files:
120
120
  - docs/ai-github-flow-prompt.md
121
121
  - docs/assets/cpflow-deploying.svg
122
122
  - docs/assets/grafana-alert.png
123
+ - docs/assets/logo/favicon.ico
124
+ - docs/assets/logo/icon-1024.png
125
+ - docs/assets/logo/icon-128.png
126
+ - docs/assets/logo/icon-16.png
127
+ - docs/assets/logo/icon-192.png
128
+ - docs/assets/logo/icon-24.png
129
+ - docs/assets/logo/icon-32.png
130
+ - docs/assets/logo/icon-48.png
131
+ - docs/assets/logo/icon-512.png
132
+ - docs/assets/logo/icon-64.png
133
+ - docs/assets/logo/icon-tile.svg
134
+ - docs/assets/logo/mark-transparent.svg
123
135
  - docs/assets/memcached.png
124
136
  - docs/assets/sidekiq-pre-stop-hook.png
125
137
  - docs/ci-automation.md
@@ -272,7 +284,7 @@ licenses:
272
284
  metadata:
273
285
  rubygems_mfa_required: 'true'
274
286
  post_install_message: |
275
- cpflow 5.1.0 installed.
287
+ cpflow 5.1.1 installed.
276
288
 
277
289
  If this repository already uses generated cpflow GitHub Actions, update the
278
290
  checked-in wrappers so GitHub loads the matching control-plane-flow release tag: