diff --git a/.github/actions/cpflow-wait-for-health/action.yml b/.github/actions/cpflow-wait-for-health/action.yml index 70347723..d33ab560 100644 --- a/.github/actions/cpflow-wait-for-health/action.yml +++ b/.github/actions/cpflow-wait-for-health/action.yml @@ -1,8 +1,9 @@ name: Wait for Control Plane workload health description: >- - Polls the workload's status endpoint with curl and exits success when the - HTTP response status is in the accepted list. Fails non-zero (and reports - `healthy=false`) once retries are exhausted. + Polls Control Plane until the latest workload version is ready, then checks + the workload endpoint with curl. Exits success when the HTTP response status + is in the accepted list. Fails non-zero (and reports `healthy=false`) once + retries are exhausted. inputs: workload_name: @@ -68,8 +69,14 @@ runs: exit 1 fi + workload_ready="$(echo "${workload_json}" | jq -r '.status.ready // false')" + latest_ready="$(echo "${workload_json}" | jq -r '.status.readyLatest // false')" + readiness_status="$(echo "${workload_json}" | jq -r '.health.readiness // "unknown"')" endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')" - if [[ -n "${endpoint}" ]]; then + + if [[ "${workload_ready}" != "true" || "${latest_ready}" != "true" ]]; then + echo "Workload status: ready=${workload_ready}, readyLatest=${latest_ready}, readiness=${readiness_status}; waiting for latest deployment." + elif [[ -n "${endpoint}" ]]; then http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || echo 000)" echo "Endpoint: ${endpoint}, HTTP status: ${http_status}" diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index cdeb510b..a6fb9fca 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -110,11 +110,58 @@ jobs: variable:STAGING_APP_NAME variable:PRODUCTION_APP_NAME + - name: Normalize Control Plane org names + id: cpln-orgs + env: + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + sanitize_control_plane_name() { + local label="$1" + local value="$2" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then + echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2 + exit 1 + fi + + printf '%s' "${value}" + } + + validate_control_plane_org() { + local label="$1" + local value="$2" + + if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + local display_value + display_value="$(printf '%q' "${value}")" + 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 + exit 1 + fi + } + + staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")" + production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")" + + validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}" + validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}" + + { + echo "staging=${staging_org}" + echo "production=${production_org}" + } >> "$GITHUB_OUTPUT" + - name: Setup production environment uses: ./.cpflow/.github/actions/cpflow-setup-environment with: token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} + org: ${{ steps.cpln-orgs.outputs.production }} working_directory: .cpflow cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} cpflow_version: ${{ vars.CPFLOW_VERSION }} @@ -183,42 +230,97 @@ jobs: CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} shell: bash run: | set -euo pipefail - 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)" - 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)" + list_gvc_env_names() { + local token="$1" + local org="$2" + local app="$3" + + CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json | + jq -r '.spec.env // [] | .[] | .name // empty' | + sort -u + } + + list_workload_env_names() { + local token="$1" + local org="$2" + local app="$3" + local workload="$4" + + CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json | + jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' | + sort -u + } + + check_required_vars() { + local staging_scope="$1" + local production_scope="$2" + local missing_message="$3" + local staging_vars="$4" + local production_vars="$5" + local missing_vars + local production_only_vars + + if [[ -z "${staging_vars}" ]]; then + echo "Staging ${staging_scope} exposes no environment variables; skipping parity check." + return + fi + + # Treat staging as the promotion source of truth: fail when a variable + # present in staging is missing in production. Production-only variables + # are allowed, but surface them so teams can spot drift. + missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - if [[ -z "${staging_vars}" ]]; then - echo "Staging GVC exposes no environment variables; skipping parity check." - exit 0 - fi + if [[ -n "${production_only_vars}" ]]; then + echo "::warning::Production ${production_scope} has environment variables that are not present in staging:" + echo "${production_only_vars}" + fi - # Treat staging as the promotion source of truth: fail when a variable - # present in staging is missing in production. Production-only variables - # are allowed, but surface them so teams can spot drift. - missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + if [[ -n "${missing_vars}" ]]; then + echo "::error::${missing_message}" + echo "${missing_vars}" + env_check_failed=1 + fi + } - if [[ -n "${production_only_vars}" ]]; then - echo "::warning::Production has environment variables that are not present in staging:" - echo "${production_only_vars}" - fi + env_check_failed=0 - if [[ -n "${missing_vars}" ]]; then - echo "::error::Production is missing environment variables that exist in staging" - echo "${missing_vars}" - exit 1 - fi + staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")" + production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")" + check_required_vars \ + "GVC '${STAGING_APP_NAME}'" \ + "GVC '${PRODUCTION_APP_NAME}'" \ + "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \ + "${staging_vars}" \ + "${production_vars}" + + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")" + production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")" + check_required_vars \ + "workload '${workload_name}'" \ + "workload '${workload_name}'" \ + "Production workload '${workload_name}' is missing environment variables that exist in staging" \ + "${staging_workload_vars}" \ + "${production_workload_vars}" + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + exit "${env_check_failed}" - name: Capture current production image id: capture-current env: PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash @@ -274,7 +376,7 @@ jobs: env: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash @@ -316,14 +418,17 @@ jobs: echo "image=${staging_image}" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + - name: Copy image from staging + id: copy-image env: - # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc//cmdline. CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} STAGING_IMAGE: ${{ steps.staging-image.outputs.image }} shell: bash run: | @@ -343,14 +448,83 @@ jobs: copy_image_attempts=$((copy_image_retries + 1)) copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL})) - if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then + if [[ "${STAGING_IMAGE}" == *@* ]]; then + staging_image="${STAGING_IMAGE}" + else + staging_image="${STAGING_IMAGE%%@*}" + fi + if [[ -z "${staging_image}" ]]; then + echo "::error::Staging image '${STAGING_IMAGE}' did not contain a usable image reference." + exit 1 + fi + + if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion." exit 1 fi + staging_tag="" + if [[ "${staging_image}" == *@* ]]; then + staging_tag="${staging_image##*@}" + elif [[ "${staging_image}" == *:* ]]; then + staging_tag="${staging_image##*:}" + fi + staging_commit="" + if [[ "${staging_tag}" == *_* ]]; then + staging_commit="${staging_tag##*_}" + fi + + # The workflow-level concurrency group serializes this sequence so two + # production promotions cannot derive and publish the same next tag. + latest_number="$( + cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json | + jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \ + '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?[0-9]+)") catch empty) | .number | tonumber] | max // 0' + )" + if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then + echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'." + exit 1 + fi + + production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))" + if [[ -n "${staging_commit}" ]]; then + production_image="${production_image}_${staging_commit}" + fi + + staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io" + production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io" + source_image_ref="${staging_registry}/${STAGING_IMAGE}" + production_image_ref="${production_registry}/${production_image}" + + docker_config_dir="$(mktemp -d)" + cleanup_copy_credentials() { + rm -rf "${docker_config_dir}" + } + trap cleanup_copy_credentials EXIT + + export DOCKER_CONFIG="${docker_config_dir}" + + if ! printf '%s' "${CPLN_TOKEN_STAGING}" | + docker login "${staging_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to staging registry '${staging_registry}'." + exit 1 + fi + + if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" | + docker login "${production_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to production registry '${production_registry}'." + exit 1 + fi + + if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then + echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it." + exit 1 + fi + copy_status=1 for attempt in $(seq 1 "${copy_image_attempts}"); do - if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then + if docker buildx imagetools inspect "${source_image_ref}" >/dev/null && + docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then copy_status=0 break else @@ -370,10 +544,12 @@ jobs: exit "${copy_status}" fi + echo "image=${production_image}" >> "$GITHUB_OUTPUT" + - name: Deploy image to production env: PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} shell: bash run: | @@ -383,6 +559,9 @@ jobs: if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then deploy_args+=("${RELEASE_PHASE_FLAG}") fi + # `cpflow deploy-image` deploys the latest image for the app. The + # workflow-level concurrency group keeps production promotion copy and + # deploy steps coupled across workflow runs. deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) cpflow deploy-image "${deploy_args[@]}" @@ -393,7 +572,7 @@ jobs: with: workload_name: ${{ steps.workloads.outputs.primary }} app_name: ${{ vars.PRODUCTION_APP_NAME }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} + org: ${{ steps.cpln-orgs.outputs.production }} max_retries: ${{ env.HEALTH_CHECK_RETRIES }} interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }} accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} @@ -403,7 +582,7 @@ jobs: env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end @@ -464,7 +643,7 @@ jobs: env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | set -euo pipefail @@ -489,8 +668,10 @@ jobs: set -euo pipefail ready=false for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do - deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')" - if [[ "${deployment_ready}" == "true" ]]; then + workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" + deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')" + latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')" + if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then ready=true break fi @@ -531,6 +712,7 @@ jobs: HEALTHY: ${{ steps.health-check.outputs.healthy }} PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }} + COPIED_IMAGE: ${{ steps.copy-image.outputs.image }} shell: bash run: | { @@ -538,12 +720,15 @@ jobs: echo if [[ "${HEALTHY}" == "true" ]]; then echo "✅ Status: deployment successful" + deployed_image="${COPIED_IMAGE}" else echo "❌ Status: deployment failed" + deployed_image="${PREVIOUS_IMAGE}" fi echo echo "Previous image: \`${PREVIOUS_IMAGE}\`" echo "Previous version: ${PREVIOUS_VERSION}" + echo "Deployed image: \`${deployed_image}\`" } >> "$GITHUB_STEP_SUMMARY" create-github-release: diff --git a/docs/ci-automation.md b/docs/ci-automation.md index 6b9ecdb4..195af935 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -196,6 +196,30 @@ For production promotion, also configure: - `CPLN_ORG_PRODUCTION` as a production environment variable, for example `company-production` - `PRODUCTION_APP_NAME` as a production environment variable, for example `my-app-production` +Enter GitHub variables such as `CPLN_ORG_STAGING`, +`CPLN_ORG_PRODUCTION`, `STAGING_APP_NAME`, and `PRODUCTION_APP_NAME` +as plain single-line values. The generated production promotion workflow trims +accidental leading/trailing whitespace and line endings from Control Plane org +names before building registry URLs, but embedded line breaks are rejected +because they could change the target org name after normalization. + +Production promotion copies the exact image currently deployed on the selected +staging workload. If that staging image is digest-pinned, the digest is used for +the source copy while the production tag is derived from the tag portion. Tags +with a `_` suffix keep that suffix in production; plain numeric tags are +also valid and promote to the next plain production tag. The copy step uses +`docker buildx imagetools create --prefer-index=false --tag` with isolated +Docker credentials, which preserves multi-architecture manifests, preserves +single-platform manifest format when supported, and avoids pulling image layers +onto the GitHub Actions runner. + +Before copying the image, production promotion compares the environment variable +names exposed by staging and production at both the GVC level and each configured +app workload's container level. Variables present in staging are treated as +required for production, while production-only variables emit warnings. A missing +production workload variable such as a renderer password or runtime secret fails +the promotion before the image copy starts. + Do not put `CPLN_TOKEN_PRODUCTION` in repository or organization secrets for sensitive production systems. Production promotion intentionally runs as a 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 Use production-only runtime secrets and values for the production app. The protected GitHub Environment controls who can run the promotion workflow, but the production app resources still need to exist before the first promotion. +After bootstrap, populate the production app secret dictionary with the values +referenced by `.controlplane/templates`, then run `cpflow apply-template` against +production when templates change so the workload env references remain persisted. +Production promotion checks for missing GVC and workload container env names +before copying the staging image, so a staging-only runtime variable will stop +the run early instead of deploying an image that cannot boot. Review apps are different: the generated `+review-app-deploy` workflow creates temporary PR apps as needed, including the identity and secret policy binding. @@ -316,6 +346,17 @@ The standard path is: 7. Store `CPLN_ORG_PRODUCTION` and `PRODUCTION_APP_NAME` as `production` environment variables, or as repository variables only when those names are intentionally non-sensitive. +8. Keep GitHub variable values single-line; a pasted trailing newline is trimmed + for Control Plane org names, but embedded line breaks are rejected before + deployment, copy, health-check, or rollback steps run. +9. Bootstrap or re-apply the persistent production app templates before first + promotion so app workload container env references and Control Plane secret + dictionaries exist in production. +10. Expect promotion to preserve the selected staging image reference. Digest + references are copied by digest, commit-suffixed tags keep the commit suffix, + and plain numeric tags remain valid. +11. Expect production health and rollback readiness polling to require Control + Plane `status.ready` and `status.readyLatest` before checking the endpoint. GitHub only exposes environment secrets to jobs that reference the environment 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 ` - Manually promotes the staging artifact to production with a confirmation input. - Runs the production job in the `production` GitHub Environment, so configured reviewers approve the job before production environment secrets are available. -- Verifies that production has the env var names staging expects. -- Runs a health check against `PRIMARY_WORKLOAD`. +- Verifies that production has the GVC and app workload container env var names staging expects. +- Runs a health check against `PRIMARY_WORKLOAD` only after Control Plane reports the latest workload version ready. - Attempts a rollback of every configured application workload if the new production image does not come up healthy. - Creates a GitHub release after a successful promotion. diff --git a/docs/commands.md b/docs/commands.md index 2e70ceb7..bfbae23d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -550,7 +550,7 @@ cpflow terraform import Regenerates the generated cpflow GitHub Actions wrappers and helper files from the currently installed cpflow gem. Use this after updating the cpflow gem so checked-in workflow wrappers move to the matching upstream -release tag, for example `v5.0.4`. +release tag, for example `v5.1.0`. If the existing generated staging workflow uses a custom single staging branch, the command preserves it. Pass `--staging-branch BRANCH` to set or diff --git a/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml b/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml index 74555791..7ca25bdf 100644 --- a/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml @@ -109,6 +109,53 @@ jobs: variable:STAGING_APP_NAME variable:PRODUCTION_APP_NAME + - name: Normalize Control Plane org names + id: cpln-orgs + env: + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + sanitize_control_plane_name() { + local label="$1" + local value="$2" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then + echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2 + exit 1 + fi + + printf '%s' "${value}" + } + + validate_control_plane_org() { + local label="$1" + local value="$2" + + if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + local display_value + display_value="$(printf '%q' "${value}")" + 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 + exit 1 + fi + } + + staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")" + production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")" + + validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}" + validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}" + + { + echo "staging=${staging_org}" + echo "production=${production_org}" + } >> "$GITHUB_OUTPUT" + - name: Capture release context id: release-context env: @@ -127,7 +174,7 @@ jobs: uses: ./.cpflow/.github/actions/cpflow-setup-environment with: token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} + org: ${{ steps.cpln-orgs.outputs.production }} working_directory: .cpflow cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} cpflow_version: ${{ vars.CPFLOW_VERSION }} @@ -200,42 +247,97 @@ jobs: CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} shell: bash run: | set -euo pipefail - 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)" - 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)" + list_gvc_env_names() { + local token="$1" + local org="$2" + local app="$3" + + CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json | + jq -r '.spec.env // [] | .[] | .name // empty' | + sort -u + } + + list_workload_env_names() { + local token="$1" + local org="$2" + local app="$3" + local workload="$4" + + CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json | + jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' | + sort -u + } + + check_required_vars() { + local staging_scope="$1" + local production_scope="$2" + local missing_message="$3" + local staging_vars="$4" + local production_vars="$5" + local missing_vars + local production_only_vars + + if [[ -z "${staging_vars}" ]]; then + echo "Staging ${staging_scope} exposes no environment variables; skipping parity check." + return + fi + + # Treat staging as the promotion source of truth: fail when a variable + # present in staging is missing in production. Production-only variables + # are allowed, but surface them so teams can spot drift. + missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - if [[ -z "${staging_vars}" ]]; then - echo "Staging GVC exposes no environment variables; skipping parity check." - exit 0 - fi + if [[ -n "${production_only_vars}" ]]; then + echo "::warning::Production ${production_scope} has environment variables that are not present in staging:" + echo "${production_only_vars}" + fi - # Treat staging as the promotion source of truth: fail when a variable - # present in staging is missing in production. Production-only variables - # are allowed, but surface them so teams can spot drift. - missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + if [[ -n "${missing_vars}" ]]; then + echo "::error::${missing_message}" + echo "${missing_vars}" + env_check_failed=1 + fi + } - if [[ -n "${production_only_vars}" ]]; then - echo "::warning::Production has environment variables that are not present in staging:" - echo "${production_only_vars}" - fi + env_check_failed=0 - if [[ -n "${missing_vars}" ]]; then - echo "::error::Production is missing environment variables that exist in staging" - echo "${missing_vars}" - exit 1 - fi + staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")" + production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")" + check_required_vars \ + "GVC '${STAGING_APP_NAME}'" \ + "GVC '${PRODUCTION_APP_NAME}'" \ + "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \ + "${staging_vars}" \ + "${production_vars}" + + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")" + production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")" + check_required_vars \ + "workload '${workload_name}'" \ + "workload '${workload_name}'" \ + "Production workload '${workload_name}' is missing environment variables that exist in staging" \ + "${staging_workload_vars}" \ + "${production_workload_vars}" + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + exit "${env_check_failed}" - name: Capture current production image id: capture-current env: PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash @@ -293,7 +395,7 @@ jobs: env: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash @@ -335,14 +437,17 @@ jobs: echo "image=${staging_image}" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + - name: Copy image from staging + id: copy-image env: - # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc//cmdline. CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} STAGING_IMAGE: ${{ steps.staging-image.outputs.image }} shell: bash run: | @@ -362,14 +467,83 @@ jobs: copy_image_attempts=$((copy_image_retries + 1)) copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL})) - if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then + if [[ "${STAGING_IMAGE}" == *@* ]]; then + staging_image="${STAGING_IMAGE}" + else + staging_image="${STAGING_IMAGE%%@*}" + fi + if [[ -z "${staging_image}" ]]; then + echo "::error::Staging image '${STAGING_IMAGE}' did not contain a usable image reference." + exit 1 + fi + + if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion." exit 1 fi + staging_tag="" + if [[ "${staging_image}" == *@* ]]; then + staging_tag="${staging_image##*@}" + elif [[ "${staging_image}" == *:* ]]; then + staging_tag="${staging_image##*:}" + fi + staging_commit="" + if [[ "${staging_tag}" == *_* ]]; then + staging_commit="${staging_tag##*_}" + fi + + # The workflow-level concurrency group serializes this sequence so two + # production promotions cannot derive and publish the same next tag. + latest_number="$( + cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json | + jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \ + '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?[0-9]+)") catch empty) | .number | tonumber] | max // 0' + )" + if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then + echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'." + exit 1 + fi + + production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))" + if [[ -n "${staging_commit}" ]]; then + production_image="${production_image}_${staging_commit}" + fi + + staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io" + production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io" + source_image_ref="${staging_registry}/${STAGING_IMAGE}" + production_image_ref="${production_registry}/${production_image}" + + docker_config_dir="$(mktemp -d)" + cleanup_copy_credentials() { + rm -rf "${docker_config_dir}" + } + trap cleanup_copy_credentials EXIT + + export DOCKER_CONFIG="${docker_config_dir}" + + if ! printf '%s' "${CPLN_TOKEN_STAGING}" | + docker login "${staging_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to staging registry '${staging_registry}'." + exit 1 + fi + + if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" | + docker login "${production_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to production registry '${production_registry}'." + exit 1 + fi + + if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then + echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it." + exit 1 + fi + copy_status=1 for attempt in $(seq 1 "${copy_image_attempts}"); do - if cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"; then + if docker buildx imagetools inspect "${source_image_ref}" >/dev/null && + docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then copy_status=0 break else @@ -389,10 +563,12 @@ jobs: exit "${copy_status}" fi + echo "image=${production_image}" >> "$GITHUB_OUTPUT" + - name: Deploy image to production env: PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} shell: bash run: | @@ -402,6 +578,9 @@ jobs: if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then deploy_args+=("${RELEASE_PHASE_FLAG}") fi + # `cpflow deploy-image` deploys the latest image for the app. The + # workflow-level concurrency group keeps production promotion copy and + # deploy steps coupled across workflow runs. deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) cpflow deploy-image "${deploy_args[@]}" @@ -412,7 +591,7 @@ jobs: with: workload_name: ${{ steps.workloads.outputs.primary }} app_name: ${{ vars.PRODUCTION_APP_NAME }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} + org: ${{ steps.cpln-orgs.outputs.production }} max_retries: ${{ env.HEALTH_CHECK_RETRIES }} interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }} accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} @@ -422,7 +601,7 @@ jobs: env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end @@ -484,7 +663,7 @@ jobs: env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | set -euo pipefail @@ -510,8 +689,10 @@ jobs: set -euo pipefail ready=false for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do - deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')" - if [[ "${deployment_ready}" == "true" ]]; then + workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" + deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')" + latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')" + if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then ready=true break fi @@ -553,7 +734,7 @@ jobs: HEALTHY: ${{ steps.health-check.outputs.healthy }} PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }} - DEPLOYED_IMAGE: ${{ steps.staging-image.outputs.image }} + COPIED_IMAGE: ${{ steps.copy-image.outputs.image }} shell: bash run: | { @@ -561,13 +742,15 @@ jobs: echo if [[ "${HEALTHY}" == "true" ]]; then echo "✅ Status: deployment successful" + deployed_image="${COPIED_IMAGE}" else echo "❌ Status: deployment failed" + deployed_image="${PREVIOUS_IMAGE}" fi echo echo "Previous image: \`${PREVIOUS_IMAGE}\`" echo "Previous version: ${PREVIOUS_VERSION}" - echo "Deployed image: \`${DEPLOYED_IMAGE}\`" + echo "Deployed image: \`${deployed_image}\`" } >> "$GITHUB_STEP_SUMMARY" create-github-release: diff --git a/spec/command/generate_github_actions_spec.rb b/spec/command/generate_github_actions_spec.rb index 9a9f38fa..4086ccf9 100644 --- a/spec/command/generate_github_actions_spec.rb +++ b/spec/command/generate_github_actions_spec.rb @@ -780,12 +780,28 @@ def shared_workflow_path(name) expect(contents).to include("environment: ${{ inputs.production_environment }}") expect(contents).to include("Validate production token") expect(contents).to include("CPLN_TOKEN_PRODUCTION is not set") + expect(contents).to include("Normalize Control Plane org names") + expect(contents).to include("contains embedded line endings") + expect(contents).to include("steps.cpln-orgs.outputs.production") + expect(wrapper).to include("Normalize Control Plane org names") + expect(wrapper).to include("contains embedded line endings") + expect(wrapper).to include("steps.cpln-orgs.outputs.production") expect(contents).to include("wrapper intentionally passes only CPLN_TOKEN_STAGING") expect(contents).to include("create-github-release:") expect(contents).to include("contents: write") expect(contents).to include("working_directory: .cpflow") expect(contents).to include("GH_REPO: ${{ github.repository }}") expect(contents).to include("Production-only variables") + expect(contents).to include("WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}") + expect(contents).to include("list_workload_env_names()") + expect(contents).to include( + "Production workload '${workload_name}' is missing environment variables that exist in staging" + ) + expect(wrapper).to include("WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }}") + expect(wrapper).to include("list_workload_env_names()") + expect(wrapper).to include( + "Production workload '${workload_name}' is missing environment variables that exist in staging" + ) expect(contents).to include("PRIMARY_WORKLOAD is not configured") expect(contents).to include(%(puts "primary=\#{primary}")) expect(contents).to include("PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}") @@ -794,6 +810,8 @@ def shared_workflow_path(name) expect(contents).to include("Could not parse rollback state") expect(contents).to include("Could not parse captured containers") expect(contents).to include("Could not build rollback image list") + expect(contents).to include(".status.readyLatest // false") + expect(wrapper).to include(".status.readyLatest // false") expect(contents).to include("Container set changed") expect(contents).to include('jq -r \'.[] | "\\(.name)\\t\\(.image)"\'') expect(contents).to include("spec.containers.${container_name}.image") @@ -802,6 +820,7 @@ def shared_workflow_path(name) expect(contents).to include( 'release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}"' ) + expect(contents).to include("workflow-level concurrency group keeps production promotion copy") expect(contents).to include( "failure() && steps.capture-current.outputs.rollback_state != '' && " \ "steps.capture-current.outputs.rollback_state != '{}'" @@ -821,18 +840,81 @@ def shared_workflow_path(name) expect(contents).not_to include('selected_workload="${PRIMARY_WORKLOAD:-}"') expect(contents).to include("staging_image=\"${staging_image_ref##*/image/}\"") expect(contents).to include("STAGING_IMAGE: ${{ steps.staging-image.outputs.image }}") - expect(contents).to include('CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${STAGING_IMAGE}"') + expect(contents).to include( + 'staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" ' \ + '"${CPLN_ORG_STAGING}")"' + ) + expect(contents).to include( + 'production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" ' \ + '"${CPLN_ORG_PRODUCTION}")"' + ) + expect(contents).to include("use lowercase alphanumeric characters and hyphens only") + expect(contents).to include("CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }}") + expect(contents).to include("CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}") expect(contents).to include("COPY_IMAGE_RETRIES must be a non-negative integer") expect(contents).to include("COPY_IMAGE_RETRY_INTERVAL must be a non-negative integer") expect(contents).to include("copy_image_attempts=$((copy_image_retries + 1))") expect(contents).to include("Staging image '${STAGING_IMAGE}' was not found") + expect(contents).to include( + "uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5" + ) + expect(contents).to include("id: copy-image") + expect(contents).to include('if [[ "${STAGING_IMAGE}" == *@* ]]; then') + expect(contents).to include('staging_image="${STAGING_IMAGE}"') + expect(contents).to include("else") + expect(contents).to include('staging_image="${STAGING_IMAGE%%@*}"') + expect(contents).to include('CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}"') + expect(contents).to include('if [[ "${staging_image}" == *@* ]]; then') + expect(contents).to include('staging_tag="${staging_image##*@}"') + expect(contents).to include('elif [[ "${staging_image}" == *:* ]]; then') + expect(contents).to include('staging_tag="${staging_image##*:}"') + expect(contents).to include('staging_commit=""') + expect(contents).to include('if [[ "${staging_tag}" == *_* ]]; then') + expect(contents).to include('staging_commit="${staging_tag##*_}"') + expect(contents).to include("workflow-level concurrency group serializes this sequence") + expect(contents).to include('--prop "name~${PRODUCTION_APP_NAME}:" --max 0') + expect(contents).to include("Could not determine the next production image number") + expect(contents).to include('production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))"') + expect(contents).to include('production_image="${production_image}_${staging_commit}"') + expect(contents).to include('docker_config_dir="$(mktemp -d)"') + expect(contents).to include("cleanup_copy_credentials") + expect(contents).to include('export DOCKER_CONFIG="${docker_config_dir}"') + expect(contents).to include('staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io"') + expect(contents).to include('production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io"') + expect(contents).to include('source_image_ref="${staging_registry}/${STAGING_IMAGE}"') + expect(contents).to include('production_image_ref="${production_registry}/${production_image}"') + expect(contents).to include("CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }}") + expect(contents).to include('docker login "${staging_registry}"') + expect(contents).to include('docker login "${production_registry}"') + expect(contents).to include("Failed to authenticate to staging registry") + expect(contents).to include("Failed to authenticate to production registry") + expect(contents).to include('docker buildx imagetools inspect "${production_image_ref}"') expect(contents).to include('for attempt in $(seq 1 "${copy_image_attempts}"); do') + expect(contents).to include('docker buildx imagetools inspect "${source_image_ref}"') + expect(contents).to include( + "docker buildx imagetools create --prefer-index=false --tag " \ + "\"${production_image_ref}\" \"${source_image_ref}\"" + ) + expect(contents).to include('echo "image=${production_image}" >> "$GITHUB_OUTPUT"') + expect(contents).to include("COPIED_IMAGE: ${{ steps.copy-image.outputs.image }}") + expect(contents).to include('deployed_image="${COPIED_IMAGE}"') + expect(contents).to include('deployed_image="${PREVIOUS_IMAGE}"') expect(contents).to include("Image copy attempt ${attempt}/${copy_image_attempts} failed") expect(contents).to include("no attempts remain") expect(contents).to include("workload_name: ${{ steps.workloads.outputs.primary }}") expect(contents).not_to include("workload_name: ${{ env.PRIMARY_WORKLOAD || 'rails' }}") - expect(contents).to include('cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" ' \ - '--org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}"') + expect(contents).not_to include("CPLN_UPSTREAM_TOKEN:") + expect(contents).not_to include("Pass the upstream token") + expect(contents).not_to include('cpln profile create "${upstream_profile}"') + expect(contents).not_to include("--profile \"${upstream_profile}\"") + expect(contents).not_to include("cpln image docker-login") + expect(contents).not_to include('cpln image copy "${STAGING_IMAGE}"') + expect(contents).not_to include('docker manifest inspect "${source_image_ref}"') + expect(contents).not_to include('docker pull "${source_image_ref}"') + expect(contents).not_to include('docker tag "${source_image_ref}" "${production_image_ref}"') + expect(contents).not_to include('docker push "${production_image_ref}"') + expect(contents).not_to include("cpflow copy-image-from-upstream") + expect(contents).not_to include("--cleanup") end it "detects release phase support from controlplane.yml instead of cpflow config text" do @@ -864,6 +946,8 @@ def shared_workflow_path(name) expect(contents).to include("Workload '${CPFLOW_WORKLOAD_NAME}' not found") expect(contents).to include("Set PRIMARY_WORKLOAD to the correct workload name.") expect(contents).to include("has no endpoint yet; waiting for one to be assigned") + expect(contents).to include(".status.readyLatest // false") + expect(contents).to include("readyLatest=${latest_ready}") end it "writes the delete-app script with the not-found guard message" do