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), "", - "", "", - (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 }}