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 c5b4faf3..00000000 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ /dev/null @@ -1,131 +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_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: - # 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 - if: ${{ inputs.docker_build_ssh_key != '' }} - shell: bash - env: - # Pass the key via env so the file write is a single printf call rather than a - # heredoc with a fixed terminator (a heredoc would silently truncate the key if - # any line of the key value happened to match the terminator). Scope is still - # this step only — the build step below does not receive 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 - - 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 - - printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" > ~/.ssh/cpflow_build_key - chmod 600 ~/.ssh/cpflow_build_key - - - name: Build Docker image - shell: bash - env: - APP_NAME: ${{ inputs.app_name }} - COMMIT_SHA: ${{ inputs.commit }} - CONTROL_PLANE_ORG: ${{ inputs.org }} - DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }} - PR_NUMBER: ${{ inputs.pr_number }} - WORKING_DIRECTORY: ${{ inputs.working_directory }} - run: | - set -euo pipefail - - PR_INFO="" - docker_build_args=() - ssh_agent_started=false - build_ssh_prepped=false - - cleanup_build_ssh() { - if [[ "${ssh_agent_started}" == "true" ]]; then - ssh-agent -k >/dev/null || true - fi - rm -f "${HOME}/.ssh/cpflow_build_key" - # Only remove known_hosts if this action's prep step wrote it. On self-hosted - # or reused runners we must not touch a user-managed file we did not create, - # so the flag is set inside the same prep-detection branch below. - if [[ "${build_ssh_prepped}" == "true" ]]; then - rm -f "${HOME}/.ssh/known_hosts" - 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 [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then - # Mark prep-step ownership so cleanup_build_ssh only removes known_hosts - # when this action wrote it (see trap above). - build_ssh_prepped=true - eval "$(ssh-agent -s)" - ssh_agent_started=true - ssh-add "${HOME}/.ssh/cpflow_build_key" - docker_build_args+=("--ssh=default") - 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})" 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 63981dd5..00000000 --- a/.github/actions/cpflow-delete-control-plane-app/action.yml +++ /dev/null @@ -1,24 +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 - -runs: - using: composite - steps: - - name: Delete application - shell: bash - run: ${{ github.action_path }}/delete-app.sh - env: - 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 1ae19759..00000000 --- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +++ /dev/null @@ -1,50 +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" - 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 5b68da87..00000000 --- a/.github/actions/cpflow-detect-release-phase/action.yml +++ /dev/null @@ -1,62 +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) - 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-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml deleted file mode 100644 index e029681b..00000000 --- a/.github/actions/cpflow-setup-environment/action.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Setup Control Plane Environment -description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, 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, or the Gemfile. - required: false - default: "" - # 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. Empty string falls back to the action's pinned default, - so callers can wire this input to the CPFLOW_VERSION repository variable - unconditionally. - required: false - default: "" - -runs: - using: composite - # Third-party actions are pinned to floating major tags (`@v4`, `@v1`, `@v7`) rather than - # immutable SHAs. SHA pinning is GitHub's stronger security recommendation, but for - # generated templates that ship into many downstream repositories floating tags are - # easier for users to keep current and Dependabot/Renovate already cover the SHA-pinning - # workflow for repositories that opt in. Repositories with stricter supply-chain - # requirements should replace each `uses: actions/...@vN` with the corresponding - # immutable commit SHA. - steps: - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ inputs.ruby_version }} - - - name: Install Control Plane CLI and cpflow gem - shell: bash - env: - CPLN_CLI_VERSION: ${{ inputs.cpln_cli_version }} - CPFLOW_VERSION: ${{ inputs.cpflow_version }} - run: | - set -euo pipefail - - # Bump these defaults when a new release lands that you want to roll out by default. - # Override per-repo by setting `CPLN_CLI_VERSION` / `CPFLOW_VERSION` repo variables; - # an empty input falls back to the action's pinned default below. - default_cpln_cli_version="3.3.1" - default_cpflow_version="5.0.0.rc.1" - - CPLN_CLI_VERSION="${CPLN_CLI_VERSION:-${default_cpln_cli_version}}" - CPFLOW_VERSION="${CPFLOW_VERSION:-${default_cpflow_version}}" - - 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 - - gem install cpflow -v "${CPFLOW_VERSION}" --no-document - 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 - - # Keep the service-account token available to later cpflow/cpln steps. - # The profile stores org/default metadata, but cpflow direct API calls - # read CPLN_TOKEN before falling back to `cpln profile token`. - echo "::add-mask::${CPLN_TOKEN}" - printf 'CPLN_TOKEN=%s\n' "${CPLN_TOKEN}" >> "${GITHUB_ENV}" - - # `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" diff --git a/.github/actions/cpflow-validate-config/action.yml b/.github/actions/cpflow-validate-config/action.yml deleted file mode 100644 index 92501389..00000000 --- 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 70347723..00000000 --- a/.github/actions/cpflow-wait-for-health/action.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Wait for Control Plane workload health -description: >- - Polls the workload's status endpoint with curl and exits success when the - HTTP response status is in the accepted list. Fails non-zero (and reports - `healthy=false`) once retries are exhausted. - -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 }} - -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}" - - 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 - - endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')" - if [[ -n "${endpoint}" ]]; then - http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || echo 000)" - echo "Endpoint: ${endpoint}, HTTP status: ${http_status}" - - for accepted in "${accepted_statuses[@]}"; do - if [[ "${http_status}" == "${accepted}" ]]; then - echo "healthy=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - done - else - echo "Workload '${CPFLOW_WORKLOAD_NAME}' has no endpoint yet; waiting for one to be assigned." - 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/cpflow-help.md b/.github/cpflow-help.md index ba51f5ec..4df158a1 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -47,6 +47,7 @@ You asked for review app help. These commands are generated by [cpflow](https:// | `STAGING_APP_NAME` | yes | App name in `controlplane.yml` used as the staging deploy target. | | `PRODUCTION_APP_NAME` | yes (for promote) | App name in `controlplane.yml` used as the production deploy target. | | `REVIEW_APP_PREFIX` | yes | Prefix for per-PR review app names (e.g. `review-app`). | +| `REVIEW_APP_DEPLOYING_ICON_URL` | optional | Custom image URL for the animated deploying icon in review-app PR comments. Set to `none` to use the text fallback icon. | | `STAGING_APP_BRANCH` | optional | Custom staging branch. Custom branches must also appear in `cpflow-deploy-staging.yml`'s push filter. | | `PRIMARY_WORKLOAD` | optional | Workload polled for health and rollback (defaults to `rails`). | | `DOCKER_BUILD_EXTRA_ARGS` | optional | Newline-delimited extra docker build tokens (e.g. `--build-arg=FOO=bar`). | @@ -60,7 +61,7 @@ You asked for review app help. These commands are generated by [cpflow](https://
Advanced: testing changes to generated workflows -When iterating on the generated workflow YAML on a PR branch, comment-triggered runs (`+review-app-deploy`, `+review-app-delete`, `+review-app-help`) execute the workflow code from the repository's default branch — not your PR branch. To exercise the top-level PR-branch workflow file before merging, dispatch the workflow manually with `gh`: +When iterating on the generated workflow YAML on a PR branch, comment-triggered runs (`+review-app-deploy`, `+review-app-delete`, `+review-app-help`) execute the workflow code from the repository's default branch — not your PR branch. To exercise the PR-branch workflow code before merging, dispatch the workflow manually with `gh`: ```sh gh workflow run cpflow-deploy-review-app.yml --ref -f pr_number= @@ -68,6 +69,6 @@ gh workflow run cpflow-delete-review-app.yml --ref -f pr_number gh workflow run cpflow-help-command.yml --ref -f pr_number= ``` -`workflow_dispatch` runs use the workflow file from the `--ref` you pass, but workflows that intentionally check out trusted local actions from the default branch will still load those local composite actions from the default branch before using secrets. Treat this as a partial smoke test for top-level workflow edits. For changes under `.github/actions/`, merge the generated fix to the default branch and rerun a real review-app deploy. +`workflow_dispatch` runs use the workflow file from the `--ref` you pass, so this is the supported way to test PR-branch workflow edits before merge. After merge, comment triggers go back to running the default-branch workflow code as usual.
diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml index 7861e672..eeae59ce 100644 --- a/.github/workflows/cpflow-cleanup-stale-review-apps.yml +++ b/.github/workflows/cpflow-cleanup-stale-review-apps.yml @@ -8,49 +8,9 @@ on: permissions: contents: read -concurrency: - # Single global group: only one cleanup sweep at a time. Independent of review-app - # deploy/delete groups (different keys), so cleanup will not block per-PR work. - group: cpflow-cleanup-stale-review-apps - # A cancelled `cpflow cleanup-stale-apps` can leave half-deleted review apps; let - # the in-flight run finish before the next scheduled tick begins. - cancel-in-progress: false - jobs: cleanup: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Validate required secrets and variables - uses: ./.github/actions/cpflow-validate-config - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - with: - required: | - secret:CPLN_TOKEN_STAGING - variable:CPLN_ORG_STAGING - variable:REVIEW_APP_PREFIX - - - name: Setup environment - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - - name: Remove stale review apps - env: - REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - shell: bash - run: | - set -euo pipefail - cpflow cleanup-stale-apps -a "${REVIEW_APP_PREFIX}" --org "${CPLN_ORG_STAGING}" --yes + uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c + with: + control_plane_flow_ref: 8e9c0c5e9991ac8651ae2721830bf5231f34de5c + secrets: inherit diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml index ffdc45f4..5aaba753 100644 --- a/.github/workflows/cpflow-delete-review-app.yml +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -17,17 +17,6 @@ permissions: issues: write pull-requests: write -concurrency: - group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - # Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave - # partial state behind. Let the in-progress deletion finish before the next run starts. - cancel-in-progress: false - -env: - APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - jobs: delete-review-app: if: | @@ -37,116 +26,7 @@ jobs: contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || (github.event_name == 'pull_request_target' && github.event.action == 'closed') || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - # pull_request_target is intentional: PR-close events from forks need access - # to staging secrets so this workflow can delete review apps and update PR - # comments. This checkout is safe because it does not set `ref:`; GitHub checks - # out the base branch's trusted workflow code, not the fork head. Do not add - # `ref: ${{ github.event.pull_request.head.sha }}` here without re-evaluating - # the trust boundary. All local composite actions below are therefore loaded from - # trusted base-branch code; keep them that way when changing this workflow. - - name: Checkout repository - uses: actions/checkout@v4 - with: - # Delete only invokes `cpln`/`cpflow`; no git push happens, so drop the - # GITHUB_TOKEN credential helper to keep the token out of .git/config under - # `pull_request_target`, which has access to repository secrets. - persist-credentials: false - - - name: Validate required secrets and variables - id: config - uses: ./.github/actions/cpflow-validate-config - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - with: - required: | - secret:CPLN_TOKEN_STAGING - variable:CPLN_ORG_STAGING - variable:REVIEW_APP_PREFIX - pull_request_friendly: "true" - - - name: Setup environment - if: steps.config.outputs.ready == 'true' - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - - name: Set workflow links - if: steps.config.outputs.ready == 'true' - uses: actions/github-script@v7 - with: - script: | - const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - core.exportVariable("WORKFLOW_URL", workflowUrl); - core.exportVariable( - "CONSOLE_URL", - `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/-info` - ); - - - name: Create initial PR comment - if: steps.config.outputs.ready == 'true' - id: create-comment - uses: actions/github-script@v7 - with: - script: | - const comment = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: Number(process.env.PR_NUMBER), - body: "🗑️ Deleting Control Plane review app..." - }); - core.setOutput("comment-id", comment.data.id); - - - name: Delete review app - if: steps.config.outputs.ready == 'true' - uses: ./.github/actions/cpflow-delete-control-plane-app - with: - app_name: ${{ env.APP_NAME }} - cpln_org: ${{ vars.CPLN_ORG_STAGING }} - review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }} - - # Finalizer still runs after delete failures, but only after config validation - # created the initial PR comment and workflow link env vars it updates. - - name: Finalize delete status - if: always() && steps.config.outputs.ready == 'true' - uses: actions/github-script@v7 - env: - COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }} - JOB_STATUS: ${{ job.status }} - with: - script: | - const commentId = Number(process.env.COMMENT_ID); - const success = process.env.JOB_STATUS === "success"; - const body = success - ? [ - `✅ Review app for PR #${process.env.PR_NUMBER} is deleted`, - "", - `[Open organization console](${process.env.CONSOLE_URL})`, - `[View workflow logs](${process.env.WORKFLOW_URL})` - ].join("\n") - : [ - `❌ Failed to delete review app for PR #${process.env.PR_NUMBER}`, - "", - `[Open organization console](${process.env.CONSOLE_URL})`, - `[View workflow logs](${process.env.WORKFLOW_URL})` - ].join("\n"); - - if (!Number.isFinite(commentId) || commentId <= 0) { - core.warning("Skipping delete status comment update because no comment id was created."); - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId, - body - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c + with: + control_plane_flow_ref: 8e9c0c5e9991ac8651ae2721830bf5231f34de5c + secrets: inherit diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml index f4b029fd..b140e878 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -20,25 +20,8 @@ permissions: issues: write pull-requests: write -concurrency: - group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - # Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the - # review app in a partially-deployed state (workload update in progress, rollout not - # settled). Let an in-flight deploy finish before the next push starts a new run. - cancel-in-progress: false - -env: - APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} - jobs: deploy: - # Skip synchronize/opened events from fork PRs at the job level — they cannot access - # repository secrets anyway, so running any steps just burns billable minutes. Users - # can still manually deploy a fork PR via `+review-app-deploy` (gated below by - # author_association) or workflow_dispatch. if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || @@ -47,408 +30,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)) - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout trusted workflow sources - uses: actions/checkout@v4 - with: - # Keep generated composite actions on the trusted base branch. The PR - # application code is checked out separately under ./app after source - # validation so same-repo PRs cannot replace local actions before - # staging secrets are passed to them. - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Validate required secrets and variables - id: config - uses: ./.github/actions/cpflow-validate-config - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - with: - required: | - secret:CPLN_TOKEN_STAGING - variable:CPLN_ORG_STAGING - variable:REVIEW_APP_PREFIX - pull_request_friendly: "true" - - - name: Resolve PR ref and commit - if: steps.config.outputs.ready == 'true' - id: resolve-pr - env: - # Route every GitHub-controlled input through env so the run script never - # interpolates ${{ ... }} into shell. All values here are GitHub-controlled - # (not user-influenced), so this is for consistency with the rest of the - # workflow and to quiet actionlint/StepSecurity, not a fix for an - # exploitable injection. - EVENT_NAME: ${{ github.event_name }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - PR_EVENT_NUMBER: ${{ github.event.pull_request.number }} - REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - shell: bash - run: | - set -euo pipefail - - case "${EVENT_NAME}" in - workflow_dispatch) - pr_number="${DISPATCH_PR_NUMBER}" - ;; - issue_comment) - pr_number="${ISSUE_NUMBER}" - ;; - pull_request) - pr_number="${PR_EVENT_NUMBER}" - ;; - *) - echo "Unsupported event type: ${EVENT_NAME}" >&2 - exit 1 - ;; - esac - - pr_data="$(gh pr view "$pr_number" --json headRefOid,headRepository,headRepositoryOwner)" - pr_sha="$(echo "$pr_data" | jq -r '.headRefOid')" - pr_repository="$(echo "$pr_data" | jq -r '[.headRepositoryOwner.login, .headRepository.name] | join("/")')" - same_repo="false" - - if [[ "$pr_repository" == "$GITHUB_REPOSITORY" ]]; then - same_repo="true" - fi - - echo "PR_NUMBER=$pr_number" >> "$GITHUB_ENV" - echo "APP_NAME=${REVIEW_APP_PREFIX}-$pr_number" >> "$GITHUB_ENV" - echo "PR_SHA=$pr_sha" >> "$GITHUB_ENV" - echo "same_repo=${same_repo}" >> "$GITHUB_OUTPUT" - - - name: Validate review app deployment source - if: steps.config.outputs.ready == 'true' - id: source - env: - EVENT_NAME: ${{ github.event_name }} - # Same env-routing pattern as Resolve PR ref and commit above: keep all - # ${{ ... }} values out of the run script. - SAME_REPO: ${{ steps.resolve-pr.outputs.same_repo }} - shell: bash - run: | - set -euo pipefail - - if [[ "${SAME_REPO}" == "true" ]]; then - echo "allowed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${EVENT_NAME}" == "pull_request" ]]; then - echo "allowed=false" >> "$GITHUB_OUTPUT" - { - echo "Review app deploys are skipped for fork pull requests." - echo "This workflow builds Docker images with repository secrets, so review app deploys only run for branches in the base repository." - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - if [[ "${EVENT_NAME}" == "issue_comment" ]]; then - echo "allowed=false" >> "$GITHUB_OUTPUT" - { - echo "Review app deploys from fork pull requests require a branch in ${GITHUB_REPOSITORY}." - echo "This workflow builds Docker images with repository secrets, so comment-triggered deploys only run for branches in the base repository." - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "Review app deploys from fork pull requests are not allowed for workflow_dispatch because this workflow uses repository secrets." >&2 - exit 1 - - - name: Checkout PR commit - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' - uses: actions/checkout@v4 - with: - ref: ${{ env.PR_SHA }} - path: app - persist-credentials: false - - - name: Remove PR checkout Git metadata - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' - shell: bash - run: | - set -euo pipefail - rm -rf app/.git - - - name: Setup environment - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - - name: Detect release phase support - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' - id: release-phase - uses: ./.github/actions/cpflow-detect-release-phase - with: - app_name: ${{ env.APP_NAME }} - working_directory: app - - - name: Check if review app exists - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' - id: check-app - working-directory: app - shell: bash - run: | - set -euo pipefail - - 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) - if [[ -n "${exists_output}" ]]; then - printf '%s\n' "${exists_output}" - fi - echo "exists=true" >> "$GITHUB_OUTPUT" - ;; - 3) - if [[ -n "${exists_output}" ]]; then - printf '%s\n' "${exists_output}" - fi - echo "exists=false" >> "$GITHUB_OUTPUT" - ;; - *) - echo "::error::cpflow exists returned unexpected exit code ${exists_status} for ${APP_NAME}" >&2 - if [[ -n "${exists_output}" ]]; then - printf '%s\n' "${exists_output}" >&2 - fi - exit "${exists_status}" - ;; - esac - - - name: Skip auto deploy until a review app is created - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request' - shell: bash - run: | - { - echo "Review app ${APP_NAME} does not exist yet." - echo "Create it with +review-app-deploy as the PR comment body." - } >> "$GITHUB_STEP_SUMMARY" - - - name: Setup review app if it does not exist yet - id: setup-review-app - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request' - working-directory: app - shell: bash - run: | - set -euo pipefail - cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" - - - name: Create initial PR comment - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - id: create-comment - uses: actions/github-script@v7 - with: - script: | - const result = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: Number(process.env.PR_NUMBER), - body: "🚀 Starting Control Plane review app deployment..." - }); - core.setOutput("comment-id", result.data.id); - - - name: Set deployment links - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - uses: actions/github-script@v7 - with: - script: | - const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - core.exportVariable("WORKFLOW_URL", workflowUrl); - core.exportVariable( - "CONSOLE_URL", - `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/gvc/${process.env.APP_NAME}/-info` - ); - - - name: Initialize GitHub deployment - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - id: init-deployment - uses: actions/github-script@v7 - with: - script: | - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: process.env.PR_SHA, - environment: `review/${process.env.APP_NAME}`, - auto_merge: false, - required_contexts: [], // intentional: review apps deploy regardless of required status checks - description: `Control Plane review app for PR #${process.env.PR_NUMBER}` - }); - - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.data.id, - state: "in_progress", - description: "Deployment started" - }); - - return deployment.data.id; - - - name: Update PR comment with build status - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - uses: actions/github-script@v7 - env: - COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }} - with: - script: | - const commentId = Number(process.env.COMMENT_ID); - if (!Number.isFinite(commentId) || commentId <= 0) { - core.warning("Skipping PR comment update because no comment id was created."); - return; - } - - const body = [ - `🏗️ Building Docker image for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}`, - "", - `[View build logs](${process.env.WORKFLOW_URL})`, - "", - `[Open Control Plane console](${process.env.CONSOLE_URL})` - ].join("\n"); - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId, - body - }); - - - name: Build Docker image - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - uses: ./.github/actions/cpflow-build-docker-image - with: - app_name: ${{ env.APP_NAME }} - org: ${{ vars.CPLN_ORG_STAGING }} - commit: ${{ env.PR_SHA }} - pr_number: ${{ env.PR_NUMBER }} - docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }} - docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }} - docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }} - working_directory: app - - - name: Update PR comment with deploy status - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - uses: actions/github-script@v7 - env: - COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }} - with: - script: | - const commentId = Number(process.env.COMMENT_ID); - if (!Number.isFinite(commentId) || commentId <= 0) { - core.warning("Skipping PR comment update because no comment id was created."); - return; - } - - const body = [ - "🚀 Deploying review app to Control Plane...", - "", - `[View deploy logs](${process.env.WORKFLOW_URL})`, - "", - `[Open Control Plane console](${process.env.CONSOLE_URL})` - ].join("\n"); - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId, - body - }); - - - name: Deploy to Control Plane - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - working-directory: app - env: - RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} - shell: bash - run: | - set -euo pipefail - - deploy_args=(-a "${APP_NAME}") - if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then - deploy_args+=("${RELEASE_PHASE_FLAG}") - fi - deploy_args+=(--org "${CPLN_ORG}" --verbose) - - cpflow deploy-image "${deploy_args[@]}" - - - name: Retrieve app URL - if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - id: workload - working-directory: app - shell: bash - run: | - set -euo pipefail - workload_name="${PRIMARY_WORKLOAD:-rails}" - workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')" - echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT" - - - name: Finalize deployment status - if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') - uses: actions/github-script@v7 - env: - COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }} - DEPLOYMENT_ID: ${{ steps.init-deployment.outputs.result }} - APP_URL: ${{ steps.workload.outputs.workload_url }} - JOB_STATUS: ${{ job.status }} - with: - script: | - const commentId = Number(process.env.COMMENT_ID); - const deploymentId = process.env.DEPLOYMENT_ID; - const appUrl = process.env.APP_URL; - const success = process.env.JOB_STATUS === "success"; - - if (deploymentId) { - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: Number(deploymentId), - state: success ? "success" : "failure", - environment: `review/${process.env.APP_NAME}`, - environment_url: success && appUrl ? appUrl : undefined, - log_url: process.env.WORKFLOW_URL, - description: success ? "Review app ready" : "Review app deployment failed" - }); - } - - const successBody = [ - "## Review app ready", - "", - appUrl ? `[Open review app](${appUrl})` : "Review app deployed, but no endpoint URL was detected.", - "", - `[Open Control Plane console](${process.env.CONSOLE_URL})`, - `[View workflow logs](${process.env.WORKFLOW_URL})` - ].join("\n"); - - const failureBody = [ - `❌ Review app deployment failed for PR #${process.env.PR_NUMBER}`, - "", - `[Open Control Plane console](${process.env.CONSOLE_URL})`, - `[View workflow logs](${process.env.WORKFLOW_URL})` - ].join("\n"); - - if (!Number.isFinite(commentId) || commentId <= 0) { - core.warning("Skipping PR comment update because no comment id was created."); - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId, - body: success ? successBody : failureBody - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c + with: + control_plane_flow_ref: 8e9c0c5e9991ac8651ae2721830bf5231f34de5c + secrets: inherit diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index 076145d6..d2923a7e 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -14,129 +14,10 @@ on: permissions: contents: read -env: - APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - STAGING_APP_BRANCH: ${{ vars.STAGING_APP_BRANCH }} - -concurrency: - group: cpflow-deploy-staging-${{ github.ref_name }} - # Match the review-app and delete workflows: a cancelled `cpflow deploy-image` mid-rollout - # can leave the staging GVC in a partially-deployed state (some workloads on the new image, - # others on the old). Let an in-flight deploy finish before the next push starts a new run. - cancel-in-progress: false - jobs: - validate-branch: - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - is_deployable: ${{ steps.check-branch.outputs.is_deployable }} - steps: - - name: Check whether this branch should deploy staging - id: check-branch - shell: bash - run: | - set -euo pipefail - - if [[ -n "${STAGING_APP_BRANCH}" ]]; then - if [[ "${GITHUB_REF_NAME}" == "${STAGING_APP_BRANCH}" ]]; then - echo "is_deployable=true" >> "$GITHUB_OUTPUT" - else - echo "Branch '${GITHUB_REF_NAME}' does not match STAGING_APP_BRANCH='${STAGING_APP_BRANCH}'" - echo "is_deployable=false" >> "$GITHUB_OUTPUT" - fi - elif [[ "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "master" ]]; then - echo "is_deployable=true" >> "$GITHUB_OUTPUT" - else - echo "Branch '${GITHUB_REF_NAME}' is not main/master and no STAGING_APP_BRANCH is configured" - echo "is_deployable=false" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout repository - if: steps.check-branch.outputs.is_deployable == 'true' - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Validate required secrets and variables - if: steps.check-branch.outputs.is_deployable == 'true' - uses: ./.github/actions/cpflow-validate-config - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - with: - required: | - secret:CPLN_TOKEN_STAGING - variable:CPLN_ORG_STAGING - variable:STAGING_APP_NAME - - build: - needs: validate-branch - if: needs.validate-branch.outputs.is_deployable == 'true' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup environment - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - - name: Build Docker image - uses: ./.github/actions/cpflow-build-docker-image - with: - app_name: ${{ env.APP_NAME }} - org: ${{ vars.CPLN_ORG_STAGING }} - commit: ${{ github.sha }} - docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }} - docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }} - docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }} - - deploy: - needs: [validate-branch, build] - if: needs.validate-branch.outputs.is_deployable == 'true' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup environment - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - - name: Detect release phase support - id: release-phase - uses: ./.github/actions/cpflow-detect-release-phase - with: - app_name: ${{ env.APP_NAME }} - - - name: Deploy staging image - env: - RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} - shell: bash - run: | - set -euo pipefail - - deploy_args=(-a "${APP_NAME}") - if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then - deploy_args+=("${RELEASE_PHASE_FLAG}") - fi - deploy_args+=(--org "${CPLN_ORG}" --verbose) - - cpflow deploy-image "${deploy_args[@]}" + deploy-staging: + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c + with: + control_plane_flow_ref: 8e9c0c5e9991ac8651ae2721830bf5231f34de5c + staging_app_branch_default: "" + secrets: inherit diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml index 0818dfb2..cdc6d470 100644 --- a/.github/workflows/cpflow-help-command.yml +++ b/.github/workflows/cpflow-help-command.yml @@ -17,42 +17,10 @@ permissions: jobs: help: - # Comment-triggered runs are gated on author_association so only repo - # owners/members/collaborators can invoke them. workflow_dispatch is - # intentionally not gated here: GitHub already restricts manual dispatches - # to users with `actions: write` (write access to the repo), which is a - # stricter bar than COLLABORATOR. if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(fromJson('["+review-app-help","+review-app-help\n","+review-app-help\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - # Help only reads `.github/cpflow-help.md`; no git push happens, so drop the - # GITHUB_TOKEN credential helper to keep the token out of .git/config. - persist-credentials: false - - - name: Post help message - uses: actions/github-script@v7 - with: - script: | - const fs = require("fs"); - const helpText = fs.readFileSync(".github/cpflow-help.md", "utf8"); - - const prNumber = context.eventName === "workflow_dispatch" - ? Number(context.payload.inputs.pr_number) - : context.issue.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: helpText - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index dc43d401..60c95596 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -9,482 +9,12 @@ on: type: string permissions: - contents: read - -env: - # Override these by editing this file or by setting the matching repository variable. - # Worst-case wall time per attempt is HEALTH_CHECK_INTERVAL plus the curl --max-time below - # (10s), so the defaults give a ~10 minute window (24 × (15 + 10) = 600s) — enough for - # most Rails cold boots (asset precompile + db:migrate + workload readiness). - HEALTH_CHECK_RETRIES: 24 - HEALTH_CHECK_INTERVAL: 15 - # Space-separated list of HTTP statuses considered healthy. The default accepts 301/302 - # because `curl` is invoked without `-L`, so a root `/` that redirects to a login page - # (common for Rails apps that auth-gate `/`) would otherwise be reported as unhealthy - # despite the workload itself being up. - # - # Strongly recommended: expose a dedicated `/health` endpoint that returns `200` and set - # HEALTH_CHECK_ACCEPTED_STATUSES to `"200"` in repository variables. The 301/302 default - # trades correctness for ergonomics — a maintenance-mode redirect or an auth-gate redirect - # to a login page can pass this check even when the underlying app is broken. Override - # via the HEALTH_CHECK_ACCEPTED_STATUSES repo variable to tighten this for apps that - # expose a dedicated health endpoint (e.g. "200" for a plain /health, or "200 401 403" - # for apps that auth-gate / without redirecting). - HEALTH_CHECK_ACCEPTED_STATUSES: ${{ vars.HEALTH_CHECK_ACCEPTED_STATUSES || '200 301 302' }} - ROLLBACK_READINESS_RETRIES: 24 - ROLLBACK_READINESS_INTERVAL: 15 - PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} - -concurrency: - # Single global group: only one production promotion may run at a time across the - # whole repo. Independent of staging deploys and review-app workflows (different - # GVCs / different concurrency keys), so those can still run in parallel. - group: cpflow-promote-staging-to-production - # Don't cancel an in-flight promotion: a half-finished `cpflow deploy-image` plus a - # rollback can leave production in a worse state than letting the first run finish. - cancel-in-progress: false + contents: write jobs: promote-to-production: if: github.event.inputs.confirm_promotion == 'promote' - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Validate required secrets and variables - uses: ./.github/actions/cpflow-validate-config - # Pass secrets via env so the composite action checks indirect shell - # variables instead of interpolating secret values into a run script. - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - with: - required: | - secret:CPLN_TOKEN_STAGING - secret:CPLN_TOKEN_PRODUCTION - variable:CPLN_ORG_STAGING - variable:CPLN_ORG_PRODUCTION - variable:STAGING_APP_NAME - variable:PRODUCTION_APP_NAME - - - name: Setup production environment - uses: ./.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - - # Runs after Setup production environment so the pinned Ruby (>= 3.1) is on PATH. - # YAML.load_file(..., aliases: true) is not supported on Ruby 3.0 (system Ruby on ubuntu-22.04). - - name: Resolve production app workloads - id: workloads - env: - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - shell: bash - run: | - set -euo pipefail - - workloads="$(ruby - "${PRODUCTION_APP_NAME}" <<'RUBY' - require "yaml" - - app = ARGV.fetch(0) - 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 PRODUCTION_APP_NAME repository variable or add the app to controlplane.yml." - exit 1 - end - - workloads = Array(app_config["app_workloads"]) - workloads = ["rails"] if workloads.empty? - puts workloads.join(",") - RUBY - )" - - echo "names=${workloads}" >> "$GITHUB_OUTPUT" - - - name: Detect release phase support - id: release-phase - uses: ./.github/actions/cpflow-detect-release-phase - with: - app_name: ${{ vars.PRODUCTION_APP_NAME }} - - - name: Verify production environment variables - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - shell: bash - run: | - set -euo pipefail - - staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" - production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" - - if [[ -z "${staging_vars}" ]]; then - echo "Staging GVC exposes no environment variables; skipping parity check." - exit 0 - fi - - # Treat staging as the promotion source of truth: fail when a variable - # present in staging is missing in production. Production-only variables - # are allowed, but surface them so teams can spot drift. - missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - - if [[ -n "${production_only_vars}" ]]; then - echo "::warning::Production has environment variables that are not present in staging:" - echo "${production_only_vars}" - fi - - if [[ -n "${missing_vars}" ]]; then - echo "::error::Production is missing environment variables that exist in staging" - echo "${missing_vars}" - exit 1 - fi - - - name: Capture current production image - id: capture-current - env: - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} - shell: bash - run: | - set -euo pipefail - - selected_workload="${PRIMARY_WORKLOAD:-}" - selected_image="" - selected_version="" - first_image="" - first_version="" - rollback_state='{}' - - while IFS= read -r workload_name; do - [[ -n "${workload_name}" ]] || continue - - workload_json="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" - workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image')" - workload_containers="$(echo "${workload_json}" | jq -c '.spec.containers | map({name, image})')" - workload_version="$(echo "${workload_json}" | jq -r '.version')" - - if [[ -z "${first_image}" ]]; then - first_image="${workload_image}" - first_version="${workload_version}" - fi - - if [[ -n "${selected_workload}" && "${workload_name}" == "${selected_workload}" ]]; then - selected_image="${workload_image}" - selected_version="${workload_version}" - fi - - rollback_state="$( - jq -c \ - --arg workload "${workload_name}" \ - --arg image "${workload_image}" \ - --arg version "${workload_version}" \ - --argjson containers "${workload_containers}" \ - '. + {($workload): {image: $image, version: $version, containers: $containers}}' \ - <<< "${rollback_state}" - )" - done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - - current_image="${selected_image:-${first_image}}" - current_version="${selected_version:-${first_version}}" - - echo "current_image=${current_image}" >> "$GITHUB_OUTPUT" - echo "current_version=${current_version}" >> "$GITHUB_OUTPUT" - # Randomize the heredoc delimiter so a stray "EOF" line inside rollback_state can't terminate it early. - delim="EOF_$(openssl rand -hex 8)" - { - echo "rollback_state<<${delim}" - echo "${rollback_state}" - echo "${delim}" - } >> "$GITHUB_OUTPUT" - - - name: Capture deployed staging image - id: staging-image - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} - shell: bash - run: | - set -euo pipefail - - selected_workload="${PRIMARY_WORKLOAD:-}" - selected_image="" - first_image="" - - while IFS= read -r workload_name; do - [[ -n "${workload_name}" ]] || continue - - workload_json="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln workload get "${workload_name}" --gvc "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json)" - workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image // empty')" - - if [[ -z "${workload_image}" ]]; then - echo "::error::Could not find an image on staging workload '${workload_name}'." >&2 - exit 1 - fi - - if [[ -z "${first_image}" ]]; then - first_image="${workload_image}" - fi - - if [[ -n "${selected_workload}" && "${workload_name}" == "${selected_workload}" ]]; then - selected_image="${workload_image}" - fi - done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - - staging_image_ref="${selected_image:-${first_image}}" - if [[ -z "${staging_image_ref}" ]]; then - echo "::error::Could not determine the deployed staging image." >&2 - exit 1 - fi - - if [[ "${staging_image_ref}" == /org/*/image/* ]]; then - staging_image="${staging_image_ref##*/image/}" - elif [[ "${staging_image_ref}" == *.registry.cpln.io/* ]]; then - staging_image="${staging_image_ref#*.registry.cpln.io/}" - else - staging_image="${staging_image_ref}" - fi - - echo "image=${staging_image}" >> "$GITHUB_OUTPUT" - - - name: Copy image from staging - env: - # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc//cmdline. - CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - STAGING_IMAGE: ${{ steps.staging-image.outputs.image }} - shell: bash - run: | - set -euo pipefail - cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" --image "${STAGING_IMAGE}" - - - name: Deploy image to production - env: - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} - shell: bash - run: | - set -euo pipefail - - deploy_args=(-a "${PRODUCTION_APP_NAME}") - if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then - deploy_args+=("${RELEASE_PHASE_FLAG}") - fi - deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) - - cpflow deploy-image "${deploy_args[@]}" - - - name: Wait for deployment health - id: health-check - uses: ./.github/actions/cpflow-wait-for-health - with: - workload_name: ${{ env.PRIMARY_WORKLOAD || 'rails' }} - app_name: ${{ vars.PRODUCTION_APP_NAME }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} - max_retries: ${{ env.HEALTH_CHECK_RETRIES }} - interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }} - accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} - - - name: Roll back on failure - if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' - env: - ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - shell: bash - run: | - # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end - # if any failed. A single cpln hiccup shouldn't leave other workloads mid-promotion. - set -uo pipefail - - rollback_failures=0 - if ! rollback_entries="$(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.containers | @json)"')"; then - echo "::error::Could not parse rollback state; manual recovery may be required." >&2 - exit 1 - fi - - while IFS=$'\t' read -r workload_name previous_containers; do - rollback_args=() - if ! current_names="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -c '.spec.containers | map(.name)')"; then - echo "::warning::Could not retrieve current containers for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - if ! previous_names="$(echo "${previous_containers}" | jq -c 'map(.name)')"; then - echo "::warning::Could not parse captured containers for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - if [[ "$(echo "${current_names}" | jq -c 'sort')" != "$(echo "${previous_names}" | jq -c 'sort')" ]]; then - echo "::error::Container set changed for workload '${workload_name}'; refusing rollback." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - if ! rollback_container_entries="$( - jq -r \ - --argjson current_names "${current_names}" \ - '.[] as $container | ($current_names | index($container.name)) as $index | "\($index)\t\($container.image)"' \ - <<< "${previous_containers}" - )"; then - echo "::warning::Could not build rollback image list for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - while IFS=$'\t' read -r index image; do - rollback_args+=(--set "spec.containers[${index}].image=${image}") - done <<< "${rollback_container_entries}" - - if ! cpln workload update "${workload_name}" \ - --gvc "${PRODUCTION_APP_NAME}" \ - --org "${CPLN_ORG_PRODUCTION}" \ - "${rollback_args[@]}"; then - echo "::warning::Rollback failed for workload '${workload_name}'; continuing with remaining workloads." >&2 - rollback_failures=$((rollback_failures + 1)) - fi - done <<< "${rollback_entries}" - - if [[ "${rollback_failures}" -gt 0 ]]; then - echo "::error::${rollback_failures} workload(s) failed to roll back; inspect the logs above." >&2 - exit 1 - fi - - - name: Wait for rollback readiness - if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' - env: - ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - shell: bash - run: | - set -euo pipefail - - mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') - - # Poll workloads in parallel so the worst-case wall time during a - # production incident is `retries × interval` rather than scaling - # linearly with the number of workloads. Each per-workload retry - # loop runs in a backgrounded subshell that writes its final state - # to a status file; the parent waits for all of them before - # aggregating warnings, keeping output ordered and deterministic. - status_dir="$(mktemp -d)" - trap 'rm -rf "${status_dir}"' EXIT - - pids=() - for workload_name in "${workloads[@]}"; do - [[ -n "${workload_name}" ]] || continue - - echo "Polling rollback readiness for workload '${workload_name}'..." - ( - set -euo pipefail - ready=false - for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do - deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')" - if [[ "${deployment_ready}" == "true" ]]; then - ready=true - break - fi - - if [[ "${attempt}" -lt "${ROLLBACK_READINESS_RETRIES}" ]]; then - sleep "${ROLLBACK_READINESS_INTERVAL}" - fi - done - - if [[ "${ready}" == "true" ]]; then - printf 'ready\n' > "${status_dir}/${workload_name}" - else - printf 'not_ready\n' > "${status_dir}/${workload_name}" - fi - ) & - pids+=("$!") - done - - # `|| true` so a single workload that fails to poll (e.g. transient - # cpln API error) doesn't abort the parent before the others finish. - # Missing or non-`ready` status files are surfaced in the aggregation - # loop below, so the failure is still visible to operators. - for pid in "${pids[@]}"; do - wait "${pid}" || true - done - - for workload_name in "${workloads[@]}"; do - [[ -n "${workload_name}" ]] || continue - status_file="${status_dir}/${workload_name}" - if [[ ! -f "${status_file}" ]] || [[ "$(<"${status_file}")" != "ready" ]]; then - echo "::warning::Workload '${workload_name}' did not report ready after rollback." - fi - done - - - name: Promotion summary - if: always() - env: - HEALTHY: ${{ steps.health-check.outputs.healthy }} - PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} - PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }} - shell: bash - run: | - { - echo "## Promotion Summary" - echo - if [[ "${HEALTHY}" == "true" ]]; then - echo "✅ Status: deployment successful" - else - echo "❌ Status: deployment failed" - fi - echo - echo "Previous image: \`${PREVIOUS_IMAGE}\`" - echo "Previous version: ${PREVIOUS_VERSION}" - } >> "$GITHUB_STEP_SUMMARY" - - create-github-release: - needs: promote-to-production - if: needs.promote-to-production.result == 'success' - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Create GitHub release - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_RUN_ID: ${{ github.run_id }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - shell: bash - run: | - set -euo pipefail - - release_date="$(date '+%Y-%m-%d')" - timestamp="$(date '+%H%M%S')" - release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}" - - gh release create "${release_tag}" \ - --title "Production Release ${release_date} ${timestamp}" \ - --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}." + uses: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c + with: + control_plane_flow_ref: 8e9c0c5e9991ac8651ae2721830bf5231f34de5c + secrets: inherit diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml index f590ea60..f05f1a88 100644 --- a/.github/workflows/cpflow-review-app-help.yml +++ b/.github/workflows/cpflow-review-app-help.yml @@ -3,8 +3,8 @@ name: Show Review App Commands on PR Open on: # pull_request_target is intentional: it has write permission to comment on PRs from # forks, where `pull_request` would be read-only. This is safe because no PR code is - # checked out — the job only calls `actions/github-script` with a hardcoded message. - # Do not switch this to `pull_request` or add a checkout step without re-evaluating. + # checked out — the job only calls the upstream reusable workflow with a hardcoded + # message. pull_request_target: types: [opened] @@ -14,30 +14,5 @@ permissions: jobs: show-help: - # Skip on PRs in repos that have not configured the cpflow review app flow yet, - # so this workflow does not noisily comment on every contributor PR. Once the - # repository sets `vars.REVIEW_APP_PREFIX`, the help message starts appearing. if: vars.REVIEW_APP_PREFIX != '' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Post quick reference - uses: actions/github-script@v7 - with: - script: | - const body = [ - "# Review app commands", - "", - "- `+review-app-deploy` - create or redeploy this PR's review app.", - "- `+review-app-delete` - delete this PR's review app and temporary resources.", - "- `+review-app-help` - show setup details and workflow behavior.", - "", - "For setup details, comment `+review-app-help`." - ].join("\n"); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@8e9c0c5e9991ac8651ae2721830bf5231f34de5c