diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml
deleted file mode 100644
index 606d0458f..000000000
--- a/.github/actions/cpflow-build-docker-image/action.yml
+++ /dev/null
@@ -1,262 +0,0 @@
-name: Build Docker Image
-description: Builds and pushes the app image for a Control Plane workload
-
-inputs:
- app_name:
- description: Name of the application
- required: true
- org:
- description: Control Plane organization name
- required: true
- commit:
- description: Commit SHA to tag the image with
- required: true
- pr_number:
- description: Pull request number for status messaging
- required: false
- docker_build_extra_args:
- description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar.
- required: false
- docker_build_secrets:
- description: Optional newline-delimited id=value secrets, passed through BuildKit secret mounts.
- required: false
- docker_build_ssh_key:
- description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
- required: false
- docker_build_ssh_known_hosts:
- description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys.
- required: false
- working_directory:
- description: Directory containing the app .controlplane config and Docker build context
- required: false
- default: "."
-
-runs:
- using: composite
- steps:
- - name: Prepare Docker build secrets
- id: build-secrets
- if: ${{ inputs.docker_build_secrets != '' }}
- shell: bash
- env:
- DOCKER_BUILD_SECRETS: ${{ inputs.docker_build_secrets }}
- run: |
- set -euo pipefail
- umask 077
- secret_directory="$(mktemp -d "${RUNNER_TEMP}/cpflow-build-secrets.XXXXXX")"
- secrets_ready=false
- trap 'if [[ "${secrets_ready}" != "true" ]]; then rm -rf "${secret_directory}"; fi' EXIT
- while IFS= read -r entry || [[ -n "${entry}" ]]; do
- entry="${entry%$'\r'}"
- [[ -n "${entry}" ]] || continue
- if [[ "${entry}" != *=* ]]; then
- echo "Docker build secrets must use id=value entries." >&2
- exit 1
- fi
- secret_id="${entry%%=*}"
- secret_value="${entry#*=}"
- if [[ ! "${secret_id}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || [[ -z "${secret_value}" ]]; then
- echo "Docker build secrets require a valid id and a nonempty value." >&2
- exit 1
- fi
- if [[ -e "${secret_directory}/${secret_id}" ]]; then
- echo "Duplicate Docker build secret id." >&2
- exit 1
- fi
- # GitHub masks the stored id=value entry; register the extracted value too.
- masked_value="${secret_value//%/%25}"
- masked_value="${masked_value//$'\r'/%0D}"
- masked_value="${masked_value//$'\n'/%0A}"
- printf '::add-mask::%s\n' "${masked_value}"
- printf '%s' "${secret_value}" > "${secret_directory}/${secret_id}"
- done <<< "${DOCKER_BUILD_SECRETS}"
- echo "directory=${secret_directory}" >> "${GITHUB_OUTPUT}"
- secrets_ready=true
-
- # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present
- # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any
- # command runs, so keeping the key out of env there avoids even admin-triggered exposure.
- - name: Prepare SSH agent for Docker build
- id: prepare-build-ssh
- if: ${{ inputs.docker_build_ssh_key != '' }}
- shell: bash
- env:
- # Pass the key via env so ssh-add can read it from stdin without persisting it to
- # disk. Scope is this step only โ the build step below receives the agent socket,
- # not DOCKER_BUILD_SSH_KEY.
- DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }}
- DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }}
- run: |
- set -euo pipefail
-
- umask 077
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
-
- known_hosts_backup="${HOME}/.ssh/cpflow_known_hosts_backup"
- if [[ -e "${known_hosts_backup}" ]]; then
- echo "Refusing to replace SSH known_hosts: a prior cpflow backup exists at ${known_hosts_backup}." >&2
- echo "If no build is currently running on this runner, restore or remove that backup, then retry." >&2
- exit 1
- fi
-
- known_hosts_backup_ready=false
- known_hosts_replaced=false
- ssh_agent_started=false
- restore_known_hosts_on_prep_failure() {
- status=$?
- if [[ "${status}" -ne 0 ]]; then
- if [[ "${ssh_agent_started}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- fi
- if [[ "${known_hosts_backup_ready}" == "true" ]]; then
- mv -f -- "${known_hosts_backup}" "${HOME}/.ssh/known_hosts"
- else
- rm -f "${known_hosts_backup}"
- if [[ "${known_hosts_replaced}" == "true" ]]; then
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
- fi
- exit "${status}"
- }
- trap restore_known_hosts_on_prep_failure EXIT
-
- if [[ -L "${HOME}/.ssh/known_hosts" ]]; then
- echo "Refusing to replace symlinked SSH known_hosts." >&2
- exit 1
- fi
- if [[ -e "${HOME}/.ssh/known_hosts" ]]; then
- if [[ ! -f "${HOME}/.ssh/known_hosts" ]]; then
- echo "Refusing to replace non-file SSH known_hosts." >&2
- exit 1
- fi
- cp -p -- "${HOME}/.ssh/known_hosts" "${known_hosts_backup}"
- known_hosts_backup_ready=true
- fi
-
- known_hosts_replaced=true
- if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then
- printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
- else
- printf '%s\n' \
- 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \
- 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \
- 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \
- > ~/.ssh/known_hosts
- fi
- chmod 600 ~/.ssh/known_hosts
-
- eval "$(ssh-agent -s)"
- ssh_agent_started=true
- printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" | ssh-add -
- {
- echo "prepared=true"
- echo "known_hosts_had_original=${known_hosts_backup_ready}"
- echo "ssh_auth_sock=${SSH_AUTH_SOCK}"
- echo "ssh_agent_pid=${SSH_AGENT_PID}"
- } >> "${GITHUB_OUTPUT}"
-
- - name: Build Docker image
- shell: bash
- env:
- APP_NAME: ${{ inputs.app_name }}
- COMMIT_SHA: ${{ inputs.commit }}
- CONTROL_PLANE_ORG: ${{ inputs.org }}
- DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
- DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
- PR_NUMBER: ${{ inputs.pr_number }}
- CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
- CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
- SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
- SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
- WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
-
- PR_INFO=""
- docker_build_args=()
- build_ssh_prepped="${CPFLOW_BUILD_SSH_PREPPED:-false}"
-
- cleanup_build_ssh() {
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
- fi
- if [[ "${build_ssh_prepped}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- # Restore a pre-existing known_hosts file on reused runners. When there was
- # no original file, remove only the file created by this action.
- if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
- mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
- else
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
- }
- trap cleanup_build_ssh EXIT
- cd "${WORKING_DIRECTORY}"
-
- if [[ -n "${PR_NUMBER}" ]]; then
- PR_INFO=" for PR #${PR_NUMBER}"
- fi
-
- if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then
- while IFS= read -r arg; do
- arg="${arg%$'\r'}"
- [[ -n "${arg}" ]] || continue
-
- if [[ "${arg}" =~ [[:space:]] ]]; then
- echo "docker_build_extra_args entries must be single docker-build tokens. " \
- "Use key=value forms like --build-arg=FOO=bar." >&2
- exit 1
- fi
-
- docker_build_args+=("${arg}")
- done <<< "${DOCKER_BUILD_EXTRA_ARGS}"
- fi
-
- if [[ "${build_ssh_prepped}" == "true" ]]; then
- docker_build_args+=("--ssh=default")
- fi
-
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- for secret_path in "${DOCKER_BUILD_SECRET_DIRECTORY}"/*; do
- [[ -f "${secret_path}" ]] || continue
- docker_build_args+=("--secret=id=${secret_path##*/},src=${secret_path}")
- done
- fi
-
- echo "๐๏ธ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..."
- cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}"
- echo "โ
Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})"
-
- - name: Clean up SSH build state
- if: ${{ always() && inputs.docker_build_ssh_key != '' }}
- shell: bash
- env:
- CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
- CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
- SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
- SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
- run: |
- set +e
-
- if [[ "${CPFLOW_BUILD_SSH_PREPPED:-false}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
- mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
- elif [[ "${CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL:-}" == "false" ]]; then
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
-
- - name: Clean up Docker build secrets
- if: ${{ always() && inputs.docker_build_secrets != '' }}
- shell: bash
- env:
- DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
- run: |
- set -euo pipefail
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
- fi
diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml
deleted file mode 100644
index b65bb9f91..000000000
--- a/.github/actions/cpflow-delete-control-plane-app/action.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: Delete Control Plane App
-description: Deletes a Control Plane app and all associated resources
-
-inputs:
- app_name:
- description: Name of the application to delete
- required: true
- cpln_org:
- description: Control Plane organization name
- required: true
- review_app_prefix:
- description: Prefix used for review app names
- required: true
- working_directory:
- description: Directory containing the downstream project's .controlplane/controlplane.yml
- required: false
- default: "."
-
-runs:
- using: composite
- steps:
- - name: Delete application
- shell: bash
- working-directory: ${{ inputs.working_directory }}
- run: "${ACTION_PATH}/delete-app.sh"
- env:
- ACTION_PATH: ${{ github.action_path }}
- APP_NAME: ${{ inputs.app_name }}
- CPLN_ORG: ${{ inputs.cpln_org }}
- REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }}
diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh
deleted file mode 100755
index 9220b6eac..000000000
--- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/bin/bash
-
-set -euo pipefail
-
-: "${APP_NAME:?APP_NAME environment variable is required}"
-: "${CPLN_ORG:?CPLN_ORG environment variable is required}"
-: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}"
-
-expected_prefix="${REVIEW_APP_PREFIX}-"
-if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then
- echo "โ ERROR: refusing to delete an app outside the review app prefix" >&2
- echo "App name: $APP_NAME" >&2
- echo "Expected prefix: ${expected_prefix}" >&2
- exit 1
-fi
-
-echo "๐ Checking if application exists: $APP_NAME"
-exists_output=""
-set +e
-exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"
-exists_status=$?
-set -e
-
-case "$exists_status" in
- 0)
- ;;
- 3)
- if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output"
- fi
- echo "โ ๏ธ Application does not exist: $APP_NAME"
- echo "๐งน Checking for disposable review app resources: $APP_NAME"
- cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
- echo "โ
Finished disposable review app cleanup: $APP_NAME"
- exit 0
- ;;
- *)
- echo "โ ERROR: failed to determine whether application exists: $APP_NAME" >&2
- if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output" >&2
- fi
- exit "$exists_status"
- ;;
-esac
-
-if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output"
-fi
-
-echo "๐๏ธ Deleting application: $APP_NAME"
-cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
-
-echo "โ
Successfully deleted application: $APP_NAME"
diff --git a/.github/actions/cpflow-detect-release-phase/action.yml b/.github/actions/cpflow-detect-release-phase/action.yml
deleted file mode 100644
index d235b15f6..000000000
--- a/.github/actions/cpflow-detect-release-phase/action.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Detect release phase support
-description: >-
- Inspects .controlplane/controlplane.yml for an app and emits `flag=--run-release-phase`
- when a `release_script:` is configured. Outputs an empty `flag` otherwise.
-
-inputs:
- app_name:
- description: cpflow app name to inspect
- required: true
- working_directory:
- description: Directory containing .controlplane/controlplane.yml
- required: false
- default: "."
-
-outputs:
- flag:
- description: Either `--run-release-phase` or empty
- value: ${{ steps.detect.outputs.flag }}
-
-runs:
- using: composite
- steps:
- - name: Detect release phase support
- id: detect
- shell: bash
- env:
- APP_NAME: ${{ inputs.app_name }}
- WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
- cd "${WORKING_DIRECTORY}"
-
- release_script="$(ruby - "${APP_NAME}" <<'RUBY'
- require "yaml"
-
- app_name = ARGV.fetch(0)
-
- unless File.file?(".controlplane/controlplane.yml")
- warn "Error: `.controlplane/controlplane.yml` is missing. " \
- "cpflow-detect-release-phase must be invoked from a cpflow-configured project."
- exit 1
- end
-
- data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
- apps = data["apps"] || {}
- app_config = apps[app_name]
-
- unless app_config
- app_config = apps.find do |name, config|
- config.is_a?(Hash) &&
- config["match_if_app_name_starts_with"] &&
- app_name.start_with?(name)
- end&.last
- end
-
- unless app_config.is_a?(Hash)
- warn "Error: app '#{app_name}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
- exit 1
- end
-
- puts app_config["release_script"].to_s
- RUBY
- )"
-
- if [[ -n "${release_script}" ]]; then
- echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT"
- else
- echo "flag=" >> "$GITHUB_OUTPUT"
- fi
diff --git a/.github/actions/cpflow-preview-promotion/action.yml b/.github/actions/cpflow-preview-promotion/action.yml
deleted file mode 100644
index 235ab06d1..000000000
--- a/.github/actions/cpflow-preview-promotion/action.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-name: Preview Production Promotion
-description: Compare the deployed staging image to the last recorded production release before approval
-inputs:
- staging_app_name:
- required: true
- description: Staging app to read
- staging_org:
- required: true
- description: Normalized staging org
- primary_workload:
- required: false
- default: ""
- description: Optional primary workload override
-outputs:
- primary_workload:
- description: Workload whose staging image was previewed
- value: ${{ steps.workloads.outputs.primary }}
- staging_image:
- description: Captured staging image for approval
- value: ${{ steps.preview.outputs.staging_image }}
- production_commit:
- description: Last recorded production release commit, if available
- value: ${{ steps.preview.outputs.production_commit }}
-runs:
- using: composite
- steps:
- - name: Resolve primary workload
- id: workloads
- env:
- STAGING_APP_NAME: ${{ inputs.staging_app_name }}
- PRIMARY_WORKLOAD: ${{ inputs.primary_workload }}
- shell: bash
- run: |
- set -euo pipefail
-
- ruby - "${STAGING_APP_NAME}" "${PRIMARY_WORKLOAD}" >> "$GITHUB_OUTPUT" <<'RUBY'
- require "yaml"
-
- app = ARGV.fetch(0)
- requested_primary = ARGV.fetch(1, "").to_s.strip
- data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
- apps = data["apps"] || {}
- app_config = apps[app]
-
- unless app_config
- warn "Error: app '#{app}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
- warn " Fix the STAGING_APP_NAME repository variable or add the app to controlplane.yml."
- exit 1
- end
-
- workloads = Array(app_config["app_workloads"]).map(&:to_s).reject(&:empty?)
- workloads = ["rails"] if workloads.empty?
-
- primary =
- if requested_primary.empty?
- if workloads.length == 1
- workloads.first
- elsif workloads.include?("rails")
- "rails"
- else
- warn "::error::PRIMARY_WORKLOAD is not configured and app '#{app}' has multiple workloads: #{workloads.join(', ')}."
- warn " Set the PRIMARY_WORKLOAD repository variable to one of these workloads."
- exit 1
- end
- elsif workloads.include?(requested_primary)
- requested_primary
- else
- warn "::error::PRIMARY_WORKLOAD '#{requested_primary}' is not one of: #{workloads.join(', ')}."
- exit 1
- end
-
- puts "primary=#{primary}"
- RUBY
- - name: Compare recorded production release to staging
- id: preview
- env:
- ACTION_PATH: ${{ github.action_path }}
- STAGING_APP_NAME: ${{ inputs.staging_app_name }}
- CPLN_ORG_STAGING: ${{ inputs.staging_org }}
- PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
- shell: bash
- run: bash "${ACTION_PATH}/preview-promotion.sh"
diff --git a/.github/actions/cpflow-preview-promotion/preview-promotion.sh b/.github/actions/cpflow-preview-promotion/preview-promotion.sh
deleted file mode 100755
index 2a07a75f6..000000000
--- a/.github/actions/cpflow-preview-promotion/preview-promotion.sh
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# This job has only the staging token. Production provenance comes from the
-# successful production-* release record and is checked against the live image
-# by the protected deployment job after approval.
-export GH_HOST="${GITHUB_SERVER_URL#https://}"
-export GH_ENTERPRISE_TOKEN="${GH_TOKEN:-}"
-workload_json="$(cpln workload get "$PRIMARY_WORKLOAD" --gvc "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" -o json)"
-staging_image="$(jq -r '.spec.containers[0].image // empty' <<< "$workload_json")"
-if [[ "$staging_image" == /org/*/image/* ]]; then
- staging_image="${staging_image##*/image/}"
-elif [[ "$staging_image" == *.registry.cpln.io/* ]]; then
- staging_image="${staging_image#*.registry.cpln.io/}"
-fi
-staging_tag="${staging_image%%@*}"
-staging_commit="${staging_tag##*_}"
-if [[ "$staging_image" == *$'\n'* || "$staging_image" == *$'\r'* ]] || ! [[ "$staging_commit" =~ ^[0-9a-f]{40}$ ]]; then
- echo "::error::The deployed staging image must include a full lowercase commit SHA suffix."
- exit 1
-fi
-echo "staging_image=$staging_image" >> "$GITHUB_OUTPUT"
-{
- echo "## Commits ready for production"
- echo
- echo "Preview captured before production approval. Baseline: the last recorded successful production release; live production is verified after approval."
- echo
-} >> "$GITHUB_STEP_SUMMARY"
-
-# Limit release discovery to the latest 100 entries. Missing provenance is
-# explicit rather than falling back to a moving branch or an unrelated release.
-if ! releases="$(timeout 30s gh api "repos/${GH_REPO}/releases?per_page=100")"; then
- echo "Comparison unavailable: GitHub could not read the production release history." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-production_release="$(jq -r '[.[] | select(.draft == false and .prerelease == false) | select(.tag_name | test("^production-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}-[0-9]+$"))] | sort_by(.published_at // "") | reverse | .[0].tag_name // empty' <<< "$releases")"
-if [[ -z "$production_release" ]]; then
- echo "Comparison unavailable: no successful production release was found in the latest 100 releases." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-# Resolve the tag itself: target_commitish can be a branch name on older releases.
-if ! production_json="$(timeout 30s gh api "repos/${GH_REPO}/commits/${production_release}")"; then
- echo "Comparison unavailable: GitHub could not resolve the recorded production release." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-production_commit="$(jq -r '.sha // empty' <<< "$production_json")"
-if ! [[ "$production_commit" =~ ^[0-9a-f]{40}$ ]]; then
- echo "Comparison unavailable: the recorded production release has no full commit SHA." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-echo "production_commit=$production_commit" >> "$GITHUB_OUTPUT"
-
-repository_url="${GITHUB_SERVER_URL}/${GH_REPO}"
-echo "[Recorded production release](${repository_url}/releases/tag/${production_release})" >> "$GITHUB_STEP_SUMMARY"
-{
- echo "Recorded production release: [\`${production_commit:0:7}\`](${repository_url}/commit/${production_commit})"
- echo "Staging: [\`${staging_commit:0:7}\`](${repository_url}/commit/${staging_commit})"
- echo
- echo "[Compare production to staging](${repository_url}/compare/${production_commit}...${staging_commit})"
- echo
-} >> "$GITHUB_STEP_SUMMARY"
-if [[ "${production_commit}" == "${staging_commit}" ]]; then
- echo "Production and staging use the same commit; there are no commits to promote." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-
-comparison="$(mktemp)"
-trap 'rm -f "$comparison" "${comparison}.summary"' EXIT
-# One bounded page; the compare link provides the complete diff and history.
-if ! timeout 30s gh api "repos/${GH_REPO}/compare/${production_commit}...${staging_commit}?per_page=100&page=1" > "$comparison"; then
- echo "Commit list unavailable: GitHub could not compare these deployed commits. Use the compare link above." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-
-if ! jq -r --arg repository_url "$repository_url" '
- "\(.ahead_by) commit(s) on staging that are not in the recorded production release.", "",
- (if .status == "diverged" or .status == "behind" then
- "Warning: staging is \(.status); production has \(.behind_by) commit(s) that are not on staging. Review the comparison before treating this as a forward promotion."
- else empty end), "",
- "
",
- (.commits[:100][] |
- "- \(.sha[:7]) " +
- (.commit.message | split("\n")[0] | @html) + "
"),
- "
", "",
- (if .ahead_by > 100 then "Showing the first 100 commits; use the compare link for the complete list." else empty end)
-' "$comparison" > "${comparison}.summary"; then
- echo "Commit list unavailable: GitHub returned incomplete comparison data." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-cat "${comparison}.summary" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/actions/cpflow-resolve-review-config/action.yml b/.github/actions/cpflow-resolve-review-config/action.yml
deleted file mode 100644
index b5404d373..000000000
--- a/.github/actions/cpflow-resolve-review-config/action.yml
+++ /dev/null
@@ -1,137 +0,0 @@
-name: Resolve Review App Config
-description: Infers review app prefix and Control Plane org from controlplane.yml
-
-inputs:
- configured_cpln_org_staging:
- description: Optional override for the staging Control Plane org.
- required: false
- default: ""
- configured_review_app_prefix:
- description: Optional override for the review app prefix.
- required: false
- default: ""
- pr_number:
- description: Pull request number used to build the review app name.
- required: false
- default: ""
- working_directory:
- description: Directory containing .controlplane/controlplane.yml.
- required: false
- default: "."
-
-outputs:
- review_app_prefix:
- description: Resolved review app prefix.
- value: ${{ steps.resolve.outputs.review_app_prefix }}
- cpln_org:
- description: Resolved Control Plane org.
- value: ${{ steps.resolve.outputs.cpln_org }}
- app_name:
- description: Resolved review app name when pr_number is present; omitted when pr_number is empty.
- value: ${{ steps.resolve.outputs.app_name }}
-
-runs:
- using: composite
- steps:
- - name: Resolve review app config
- id: resolve
- shell: bash
- working-directory: ${{ inputs.working_directory }}
- env:
- CONFIGURED_CPLN_ORG_STAGING: ${{ inputs.configured_cpln_org_staging }}
- CONFIGURED_REVIEW_APP_PREFIX: ${{ inputs.configured_review_app_prefix }}
- PR_NUMBER: ${{ inputs.pr_number }}
- run: |
- set -euo pipefail
-
- ruby <<'RUBY'
- require "yaml"
-
- def safe_load_yaml_file(path)
- contents = File.read(path)
-
- if YAML.method(:safe_load).parameters.any? { |type, name| type == :key && name == :aliases }
- YAML.safe_load(contents, aliases: true)
- else
- YAML.safe_load(contents, [], [], true)
- end
- end
-
- def validate_github_env_value!(name, value)
- return if value.match?(/\A[A-Za-z0-9-]+\z/)
-
- warn "::error::#{name} must contain only letters, numbers, and hyphens so it is a valid Control Plane name and can be safely written to GitHub environment files."
- exit 1
- end
-
- begin
- config = safe_load_yaml_file(".controlplane/controlplane.yml")
- unless config.is_a?(Hash)
- warn "::error::.controlplane/controlplane.yml must be a YAML mapping; got #{config.class}: #{config.inspect}"
- exit 1
- end
-
- apps = config["apps"]
- unless apps.is_a?(Hash)
- warn "::error::.controlplane/controlplane.yml must define an apps mapping; got #{apps.class}: #{apps.inspect}"
- exit 1
- end
-
- review_apps = apps.select do |_name, app_config|
- app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
- end
-
- prefix = ENV.fetch("CONFIGURED_REVIEW_APP_PREFIX", "").strip
- if prefix.empty?
- if review_apps.length == 1
- prefix = review_apps.keys.first
- elsif review_apps.empty?
- warn "::error::Set REVIEW_APP_PREFIX or define exactly one app with match_if_app_name_starts_with: true in .controlplane/controlplane.yml."
- exit 1
- else
- warn "::error::Set REVIEW_APP_PREFIX because .controlplane/controlplane.yml defines multiple review app prefixes: #{review_apps.keys.sort.join(', ')}."
- exit 1
- end
- end
-
- app_config = apps[prefix]
- unless app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
- warn "::error::Review app prefix '#{prefix}' must match an app in .controlplane/controlplane.yml with match_if_app_name_starts_with: true."
- exit 1
- end
-
- cpln_org = ENV.fetch("CONFIGURED_CPLN_ORG_STAGING", "").strip
- cpln_org = app_config["cpln_org"].to_s.strip if cpln_org.empty?
- if cpln_org.empty?
- warn "::error::Set CPLN_ORG_STAGING or cpln_org for review app prefix '#{prefix}' in .controlplane/controlplane.yml."
- exit 1
- end
-
- pr_number = ENV.fetch("PR_NUMBER", "").strip
- unless pr_number.empty? || pr_number.match?(/\A[1-9][0-9]*\z/)
- warn "::error::PR_NUMBER must be a positive integer; got: #{pr_number.inspect}"
- exit 1
- end
-
- app_name = pr_number.empty? ? "" : "#{prefix}-#{pr_number}"
-
- validate_github_env_value!("REVIEW_APP_PREFIX", prefix)
- validate_github_env_value!("CPLN_ORG", cpln_org)
- validate_github_env_value!("APP_NAME", app_name) unless app_name.empty?
-
- File.open(ENV.fetch("GITHUB_ENV"), "a") do |file|
- file.puts "REVIEW_APP_PREFIX=#{prefix}"
- file.puts "CPLN_ORG=#{cpln_org}"
- file.puts "APP_NAME=#{app_name}" unless app_name.empty?
- end
-
- File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |file|
- file.puts "review_app_prefix=#{prefix}"
- file.puts "cpln_org=#{cpln_org}"
- file.puts "app_name=#{app_name}" unless app_name.empty?
- end
- rescue StandardError => e
- warn "::error::Could not resolve review app config from .controlplane/controlplane.yml: #{e.class}: #{e.message}"
- exit 1
- end
- RUBY
diff --git a/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml
deleted file mode 100644
index 9ed686ff3..000000000
--- a/.github/actions/cpflow-setup-environment/action.yml
+++ /dev/null
@@ -1,281 +0,0 @@
-name: Setup Control Plane Environment
-description: Sets up Ruby, installs the Control Plane CLI and cpflow, and configures a default profile
-
-inputs:
- token:
- description: Control Plane token
- required: true
- org:
- description: Control Plane organization
- required: true
- ruby_version:
- description: >-
- Ruby version used for cpflow. When empty (the default), ruby/setup-ruby
- auto-detects from .ruby-version, .tool-versions, mise.toml, or a Gemfile
- ruby directive, then falls back to the action's pinned default.
- required: false
- default: ""
- working_directory:
- description: Directory where ruby/setup-ruby should detect Ruby version files.
- required: false
- default: "."
- cpflow_source_directory:
- description: Directory containing the checked-out control-plane-flow source.
- required: false
- default: ".cpflow"
- # GitHub parses double-brace expression snippets inside action metadata (including
- # `description:`) while loading the composite action, and the `vars` context is not
- # available in that phase. Keep these descriptions in plain prose - reference repo
- # variables by NAME only, never with literal GitHub Actions expression syntax.
- cpln_cli_version:
- description: >-
- @controlplane/cli version. Empty string falls back to the action's pinned default,
- so callers can wire this input to the CPLN_CLI_VERSION repository variable
- unconditionally.
- required: false
- default: ""
- cpflow_version:
- description: >-
- cpflow gem version to install from RubyGems. Empty string installs cpflow from
- the checked-out control-plane-flow repository, so callers can test a GitHub ref
- without publishing a release.
- required: false
- default: ""
- control_plane_flow_ref:
- description: >-
- Full GitHub workflow ref for the control-plane-flow reusable workflow, for
- example shakacode/control-plane-flow/.github/workflows/deploy.yml@refs/tags/v5.0.1.
- When cpflow_version is set, this must point at the matching release tag.
- required: false
- default: ""
-
-runs:
- using: composite
- # Dependabot proposes action updates, but each reviewed dependency remains
- # pinned to the release commit recorded beside its exact version comment.
- steps:
- - name: Resolve Ruby setup version
- id: ruby-version
- shell: bash
- env:
- INPUT_RUBY_VERSION: ${{ inputs.ruby_version }}
- INPUT_WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
-
- ruby_version="${INPUT_RUBY_VERSION}"
- working_directory="${INPUT_WORKING_DIRECTORY:-.}"
- # Bump when the project's minimum-supported Ruby advances.
- default_ruby_version="3.2"
-
- if [[ -z "${ruby_version}" ]]; then
- if [[ -f "${working_directory}/.ruby-version" ]] ||
- { [[ -f "${working_directory}/.tool-versions" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]+" "${working_directory}/.tool-versions"; } ||
- { [[ -f "${working_directory}/mise.toml" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*=" "${working_directory}/mise.toml"; } ||
- { [[ -f "${working_directory}/.mise.toml" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*=" "${working_directory}/.mise.toml"; }; then
- : # keep empty; ruby/setup-ruby will auto-detect
- elif [[ -f "${working_directory}/Gemfile" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*(\(|file:|['\"])" "${working_directory}/Gemfile"; then
- : # keep empty; ruby/setup-ruby will read Gemfile
- else
- ruby_version="${default_ruby_version}"
- fi
- fi
-
- echo "ruby_version=${ruby_version}" >> "$GITHUB_OUTPUT"
-
- - name: Set up Ruby
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
- with:
- ruby-version: ${{ steps.ruby-version.outputs.ruby_version }}
- working-directory: ${{ inputs.working_directory }}
-
- - name: Install Control Plane CLI and cpflow gem
- shell: bash
- env:
- CONTROL_PLANE_FLOW_REF: ${{ inputs.control_plane_flow_ref }}
- CPLN_CLI_VERSION: ${{ inputs.cpln_cli_version }}
- CPFLOW_VERSION: ${{ inputs.cpflow_version }}
- CPFLOW_SOURCE_DIR: ${{ inputs.cpflow_source_directory }}
- run: |
- set -euo pipefail
-
- # Bump this default when a new Control Plane CLI release should roll out by default.
- # Override per-repo by setting the `CPLN_CLI_VERSION` repo variable.
- default_cpln_cli_version="3.11.0"
-
- CPLN_CLI_VERSION="${CPLN_CLI_VERSION:-${default_cpln_cli_version}}"
-
- normalize_version() {
- local version="${1#v}"
- version="${version//-/.}"
-
- if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9A-Za-z]+)*$ ]]; then
- echo "${version}"
- fi
- # Empty output means an unrecognized format; callers check for that.
- }
-
- extract_ref_name() {
- local ref="${1##*@}"
- ref="${ref#refs/tags/}"
- echo "${ref}"
- }
-
- normalize_release_ref() {
- local ref
- ref="$(extract_ref_name "$1")"
-
- # Intentional: non-v refs return success with empty stdout; callers
- # treat empty output as "not a release tag".
- [[ "${ref}" == v* ]] || return 0
- normalize_version "${ref}"
- }
-
- verify_release_ref_matches_checkout() {
- local ref
- local remote_url="https://github.com/shakacode/control-plane-flow.git"
- local tag_refs
- local tag_commit=""
- local checkout_commit
-
- ref="$(extract_ref_name "$1")"
- [[ "${ref}" == v* ]] || return 1
-
- git_ls_remote_tag() {
- if command -v timeout >/dev/null 2>&1; then
- timeout 20 git ls-remote --tags "${remote_url}" "refs/tags/${ref}" "refs/tags/${ref}^{}"
- else
- git ls-remote --tags "${remote_url}" "refs/tags/${ref}" "refs/tags/${ref}^{}"
- fi
- }
-
- if ! tag_refs="$(git_ls_remote_tag 2>&1)"; then
- echo "::error::Could not verify the control-plane-flow workflow ref against ${remote_url}. Runners that set CPFLOW_VERSION need outbound HTTPS access to GitHub for this tag check. Leave CPFLOW_VERSION unset when testing a commit SHA or branch. Details: ${tag_refs}"
- exit 1
- fi
-
- if [[ -z "${tag_refs}" ]]; then
- echo "::error::CPFLOW_VERSION can only be used with an existing control-plane-flow release tag. No remote tag found for workflow ref ${CONTROL_PLANE_FLOW_REF}."
- exit 1
- fi
-
- while read -r sha tag_ref; do
- if [[ "${tag_ref}" == "refs/tags/${ref}^{}" ]]; then
- tag_commit="${sha}"
- break
- fi
-
- if [[ "${tag_ref}" == "refs/tags/${ref}" ]]; then
- tag_commit="${sha}"
- fi
- done <<< "${tag_refs}"
-
- if [[ -z "${tag_commit}" ]]; then
- echo "::error::Could not resolve the commit for release tag ${ref}."
- exit 1
- fi
-
- checkout_commit="$(git -C "${CPFLOW_SOURCE_DIR}" rev-parse HEAD)"
- if [[ "${checkout_commit}" != "${tag_commit}" ]]; then
- echo "::error::control-plane-flow workflow ref ${CONTROL_PLANE_FLOW_REF} resolved to ${checkout_commit}, but release tag ${ref} points to ${tag_commit}. Use the real release tag ref, not a moving branch, or leave CPFLOW_VERSION unset."
- exit 1
- fi
- }
-
- is_rubygems_version() {
- [[ "${1}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9A-Za-z]+)*$ ]]
- }
-
- validate_cpflow_version_pin() {
- [[ -z "${CPFLOW_VERSION}" ]] && return 0
-
- local actual_version
- local expected_version
-
- if ! is_rubygems_version "${CPFLOW_VERSION}"; then
- echo "::error::CPFLOW_VERSION must be a RubyGems version usable by 'gem install cpflow -v', such as 5.0.0 or 5.0.0.rc.1. Do not include a leading v, and use dot-separated prereleases instead of dash-separated prereleases."
- exit 1
- fi
-
- actual_version="$(normalize_version "${CPFLOW_VERSION}")"
- expected_version="$(normalize_release_ref "${CONTROL_PLANE_FLOW_REF}")"
-
- if [[ -z "${expected_version}" ]]; then
- echo "::error::CPFLOW_VERSION can only be used when the control-plane-flow reusable workflow is pinned to a release tag like v${CPFLOW_VERSION}. Dot and dash prerelease tags are accepted, for example v5.0.0.rc.1 or v5.0.0-rc.1. Current workflow ref: ${CONTROL_PLANE_FLOW_REF:-}. Leave CPFLOW_VERSION unset when testing a commit SHA or branch so cpflow is built from the same source as the reusable workflow."
- exit 1
- fi
-
- verify_release_ref_matches_checkout "${CONTROL_PLANE_FLOW_REF}"
-
- if [[ "${actual_version}" != "${expected_version}" ]]; then
- echo "::error::CPFLOW_VERSION must match the control-plane-flow reusable workflow tag. CPFLOW_VERSION=${CPFLOW_VERSION}, normalized CPFLOW_VERSION=${actual_version}, workflow ref=${CONTROL_PLANE_FLOW_REF}, expected CPFLOW_VERSION=${expected_version}."
- exit 1
- fi
- }
-
- validate_cpflow_version_pin
-
- npm_global_prefix="${HOME}/.npm-global"
- mkdir -p "${npm_global_prefix}"
- echo "${npm_global_prefix}/bin" >> "$GITHUB_PATH"
- export PATH="${npm_global_prefix}/bin:${PATH}"
-
- npm install --global --prefix "${npm_global_prefix}" "@controlplane/cli@${CPLN_CLI_VERSION}"
- cpln --version
-
- if [[ -n "${CPFLOW_VERSION}" ]]; then
- gem install cpflow -v "${CPFLOW_VERSION}" --no-document
- else
- cpflow_source_dir="$(cd "${CPFLOW_SOURCE_DIR}" && pwd)"
- if [[ ! -f "${cpflow_source_dir}/cpflow.gemspec" ]]; then
- echo "::error::CPFLOW_SOURCE_DIR (${cpflow_source_dir}) does not contain cpflow.gemspec" >&2
- exit 1
- fi
-
- cpflow_gem="$(mktemp -t cpflow-XXXXXX.gem)"
- trap 'rm -f "${cpflow_gem}"' EXIT
- (
- cd "${cpflow_source_dir}"
- gem build cpflow.gemspec --output "${cpflow_gem}"
- )
- gem install "${cpflow_gem}" --no-document
- fi
-
- cpflow --version
-
- - name: Setup Control Plane profile and registry login
- shell: bash
- env:
- # Pass the token via CPLN_TOKEN so cpln picks it up from the environment
- # rather than `--token`, which would leak it into /proc//cmdline and ps output.
- CPLN_TOKEN: ${{ inputs.token }}
- ORG: ${{ inputs.org }}
- run: |
- set -euo pipefail
-
- if [[ -z "$CPLN_TOKEN" ]]; then
- echo "Error: Control Plane token not provided" >&2
- exit 1
- fi
-
- if [[ -z "$ORG" ]]; then
- echo "Error: Control Plane organization not provided" >&2
- exit 1
- fi
-
- # `cpln profile update` lists `create` as an alias (cpln profile --help) and is
- # idempotent: it creates the profile if missing and updates it otherwise. Calling
- # update directly avoids parsing the CLI's "already exists" English error text,
- # which would silently swallow a real failure if the wording ever changed.
- cpln profile update default --org "$ORG"
- cpln image docker-login --org "$ORG"
-
- # Keep the token available to later cpflow/cpln steps without passing it
- # on the command line. GitHub masks secret values in logs, and the env file
- # itself is not echoed.
- delim="CPLN_TOKEN_DELIM_$(openssl rand -hex 8)"
- {
- echo "CPLN_TOKEN<<${delim}"
- echo "${CPLN_TOKEN}"
- echo "${delim}"
- } >> "$GITHUB_ENV"
diff --git a/.github/actions/cpflow-validate-config/action.yml b/.github/actions/cpflow-validate-config/action.yml
deleted file mode 100644
index 925013899..000000000
--- a/.github/actions/cpflow-validate-config/action.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: Validate cpflow GitHub configuration
-description: >-
- Validates that required secrets and repository variables are set before a workflow
- proceeds. Pass each value via `env:` with the same NAME as the secret or variable,
- then list the required entries in `required` as `type:NAME` pairs (type is `secret`
- or `variable`). When `pull_request_friendly: true` and the current event is a
- pull request event, missing config writes a step summary and exits 0 with
- `ready=false` instead of failing the job.
-
-inputs:
- required:
- description: |
- Newline-separated `type:NAME` pairs. Type is `secret` or `variable`. The
- caller MUST export the matching values via `env:` using the same NAME.
- required: true
- pull_request_friendly:
- description: When "true" and event is pull_request/pull_request_target, write summary and exit 0 with ready=false.
- required: false
- default: "false"
-
-outputs:
- ready:
- description: '"true" when all values are set, "false" when missing in PR-friendly mode.'
- value: ${{ steps.check.outputs.ready }}
-
-runs:
- using: composite
- steps:
- - name: Check required secrets and variables
- id: check
- shell: bash
- env:
- CPFLOW_REQUIRED: ${{ inputs.required }}
- CPFLOW_PR_FRIENDLY: ${{ inputs.pull_request_friendly }}
- CPFLOW_EVENT_NAME: ${{ github.event_name }}
- run: |
- set -euo pipefail
-
- missing=()
- while IFS= read -r entry; do
- entry="${entry%$'\r'}"
- entry="${entry## }"
- entry="${entry%% }"
- [[ -z "${entry}" ]] && continue
-
- type="${entry%%:*}"
- name="${entry#*:}"
-
- # Reject names that are not plain SHELL_VAR identifiers before doing the
- # indirect lookup below. Without this guard, ${!name} would expand whatever
- # bash nameref/transformation a hand-edited generated workflow snuck in
- # (e.g. `BASH_FUNC_foo%%`). Callers today are the generated templates, but
- # the generated file lives in the user's repo and can be hand-edited.
- if [[ ! "${name}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
- echo "Invalid config entry name: ${name}" >&2
- exit 1
- fi
-
- # Indirect bash lookup: reads the env var named by ${name} (e.g. CPLN_TOKEN_STAGING)
- # so the value never has to round-trip through workflow logs.
- if [[ -z "${!name:-}" ]]; then
- missing+=("${type}:${name}")
- fi
- done <<< "${CPFLOW_REQUIRED}"
-
- if [[ ${#missing[@]} -eq 0 ]]; then
- echo "ready=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- if [[ "${CPFLOW_PR_FRIENDLY}" == "true" && ( "${CPFLOW_EVENT_NAME}" == "pull_request" || "${CPFLOW_EVENT_NAME}" == "pull_request_target" ) ]]; then
- echo "ready=false" >> "$GITHUB_OUTPUT"
- {
- echo "Control Plane review app automation is not configured yet."
- echo
- echo "Missing required GitHub configuration:"
- printf -- '- `%s`\n' "${missing[@]}"
- echo
- echo "Pushes to this pull request will skip review app deploys until the repository is configured."
- } >> "$GITHUB_STEP_SUMMARY"
- exit 0
- fi
-
- printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2
- exit 1
diff --git a/.github/actions/cpflow-wait-for-health/action.yml b/.github/actions/cpflow-wait-for-health/action.yml
deleted file mode 100644
index 21141a482..000000000
--- a/.github/actions/cpflow-wait-for-health/action.yml
+++ /dev/null
@@ -1,171 +0,0 @@
-name: Wait for Control Plane workload health
-description: >-
- Polls Control Plane until the latest workload version is ready, then checks
- the standard workload endpoint and ready location endpoints 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:
- description: Workload to query (e.g. `rails`).
- required: true
- app_name:
- description: GVC / Control Plane app name the workload belongs to.
- required: true
- org:
- description: Control Plane organization.
- required: true
- max_retries:
- description: Number of attempts before giving up.
- required: false
- default: "24"
- interval_seconds:
- description: Seconds to sleep between attempts.
- required: false
- default: "15"
- accepted_statuses:
- description: >-
- Space-separated list of HTTP status codes considered healthy. The default
- `200 301 302` accepts redirects because curl is invoked without `-L`, so a
- root path that auth-redirects looks like a redirect, not a failure.
- required: false
- default: "200 301 302"
- curl_max_time:
- description: Per-request curl timeout, seconds.
- required: false
- default: "10"
-
-outputs:
- healthy:
- description: '"true" once a healthy response was observed; "false" otherwise.'
- value: ${{ steps.poll.outputs.healthy }}
- endpoint:
- description: The workload or location endpoint that returned an accepted status.
- value: ${{ steps.poll.outputs.endpoint }}
-
-runs:
- using: composite
- steps:
- - name: Poll workload endpoint
- id: poll
- shell: bash
- env:
- CPFLOW_WORKLOAD_NAME: ${{ inputs.workload_name }}
- CPFLOW_APP_NAME: ${{ inputs.app_name }}
- CPFLOW_ORG: ${{ inputs.org }}
- CPFLOW_MAX_RETRIES: ${{ inputs.max_retries }}
- CPFLOW_INTERVAL_SECONDS: ${{ inputs.interval_seconds }}
- CPFLOW_ACCEPTED_STATUSES: ${{ inputs.accepted_statuses }}
- CPFLOW_CURL_MAX_TIME: ${{ inputs.curl_max_time }}
- run: |
- set -euo pipefail
-
- read -r -a accepted_statuses <<< "${CPFLOW_ACCEPTED_STATUSES}"
-
- check_endpoint() {
- local endpoint="$1"
- local endpoint_type="$2"
- local http_status
-
- http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || true)"
- http_status="${http_status:-000}"
- echo "${endpoint_type}: ${endpoint}, HTTP status: ${http_status}"
-
- for accepted in "${accepted_statuses[@]}"; do
- if [[ "${http_status}" == "${accepted}" ]]; then
- {
- echo "healthy=true"
- echo "endpoint=${endpoint}"
- } >> "$GITHUB_OUTPUT"
- return 0
- fi
- done
-
- return 1
- }
-
- summarize_cpln_error() {
- local error_text="$1"
-
- case "${error_text}" in
- *401*|*403*|*[Uu]nauthorized*|*[Ff]orbidden*) echo "authorization failure" ;;
- *404*|*"not found"*|*"Not Found"*) echo "resource not found" ;;
- *429*|*"rate limit"*|*"Rate Limit"*) echo "rate limited" ;;
- *"timed out"*|*[Tt]imeout*) echo "request timeout" ;;
- *) echo "CLI error (details suppressed; ${#error_text} stderr bytes captured)" ;;
- esac
- }
-
- for attempt in $(seq 1 "${CPFLOW_MAX_RETRIES}"); do
- echo "Health check attempt ${attempt}/${CPFLOW_MAX_RETRIES}"
-
- if ! workload_json="$(cpln workload get "${CPFLOW_WORKLOAD_NAME}" --gvc "${CPFLOW_APP_NAME}" --org "${CPFLOW_ORG}" -o json 2>&1)"; then
- echo "::error::Workload '${CPFLOW_WORKLOAD_NAME}' not found in GVC '${CPFLOW_APP_NAME}'. Set PRIMARY_WORKLOAD to the correct workload name." >&2
- printf '%s\n' "${workload_json}" >&2
- echo "healthy=false" >> "$GITHUB_OUTPUT"
- 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"')"
- workload_endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')"
-
- if [[ "${workload_ready}" != "true" || "${latest_ready}" != "true" ]]; then
- echo "Workload status: ready=${workload_ready}, readyLatest=${latest_ready}, readiness=${readiness_status}; waiting for latest deployment."
- else
- if [[ -n "${workload_endpoint}" ]] && check_endpoint "${workload_endpoint}" "Workload endpoint"; then
- exit 0
- fi
-
- location_endpoint_count=0
- deployments_listed=false
- deployments_settled=false
- deployments_stderr="$(mktemp)"
- if deployments_json="$(cpln workload get-deployments "${CPFLOW_WORKLOAD_NAME}" --gvc "${CPFLOW_APP_NAME}" --org "${CPFLOW_ORG}" -o json 2>"${deployments_stderr}")"; then
- deployments_listed=true
- unsettled_location_count="$(
- printf '%s\n' "${deployments_json}" |
- jq '[.items[]? | select(.status.ready != true or (.status.deploying // false) == true)] | length'
- )"
-
- if [[ "${unsettled_location_count}" -gt 0 ]]; then
- echo "Deployment locations are not all ready (${unsettled_location_count} unsettled); waiting before endpoint fallback."
- else
- deployments_settled=true
- while IFS= read -r location_endpoint; do
- location_endpoint_count=$((location_endpoint_count + 1))
- if [[ -n "${location_endpoint}" &&
- "${location_endpoint}" != "${workload_endpoint}" ]] &&
- check_endpoint "${location_endpoint}" "Location endpoint"; then
- rm -f "${deployments_stderr}"
- exit 0
- fi
- done < <(
- printf '%s\n' "${deployments_json}" |
- jq -r '.items[]? | .status.endpoint // empty' |
- awk '!seen[$0]++'
- )
- fi
- else
- deployments_error="$(<"${deployments_stderr}")"
- deployments_error_summary="$(summarize_cpln_error "${deployments_error}")"
- echo "::warning::Could not list location endpoints for workload '${CPFLOW_WORKLOAD_NAME}' (${deployments_error_summary}); retrying."
- fi
- rm -f "${deployments_stderr}"
-
- if [[ -z "${workload_endpoint}" &&
- "${deployments_listed}" == "true" &&
- "${deployments_settled}" == "true" &&
- "${location_endpoint_count}" -eq 0 ]]; then
- echo "Workload '${CPFLOW_WORKLOAD_NAME}' has no endpoint yet; waiting for one to be assigned."
- fi
- fi
-
- if [[ "${attempt}" -lt "${CPFLOW_MAX_RETRIES}" ]]; then
- sleep "${CPFLOW_INTERVAL_SECONDS}"
- fi
- done
-
- echo "healthy=false" >> "$GITHUB_OUTPUT"
- exit 1
diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml
index 7a6d44543..8351308b4 100644
--- a/.github/workflows/cpflow-delete-review-app.yml
+++ b/.github/workflows/cpflow-delete-review-app.yml
@@ -41,6 +41,6 @@ jobs:
github.event_name == 'workflow_dispatch'
# This `if:` mirrors the upstream job guard to avoid a billable workflow_call
# when the event does not match. Keep both conditions in sync.
- uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@68b5d83152c02a70eb98ffdc7fd2072e28265fef # v6.0.0
+ uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@da38e257c6256899e3ab35b0a3bc5d9e02b9934c # control-plane-flow PR 511 trial
secrets:
CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml
index abf7243ce..e7113698e 100644
--- a/.github/workflows/cpflow-deploy-review-app.yml
+++ b/.github/workflows/cpflow-deploy-review-app.yml
@@ -39,7 +39,7 @@ jobs:
github.event.issue.pull_request &&
contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
- uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@68b5d83152c02a70eb98ffdc7fd2072e28265fef # v6.0.0
+ uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@da38e257c6256899e3ab35b0a3bc5d9e02b9934c # control-plane-flow PR 511 trial
secrets:
CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
REVIEW_APP_DOCKER_BUILD_SECRETS: ${{ secrets.REVIEW_APP_DOCKER_BUILD_SECRETS }}