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 +4 -4
- data/.github/actions/cpflow-wait-for-health/action.yml +11 -4
- data/.github/workflows/cpflow-promote-staging-to-production.yml +224 -37
- data/.github/workflows/rspec-shared.yml +8 -1
- data/CHANGELOG.md +15 -1
- data/Gemfile.lock +1 -1
- data/README.md +4 -0
- data/docs/assets/logo/favicon.ico +0 -0
- data/docs/assets/logo/icon-1024.png +0 -0
- data/docs/assets/logo/icon-128.png +0 -0
- data/docs/assets/logo/icon-16.png +0 -0
- data/docs/assets/logo/icon-192.png +0 -0
- data/docs/assets/logo/icon-24.png +0 -0
- data/docs/assets/logo/icon-32.png +0 -0
- data/docs/assets/logo/icon-48.png +0 -0
- data/docs/assets/logo/icon-512.png +0 -0
- data/docs/assets/logo/icon-64.png +0 -0
- data/docs/assets/logo/icon-tile.svg +17 -0
- data/docs/assets/logo/mark-transparent.svg +16 -0
- data/docs/ci-automation.md +43 -2
- data/docs/commands.md +5 -1
- data/lib/command/maintenance_off.rb +1 -0
- data/lib/command/maintenance_on.rb +1 -0
- data/lib/command/run.rb +25 -5
- data/lib/core/maintenance_mode.rb +93 -6
- data/lib/cpflow/version.rb +1 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +13 -1
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +224 -39
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 183da85ac156c39e59af60c42727a8144e9b23bcc44fcacb3eb6a0498a3ab831
|
|
4
|
+
data.tar.gz: c801e2e1c97114fbd405494600ad9c637b757256331dd59a4069cc56e59b3934
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
4
|
-
|
|
5
|
-
`healthy=false`) once
|
|
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
|
-
|
|
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: ${{
|
|
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: ${{
|
|
187
|
-
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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: ${{
|
|
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: ${{
|
|
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
|
-
|
|
431
|
+
CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
|
|
324
432
|
PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
|
|
325
|
-
CPLN_ORG_STAGING: ${{
|
|
326
|
-
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
|
-
|
|
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
|
|
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: ${{
|
|
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: ${{
|
|
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: ${{
|
|
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: ${{
|
|
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
|
-
|
|
493
|
-
|
|
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.
|
|
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
data/README.md
CHANGED
|
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>
|
data/docs/ci-automation.md
CHANGED
|
@@ -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
|
|