diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md
index 8d19ec039..c60d39356 100644
--- a/.controlplane/docs/testing-cpflow-github-actions.md
+++ b/.controlplane/docs/testing-cpflow-github-actions.md
@@ -33,28 +33,23 @@ helper separately when validating workflow changes.
## Released Review-App Pair
-The deploy/delete callers and local CLI use cpflow **6.0.0**. Both callers pin
-`68b5d83152c02a70eb98ffdc7fd2072e28265fef` with a readable `v6.0.0`
+The deploy/delete callers and local CLI use cpflow **6.0.1**. Both callers pin
+`6aa575780f2d9175755881b79cbb3253adbc9c1f` with a readable `v6.0.1`
comment. Other generated callers intentionally remain on their existing
**5.2.0** cohort; their migration is separate scope.
The pair is inseparable: both need the generated authenticated redispatch
input, exact run/job names, and matching permissions. A pin-only upgrade or a
single divergent canary is rejected. The offline validator reads the installed
-6.0.0 gem's caller templates as its source of truth, without loading cpflow code.
-
-From 6.0.0 the pair runs the composite actions checked in under
-`.github/actions/cpflow-*` instead of the copies in the upstream checkout. Keep
-those directories identical to the bundled gem's `.github/actions`; the contract
-check fails when they differ. The older cohort still loads its actions from its
-own pinned upstream source.
-
-The released 6.0.0 workflow checks this repository out at the triggering
-event's own revision before it runs these actions, and checks the pull
-request's application code out separately under `app/`. The actions therefore
-carry the caller workflow's trust, not the application code's. The repository
-variables `REVIEW_APP_PREFIX` and `CPLN_ORG_STAGING` must stay set: they take
-precedence over the prefix and org in the pull request's `controlplane.yml`. The deploy caller also maps the optional
+6.0.1 gem's caller templates as its source of truth, without loading cpflow code.
+
+Both cohorts load cpflow's composite actions from their own pinned upstream
+source, so this repository checks in no `.github/actions/cpflow-*` copies.
+cpflow 6.0.0 briefly required such copies; 6.0.1 removed that requirement.
+
+The repository variables `REVIEW_APP_PREFIX` and `CPLN_ORG_STAGING` must stay
+set: they take precedence over the prefix and org in the pull request's
+`controlplane.yml`. The deploy caller also maps the optional
`REVIEW_APP_DOCKER_BUILD_SECRETS` secret, which this repo does not define. A
pull request's Dockerfile can read it during the build, so only ever store
review-grade values there.
@@ -65,8 +60,8 @@ a release-tag ref and cannot be combined with a commit SHA.
Do not run the all-wrapper pin helper or blindly regenerate over the customized
validation script for this split migration. A future upgrade must update both
-callers, the local dependency/lockfile, the checked-in local actions, validator
-release constants, and fixture tests together. Preserve the renderer assertions
+callers, the local dependency/lockfile, validator release constants, and fixture
+tests together. Preserve the renderer assertions
and separately review any changes to other caller cohorts.
## Review App Canary
diff --git a/.controlplane/readme.md b/.controlplane/readme.md
index 4c6b905f6..7043f44a0 100644
--- a/.controlplane/readme.md
+++ b/.controlplane/readme.md
@@ -6,7 +6,7 @@ _If you need a free demo account for Control Plane (no CC required), you can con
---
-Check [how the `cpflow` gem is used in the generated GitHub Actions flow](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.github/actions/cpflow-build-docker-image/action.yml).
+Check [how the `cpflow` gem is used in the generated GitHub Actions flow](https://github.com/shakacode/control-plane-flow/blob/v6.0.1/.github/actions/cpflow-build-docker-image/action.yml).
Here is a brief [video overview](https://www.youtube.com/watch?v=llaQoAV_6Iw).
---
@@ -638,11 +638,9 @@ Keep the reusable-workflow mechanics in the upstream
[`control-plane-flow` CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v5.2.0/docs/ci-automation.md).
This repo deliberately keeps two release cohorts:
-- The review-app deploy/delete callers pin cpflow `v6.0.0` at
- `68b5d83152c02a70eb98ffdc7fd2072e28265fef`. The bundled `cpflow` gem and
- lockfile use `6.0.0`. From 6.0.0 these workflows run the composite actions
- checked in under `.github/actions/cpflow-*`, so those files must match the
- bundled gem.
+- The review-app deploy/delete callers pin cpflow `v6.0.1` at
+ `6aa575780f2d9175755881b79cbb3253adbc9c1f`. The bundled `cpflow` gem and
+ lockfile use `6.0.1`.
- Staging, cleanup, help, and promotion retain `v5.2.0` at
`1d1ec7f7af181c5c6cf07f512ce336dbdb367246`. Their migration is separate
scope, not an automatic consequence of updating the local gem.
diff --git a/.controlplane/shakacode-team.md b/.controlplane/shakacode-team.md
index 2b03a10e1..83638901f 100644
--- a/.controlplane/shakacode-team.md
+++ b/.controlplane/shakacode-team.md
@@ -142,10 +142,8 @@ filter in `.github/workflows/cpflow-deploy-staging.yml`.
Keep upstream workflow and action references on reviewed, immutable full commit
SHAs with readable release comments, not release tags, `main`, or feature
branches. The review-app deploy/delete pair advances together on released
-**6.0.0**; other generated callers and promotion actions retain their reviewed
-**5.2.0** cohort until a separately scoped migration. From 6.0.0 the pair runs
-the composite actions checked in under `.github/actions/cpflow-*`; keep them
-identical to the bundled gem.
+**6.0.1**; other generated callers and promotion actions retain their reviewed
+**5.2.0** cohort until a separately scoped migration.
Do not blanket-regenerate the automation or use the all-wrapper pin helper for
this split migration. Preserve the customized validator and renderer assertions,
diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml
deleted file mode 100644
index 606d0458f..000000000
--- a/.github/actions/cpflow-build-docker-image/action.yml
+++ /dev/null
@@ -1,262 +0,0 @@
-name: Build Docker Image
-description: Builds and pushes the app image for a Control Plane workload
-
-inputs:
- app_name:
- description: Name of the application
- required: true
- org:
- description: Control Plane organization name
- required: true
- commit:
- description: Commit SHA to tag the image with
- required: true
- pr_number:
- description: Pull request number for status messaging
- required: false
- docker_build_extra_args:
- description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar.
- required: false
- docker_build_secrets:
- description: Optional newline-delimited id=value secrets, passed through BuildKit secret mounts.
- required: false
- docker_build_ssh_key:
- description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
- required: false
- docker_build_ssh_known_hosts:
- description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys.
- required: false
- working_directory:
- description: Directory containing the app .controlplane config and Docker build context
- required: false
- default: "."
-
-runs:
- using: composite
- steps:
- - name: Prepare Docker build secrets
- id: build-secrets
- if: ${{ inputs.docker_build_secrets != '' }}
- shell: bash
- env:
- DOCKER_BUILD_SECRETS: ${{ inputs.docker_build_secrets }}
- run: |
- set -euo pipefail
- umask 077
- secret_directory="$(mktemp -d "${RUNNER_TEMP}/cpflow-build-secrets.XXXXXX")"
- secrets_ready=false
- trap 'if [[ "${secrets_ready}" != "true" ]]; then rm -rf "${secret_directory}"; fi' EXIT
- while IFS= read -r entry || [[ -n "${entry}" ]]; do
- entry="${entry%$'\r'}"
- [[ -n "${entry}" ]] || continue
- if [[ "${entry}" != *=* ]]; then
- echo "Docker build secrets must use id=value entries." >&2
- exit 1
- fi
- secret_id="${entry%%=*}"
- secret_value="${entry#*=}"
- if [[ ! "${secret_id}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || [[ -z "${secret_value}" ]]; then
- echo "Docker build secrets require a valid id and a nonempty value." >&2
- exit 1
- fi
- if [[ -e "${secret_directory}/${secret_id}" ]]; then
- echo "Duplicate Docker build secret id." >&2
- exit 1
- fi
- # GitHub masks the stored id=value entry; register the extracted value too.
- masked_value="${secret_value//%/%25}"
- masked_value="${masked_value//$'\r'/%0D}"
- masked_value="${masked_value//$'\n'/%0A}"
- printf '::add-mask::%s\n' "${masked_value}"
- printf '%s' "${secret_value}" > "${secret_directory}/${secret_id}"
- done <<< "${DOCKER_BUILD_SECRETS}"
- echo "directory=${secret_directory}" >> "${GITHUB_OUTPUT}"
- secrets_ready=true
-
- # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present
- # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any
- # command runs, so keeping the key out of env there avoids even admin-triggered exposure.
- - name: Prepare SSH agent for Docker build
- id: prepare-build-ssh
- if: ${{ inputs.docker_build_ssh_key != '' }}
- shell: bash
- env:
- # Pass the key via env so ssh-add can read it from stdin without persisting it to
- # disk. Scope is this step only โ the build step below receives the agent socket,
- # not DOCKER_BUILD_SSH_KEY.
- DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }}
- DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }}
- run: |
- set -euo pipefail
-
- umask 077
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
-
- known_hosts_backup="${HOME}/.ssh/cpflow_known_hosts_backup"
- if [[ -e "${known_hosts_backup}" ]]; then
- echo "Refusing to replace SSH known_hosts: a prior cpflow backup exists at ${known_hosts_backup}." >&2
- echo "If no build is currently running on this runner, restore or remove that backup, then retry." >&2
- exit 1
- fi
-
- known_hosts_backup_ready=false
- known_hosts_replaced=false
- ssh_agent_started=false
- restore_known_hosts_on_prep_failure() {
- status=$?
- if [[ "${status}" -ne 0 ]]; then
- if [[ "${ssh_agent_started}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- fi
- if [[ "${known_hosts_backup_ready}" == "true" ]]; then
- mv -f -- "${known_hosts_backup}" "${HOME}/.ssh/known_hosts"
- else
- rm -f "${known_hosts_backup}"
- if [[ "${known_hosts_replaced}" == "true" ]]; then
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
- fi
- exit "${status}"
- }
- trap restore_known_hosts_on_prep_failure EXIT
-
- if [[ -L "${HOME}/.ssh/known_hosts" ]]; then
- echo "Refusing to replace symlinked SSH known_hosts." >&2
- exit 1
- fi
- if [[ -e "${HOME}/.ssh/known_hosts" ]]; then
- if [[ ! -f "${HOME}/.ssh/known_hosts" ]]; then
- echo "Refusing to replace non-file SSH known_hosts." >&2
- exit 1
- fi
- cp -p -- "${HOME}/.ssh/known_hosts" "${known_hosts_backup}"
- known_hosts_backup_ready=true
- fi
-
- known_hosts_replaced=true
- if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then
- printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
- else
- printf '%s\n' \
- 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \
- 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \
- 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \
- > ~/.ssh/known_hosts
- fi
- chmod 600 ~/.ssh/known_hosts
-
- eval "$(ssh-agent -s)"
- ssh_agent_started=true
- printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" | ssh-add -
- {
- echo "prepared=true"
- echo "known_hosts_had_original=${known_hosts_backup_ready}"
- echo "ssh_auth_sock=${SSH_AUTH_SOCK}"
- echo "ssh_agent_pid=${SSH_AGENT_PID}"
- } >> "${GITHUB_OUTPUT}"
-
- - name: Build Docker image
- shell: bash
- env:
- APP_NAME: ${{ inputs.app_name }}
- COMMIT_SHA: ${{ inputs.commit }}
- CONTROL_PLANE_ORG: ${{ inputs.org }}
- DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
- DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
- PR_NUMBER: ${{ inputs.pr_number }}
- CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
- CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
- SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
- SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
- WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
-
- PR_INFO=""
- docker_build_args=()
- build_ssh_prepped="${CPFLOW_BUILD_SSH_PREPPED:-false}"
-
- cleanup_build_ssh() {
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
- fi
- if [[ "${build_ssh_prepped}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- # Restore a pre-existing known_hosts file on reused runners. When there was
- # no original file, remove only the file created by this action.
- if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
- mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
- else
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
- }
- trap cleanup_build_ssh EXIT
- cd "${WORKING_DIRECTORY}"
-
- if [[ -n "${PR_NUMBER}" ]]; then
- PR_INFO=" for PR #${PR_NUMBER}"
- fi
-
- if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then
- while IFS= read -r arg; do
- arg="${arg%$'\r'}"
- [[ -n "${arg}" ]] || continue
-
- if [[ "${arg}" =~ [[:space:]] ]]; then
- echo "docker_build_extra_args entries must be single docker-build tokens. " \
- "Use key=value forms like --build-arg=FOO=bar." >&2
- exit 1
- fi
-
- docker_build_args+=("${arg}")
- done <<< "${DOCKER_BUILD_EXTRA_ARGS}"
- fi
-
- if [[ "${build_ssh_prepped}" == "true" ]]; then
- docker_build_args+=("--ssh=default")
- fi
-
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- for secret_path in "${DOCKER_BUILD_SECRET_DIRECTORY}"/*; do
- [[ -f "${secret_path}" ]] || continue
- docker_build_args+=("--secret=id=${secret_path##*/},src=${secret_path}")
- done
- fi
-
- echo "๐๏ธ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..."
- cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}"
- echo "โ
Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})"
-
- - name: Clean up SSH build state
- if: ${{ always() && inputs.docker_build_ssh_key != '' }}
- shell: bash
- env:
- CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
- CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
- SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
- SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
- run: |
- set +e
-
- if [[ "${CPFLOW_BUILD_SSH_PREPPED:-false}" == "true" ]]; then
- ssh-agent -k >/dev/null || true
- if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
- mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
- elif [[ "${CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL:-}" == "false" ]]; then
- rm -f "${HOME}/.ssh/known_hosts"
- fi
- fi
-
- - name: Clean up Docker build secrets
- if: ${{ always() && inputs.docker_build_secrets != '' }}
- shell: bash
- env:
- DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
- run: |
- set -euo pipefail
- if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
- rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
- fi
diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml
deleted file mode 100644
index b65bb9f91..000000000
--- a/.github/actions/cpflow-delete-control-plane-app/action.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: Delete Control Plane App
-description: Deletes a Control Plane app and all associated resources
-
-inputs:
- app_name:
- description: Name of the application to delete
- required: true
- cpln_org:
- description: Control Plane organization name
- required: true
- review_app_prefix:
- description: Prefix used for review app names
- required: true
- working_directory:
- description: Directory containing the downstream project's .controlplane/controlplane.yml
- required: false
- default: "."
-
-runs:
- using: composite
- steps:
- - name: Delete application
- shell: bash
- working-directory: ${{ inputs.working_directory }}
- run: "${ACTION_PATH}/delete-app.sh"
- env:
- ACTION_PATH: ${{ github.action_path }}
- APP_NAME: ${{ inputs.app_name }}
- CPLN_ORG: ${{ inputs.cpln_org }}
- REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }}
diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh
deleted file mode 100755
index 9220b6eac..000000000
--- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/bin/bash
-
-set -euo pipefail
-
-: "${APP_NAME:?APP_NAME environment variable is required}"
-: "${CPLN_ORG:?CPLN_ORG environment variable is required}"
-: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}"
-
-expected_prefix="${REVIEW_APP_PREFIX}-"
-if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then
- echo "โ ERROR: refusing to delete an app outside the review app prefix" >&2
- echo "App name: $APP_NAME" >&2
- echo "Expected prefix: ${expected_prefix}" >&2
- exit 1
-fi
-
-echo "๐ Checking if application exists: $APP_NAME"
-exists_output=""
-set +e
-exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"
-exists_status=$?
-set -e
-
-case "$exists_status" in
- 0)
- ;;
- 3)
- if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output"
- fi
- echo "โ ๏ธ Application does not exist: $APP_NAME"
- echo "๐งน Checking for disposable review app resources: $APP_NAME"
- cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
- echo "โ
Finished disposable review app cleanup: $APP_NAME"
- exit 0
- ;;
- *)
- echo "โ ERROR: failed to determine whether application exists: $APP_NAME" >&2
- if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output" >&2
- fi
- exit "$exists_status"
- ;;
-esac
-
-if [[ -n "$exists_output" ]]; then
- printf '%s\n' "$exists_output"
-fi
-
-echo "๐๏ธ Deleting application: $APP_NAME"
-cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
-
-echo "โ
Successfully deleted application: $APP_NAME"
diff --git a/.github/actions/cpflow-detect-release-phase/action.yml b/.github/actions/cpflow-detect-release-phase/action.yml
deleted file mode 100644
index d235b15f6..000000000
--- a/.github/actions/cpflow-detect-release-phase/action.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Detect release phase support
-description: >-
- Inspects .controlplane/controlplane.yml for an app and emits `flag=--run-release-phase`
- when a `release_script:` is configured. Outputs an empty `flag` otherwise.
-
-inputs:
- app_name:
- description: cpflow app name to inspect
- required: true
- working_directory:
- description: Directory containing .controlplane/controlplane.yml
- required: false
- default: "."
-
-outputs:
- flag:
- description: Either `--run-release-phase` or empty
- value: ${{ steps.detect.outputs.flag }}
-
-runs:
- using: composite
- steps:
- - name: Detect release phase support
- id: detect
- shell: bash
- env:
- APP_NAME: ${{ inputs.app_name }}
- WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
- cd "${WORKING_DIRECTORY}"
-
- release_script="$(ruby - "${APP_NAME}" <<'RUBY'
- require "yaml"
-
- app_name = ARGV.fetch(0)
-
- unless File.file?(".controlplane/controlplane.yml")
- warn "Error: `.controlplane/controlplane.yml` is missing. " \
- "cpflow-detect-release-phase must be invoked from a cpflow-configured project."
- exit 1
- end
-
- data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
- apps = data["apps"] || {}
- app_config = apps[app_name]
-
- unless app_config
- app_config = apps.find do |name, config|
- config.is_a?(Hash) &&
- config["match_if_app_name_starts_with"] &&
- app_name.start_with?(name)
- end&.last
- end
-
- unless app_config.is_a?(Hash)
- warn "Error: app '#{app_name}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
- exit 1
- end
-
- puts app_config["release_script"].to_s
- RUBY
- )"
-
- if [[ -n "${release_script}" ]]; then
- echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT"
- else
- echo "flag=" >> "$GITHUB_OUTPUT"
- fi
diff --git a/.github/actions/cpflow-preview-promotion/action.yml b/.github/actions/cpflow-preview-promotion/action.yml
deleted file mode 100644
index 235ab06d1..000000000
--- a/.github/actions/cpflow-preview-promotion/action.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-name: Preview Production Promotion
-description: Compare the deployed staging image to the last recorded production release before approval
-inputs:
- staging_app_name:
- required: true
- description: Staging app to read
- staging_org:
- required: true
- description: Normalized staging org
- primary_workload:
- required: false
- default: ""
- description: Optional primary workload override
-outputs:
- primary_workload:
- description: Workload whose staging image was previewed
- value: ${{ steps.workloads.outputs.primary }}
- staging_image:
- description: Captured staging image for approval
- value: ${{ steps.preview.outputs.staging_image }}
- production_commit:
- description: Last recorded production release commit, if available
- value: ${{ steps.preview.outputs.production_commit }}
-runs:
- using: composite
- steps:
- - name: Resolve primary workload
- id: workloads
- env:
- STAGING_APP_NAME: ${{ inputs.staging_app_name }}
- PRIMARY_WORKLOAD: ${{ inputs.primary_workload }}
- shell: bash
- run: |
- set -euo pipefail
-
- ruby - "${STAGING_APP_NAME}" "${PRIMARY_WORKLOAD}" >> "$GITHUB_OUTPUT" <<'RUBY'
- require "yaml"
-
- app = ARGV.fetch(0)
- requested_primary = ARGV.fetch(1, "").to_s.strip
- data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
- apps = data["apps"] || {}
- app_config = apps[app]
-
- unless app_config
- warn "Error: app '#{app}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
- warn " Fix the STAGING_APP_NAME repository variable or add the app to controlplane.yml."
- exit 1
- end
-
- workloads = Array(app_config["app_workloads"]).map(&:to_s).reject(&:empty?)
- workloads = ["rails"] if workloads.empty?
-
- primary =
- if requested_primary.empty?
- if workloads.length == 1
- workloads.first
- elsif workloads.include?("rails")
- "rails"
- else
- warn "::error::PRIMARY_WORKLOAD is not configured and app '#{app}' has multiple workloads: #{workloads.join(', ')}."
- warn " Set the PRIMARY_WORKLOAD repository variable to one of these workloads."
- exit 1
- end
- elsif workloads.include?(requested_primary)
- requested_primary
- else
- warn "::error::PRIMARY_WORKLOAD '#{requested_primary}' is not one of: #{workloads.join(', ')}."
- exit 1
- end
-
- puts "primary=#{primary}"
- RUBY
- - name: Compare recorded production release to staging
- id: preview
- env:
- ACTION_PATH: ${{ github.action_path }}
- STAGING_APP_NAME: ${{ inputs.staging_app_name }}
- CPLN_ORG_STAGING: ${{ inputs.staging_org }}
- PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
- shell: bash
- run: bash "${ACTION_PATH}/preview-promotion.sh"
diff --git a/.github/actions/cpflow-preview-promotion/preview-promotion.sh b/.github/actions/cpflow-preview-promotion/preview-promotion.sh
deleted file mode 100755
index 2a07a75f6..000000000
--- a/.github/actions/cpflow-preview-promotion/preview-promotion.sh
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# This job has only the staging token. Production provenance comes from the
-# successful production-* release record and is checked against the live image
-# by the protected deployment job after approval.
-export GH_HOST="${GITHUB_SERVER_URL#https://}"
-export GH_ENTERPRISE_TOKEN="${GH_TOKEN:-}"
-workload_json="$(cpln workload get "$PRIMARY_WORKLOAD" --gvc "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" -o json)"
-staging_image="$(jq -r '.spec.containers[0].image // empty' <<< "$workload_json")"
-if [[ "$staging_image" == /org/*/image/* ]]; then
- staging_image="${staging_image##*/image/}"
-elif [[ "$staging_image" == *.registry.cpln.io/* ]]; then
- staging_image="${staging_image#*.registry.cpln.io/}"
-fi
-staging_tag="${staging_image%%@*}"
-staging_commit="${staging_tag##*_}"
-if [[ "$staging_image" == *$'\n'* || "$staging_image" == *$'\r'* ]] || ! [[ "$staging_commit" =~ ^[0-9a-f]{40}$ ]]; then
- echo "::error::The deployed staging image must include a full lowercase commit SHA suffix."
- exit 1
-fi
-echo "staging_image=$staging_image" >> "$GITHUB_OUTPUT"
-{
- echo "## Commits ready for production"
- echo
- echo "Preview captured before production approval. Baseline: the last recorded successful production release; live production is verified after approval."
- echo
-} >> "$GITHUB_STEP_SUMMARY"
-
-# Limit release discovery to the latest 100 entries. Missing provenance is
-# explicit rather than falling back to a moving branch or an unrelated release.
-if ! releases="$(timeout 30s gh api "repos/${GH_REPO}/releases?per_page=100")"; then
- echo "Comparison unavailable: GitHub could not read the production release history." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-production_release="$(jq -r '[.[] | select(.draft == false and .prerelease == false) | select(.tag_name | test("^production-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}-[0-9]+$"))] | sort_by(.published_at // "") | reverse | .[0].tag_name // empty' <<< "$releases")"
-if [[ -z "$production_release" ]]; then
- echo "Comparison unavailable: no successful production release was found in the latest 100 releases." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-# Resolve the tag itself: target_commitish can be a branch name on older releases.
-if ! production_json="$(timeout 30s gh api "repos/${GH_REPO}/commits/${production_release}")"; then
- echo "Comparison unavailable: GitHub could not resolve the recorded production release." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-production_commit="$(jq -r '.sha // empty' <<< "$production_json")"
-if ! [[ "$production_commit" =~ ^[0-9a-f]{40}$ ]]; then
- echo "Comparison unavailable: the recorded production release has no full commit SHA." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-echo "production_commit=$production_commit" >> "$GITHUB_OUTPUT"
-
-repository_url="${GITHUB_SERVER_URL}/${GH_REPO}"
-echo "[Recorded production release](${repository_url}/releases/tag/${production_release})" >> "$GITHUB_STEP_SUMMARY"
-{
- echo "Recorded production release: [\`${production_commit:0:7}\`](${repository_url}/commit/${production_commit})"
- echo "Staging: [\`${staging_commit:0:7}\`](${repository_url}/commit/${staging_commit})"
- echo
- echo "[Compare production to staging](${repository_url}/compare/${production_commit}...${staging_commit})"
- echo
-} >> "$GITHUB_STEP_SUMMARY"
-if [[ "${production_commit}" == "${staging_commit}" ]]; then
- echo "Production and staging use the same commit; there are no commits to promote." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-
-comparison="$(mktemp)"
-trap 'rm -f "$comparison" "${comparison}.summary"' EXIT
-# One bounded page; the compare link provides the complete diff and history.
-if ! timeout 30s gh api "repos/${GH_REPO}/compare/${production_commit}...${staging_commit}?per_page=100&page=1" > "$comparison"; then
- echo "Commit list unavailable: GitHub could not compare these deployed commits. Use the compare link above." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-
-if ! jq -r --arg repository_url "$repository_url" '
- "\(.ahead_by) commit(s) on staging that are not in the recorded production release.", "",
- (if .status == "diverged" or .status == "behind" then
- "Warning: staging is \(.status); production has \(.behind_by) commit(s) that are not on staging. Review the comparison before treating this as a forward promotion."
- else empty end), "",
- "
",
- (.commits[:100][] |
- "- \(.sha[:7]) " +
- (.commit.message | split("\n")[0] | @html) + "
"),
- "
", "",
- (if .ahead_by > 100 then "Showing the first 100 commits; use the compare link for the complete list." else empty end)
-' "$comparison" > "${comparison}.summary"; then
- echo "Commit list unavailable: GitHub returned incomplete comparison data." >> "$GITHUB_STEP_SUMMARY"
- exit 0
-fi
-cat "${comparison}.summary" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/actions/cpflow-resolve-review-config/action.yml b/.github/actions/cpflow-resolve-review-config/action.yml
deleted file mode 100644
index b5404d373..000000000
--- a/.github/actions/cpflow-resolve-review-config/action.yml
+++ /dev/null
@@ -1,137 +0,0 @@
-name: Resolve Review App Config
-description: Infers review app prefix and Control Plane org from controlplane.yml
-
-inputs:
- configured_cpln_org_staging:
- description: Optional override for the staging Control Plane org.
- required: false
- default: ""
- configured_review_app_prefix:
- description: Optional override for the review app prefix.
- required: false
- default: ""
- pr_number:
- description: Pull request number used to build the review app name.
- required: false
- default: ""
- working_directory:
- description: Directory containing .controlplane/controlplane.yml.
- required: false
- default: "."
-
-outputs:
- review_app_prefix:
- description: Resolved review app prefix.
- value: ${{ steps.resolve.outputs.review_app_prefix }}
- cpln_org:
- description: Resolved Control Plane org.
- value: ${{ steps.resolve.outputs.cpln_org }}
- app_name:
- description: Resolved review app name when pr_number is present; omitted when pr_number is empty.
- value: ${{ steps.resolve.outputs.app_name }}
-
-runs:
- using: composite
- steps:
- - name: Resolve review app config
- id: resolve
- shell: bash
- working-directory: ${{ inputs.working_directory }}
- env:
- CONFIGURED_CPLN_ORG_STAGING: ${{ inputs.configured_cpln_org_staging }}
- CONFIGURED_REVIEW_APP_PREFIX: ${{ inputs.configured_review_app_prefix }}
- PR_NUMBER: ${{ inputs.pr_number }}
- run: |
- set -euo pipefail
-
- ruby <<'RUBY'
- require "yaml"
-
- def safe_load_yaml_file(path)
- contents = File.read(path)
-
- if YAML.method(:safe_load).parameters.any? { |type, name| type == :key && name == :aliases }
- YAML.safe_load(contents, aliases: true)
- else
- YAML.safe_load(contents, [], [], true)
- end
- end
-
- def validate_github_env_value!(name, value)
- return if value.match?(/\A[A-Za-z0-9-]+\z/)
-
- warn "::error::#{name} must contain only letters, numbers, and hyphens so it is a valid Control Plane name and can be safely written to GitHub environment files."
- exit 1
- end
-
- begin
- config = safe_load_yaml_file(".controlplane/controlplane.yml")
- unless config.is_a?(Hash)
- warn "::error::.controlplane/controlplane.yml must be a YAML mapping; got #{config.class}: #{config.inspect}"
- exit 1
- end
-
- apps = config["apps"]
- unless apps.is_a?(Hash)
- warn "::error::.controlplane/controlplane.yml must define an apps mapping; got #{apps.class}: #{apps.inspect}"
- exit 1
- end
-
- review_apps = apps.select do |_name, app_config|
- app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
- end
-
- prefix = ENV.fetch("CONFIGURED_REVIEW_APP_PREFIX", "").strip
- if prefix.empty?
- if review_apps.length == 1
- prefix = review_apps.keys.first
- elsif review_apps.empty?
- warn "::error::Set REVIEW_APP_PREFIX or define exactly one app with match_if_app_name_starts_with: true in .controlplane/controlplane.yml."
- exit 1
- else
- warn "::error::Set REVIEW_APP_PREFIX because .controlplane/controlplane.yml defines multiple review app prefixes: #{review_apps.keys.sort.join(', ')}."
- exit 1
- end
- end
-
- app_config = apps[prefix]
- unless app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
- warn "::error::Review app prefix '#{prefix}' must match an app in .controlplane/controlplane.yml with match_if_app_name_starts_with: true."
- exit 1
- end
-
- cpln_org = ENV.fetch("CONFIGURED_CPLN_ORG_STAGING", "").strip
- cpln_org = app_config["cpln_org"].to_s.strip if cpln_org.empty?
- if cpln_org.empty?
- warn "::error::Set CPLN_ORG_STAGING or cpln_org for review app prefix '#{prefix}' in .controlplane/controlplane.yml."
- exit 1
- end
-
- pr_number = ENV.fetch("PR_NUMBER", "").strip
- unless pr_number.empty? || pr_number.match?(/\A[1-9][0-9]*\z/)
- warn "::error::PR_NUMBER must be a positive integer; got: #{pr_number.inspect}"
- exit 1
- end
-
- app_name = pr_number.empty? ? "" : "#{prefix}-#{pr_number}"
-
- validate_github_env_value!("REVIEW_APP_PREFIX", prefix)
- validate_github_env_value!("CPLN_ORG", cpln_org)
- validate_github_env_value!("APP_NAME", app_name) unless app_name.empty?
-
- File.open(ENV.fetch("GITHUB_ENV"), "a") do |file|
- file.puts "REVIEW_APP_PREFIX=#{prefix}"
- file.puts "CPLN_ORG=#{cpln_org}"
- file.puts "APP_NAME=#{app_name}" unless app_name.empty?
- end
-
- File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |file|
- file.puts "review_app_prefix=#{prefix}"
- file.puts "cpln_org=#{cpln_org}"
- file.puts "app_name=#{app_name}" unless app_name.empty?
- end
- rescue StandardError => e
- warn "::error::Could not resolve review app config from .controlplane/controlplane.yml: #{e.class}: #{e.message}"
- exit 1
- end
- RUBY
diff --git a/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml
deleted file mode 100644
index 9ed686ff3..000000000
--- a/.github/actions/cpflow-setup-environment/action.yml
+++ /dev/null
@@ -1,281 +0,0 @@
-name: Setup Control Plane Environment
-description: Sets up Ruby, installs the Control Plane CLI and cpflow, and configures a default profile
-
-inputs:
- token:
- description: Control Plane token
- required: true
- org:
- description: Control Plane organization
- required: true
- ruby_version:
- description: >-
- Ruby version used for cpflow. When empty (the default), ruby/setup-ruby
- auto-detects from .ruby-version, .tool-versions, mise.toml, or a Gemfile
- ruby directive, then falls back to the action's pinned default.
- required: false
- default: ""
- working_directory:
- description: Directory where ruby/setup-ruby should detect Ruby version files.
- required: false
- default: "."
- cpflow_source_directory:
- description: Directory containing the checked-out control-plane-flow source.
- required: false
- default: ".cpflow"
- # GitHub parses double-brace expression snippets inside action metadata (including
- # `description:`) while loading the composite action, and the `vars` context is not
- # available in that phase. Keep these descriptions in plain prose - reference repo
- # variables by NAME only, never with literal GitHub Actions expression syntax.
- cpln_cli_version:
- description: >-
- @controlplane/cli version. Empty string falls back to the action's pinned default,
- so callers can wire this input to the CPLN_CLI_VERSION repository variable
- unconditionally.
- required: false
- default: ""
- cpflow_version:
- description: >-
- cpflow gem version to install from RubyGems. Empty string installs cpflow from
- the checked-out control-plane-flow repository, so callers can test a GitHub ref
- without publishing a release.
- required: false
- default: ""
- control_plane_flow_ref:
- description: >-
- Full GitHub workflow ref for the control-plane-flow reusable workflow, for
- example shakacode/control-plane-flow/.github/workflows/deploy.yml@refs/tags/v5.0.1.
- When cpflow_version is set, this must point at the matching release tag.
- required: false
- default: ""
-
-runs:
- using: composite
- # Dependabot proposes action updates, but each reviewed dependency remains
- # pinned to the release commit recorded beside its exact version comment.
- steps:
- - name: Resolve Ruby setup version
- id: ruby-version
- shell: bash
- env:
- INPUT_RUBY_VERSION: ${{ inputs.ruby_version }}
- INPUT_WORKING_DIRECTORY: ${{ inputs.working_directory }}
- run: |
- set -euo pipefail
-
- ruby_version="${INPUT_RUBY_VERSION}"
- working_directory="${INPUT_WORKING_DIRECTORY:-.}"
- # Bump when the project's minimum-supported Ruby advances.
- default_ruby_version="3.2"
-
- if [[ -z "${ruby_version}" ]]; then
- if [[ -f "${working_directory}/.ruby-version" ]] ||
- { [[ -f "${working_directory}/.tool-versions" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]+" "${working_directory}/.tool-versions"; } ||
- { [[ -f "${working_directory}/mise.toml" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*=" "${working_directory}/mise.toml"; } ||
- { [[ -f "${working_directory}/.mise.toml" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*=" "${working_directory}/.mise.toml"; }; then
- : # keep empty; ruby/setup-ruby will auto-detect
- elif [[ -f "${working_directory}/Gemfile" ]] && grep -Eq "^[[:space:]]*ruby[[:space:]]*(\(|file:|['\"])" "${working_directory}/Gemfile"; then
- : # keep empty; ruby/setup-ruby will read Gemfile
- else
- ruby_version="${default_ruby_version}"
- fi
- fi
-
- echo "ruby_version=${ruby_version}" >> "$GITHUB_OUTPUT"
-
- - name: Set up Ruby
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
- with:
- ruby-version: ${{ steps.ruby-version.outputs.ruby_version }}
- working-directory: ${{ inputs.working_directory }}
-
- - name: Install Control Plane CLI and cpflow gem
- shell: bash
- env:
- CONTROL_PLANE_FLOW_REF: ${{ inputs.control_plane_flow_ref }}
- CPLN_CLI_VERSION: ${{ inputs.cpln_cli_version }}
- CPFLOW_VERSION: ${{ inputs.cpflow_version }}
- CPFLOW_SOURCE_DIR: ${{ inputs.cpflow_source_directory }}
- run: |
- set -euo pipefail
-
- # Bump this default when a new Control Plane CLI release should roll out by default.
- # Override per-repo by setting the `CPLN_CLI_VERSION` repo variable.
- default_cpln_cli_version="3.11.0"
-
- CPLN_CLI_VERSION="${CPLN_CLI_VERSION:-${default_cpln_cli_version}}"
-
- normalize_version() {
- local version="${1#v}"
- version="${version//-/.}"
-
- if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9A-Za-z]+)*$ ]]; then
- echo "${version}"
- fi
- # Empty output means an unrecognized format; callers check for that.
- }
-
- extract_ref_name() {
- local ref="${1##*@}"
- ref="${ref#refs/tags/}"
- echo "${ref}"
- }
-
- normalize_release_ref() {
- local ref
- ref="$(extract_ref_name "$1")"
-
- # Intentional: non-v refs return success with empty stdout; callers
- # treat empty output as "not a release tag".
- [[ "${ref}" == v* ]] || return 0
- normalize_version "${ref}"
- }
-
- verify_release_ref_matches_checkout() {
- local ref
- local remote_url="https://github.com/shakacode/control-plane-flow.git"
- local tag_refs
- local tag_commit=""
- local checkout_commit
-
- ref="$(extract_ref_name "$1")"
- [[ "${ref}" == v* ]] || return 1
-
- git_ls_remote_tag() {
- if command -v timeout >/dev/null 2>&1; then
- timeout 20 git ls-remote --tags "${remote_url}" "refs/tags/${ref}" "refs/tags/${ref}^{}"
- else
- git ls-remote --tags "${remote_url}" "refs/tags/${ref}" "refs/tags/${ref}^{}"
- fi
- }
-
- if ! tag_refs="$(git_ls_remote_tag 2>&1)"; then
- echo "::error::Could not verify the control-plane-flow workflow ref against ${remote_url}. Runners that set CPFLOW_VERSION need outbound HTTPS access to GitHub for this tag check. Leave CPFLOW_VERSION unset when testing a commit SHA or branch. Details: ${tag_refs}"
- exit 1
- fi
-
- if [[ -z "${tag_refs}" ]]; then
- echo "::error::CPFLOW_VERSION can only be used with an existing control-plane-flow release tag. No remote tag found for workflow ref ${CONTROL_PLANE_FLOW_REF}."
- exit 1
- fi
-
- while read -r sha tag_ref; do
- if [[ "${tag_ref}" == "refs/tags/${ref}^{}" ]]; then
- tag_commit="${sha}"
- break
- fi
-
- if [[ "${tag_ref}" == "refs/tags/${ref}" ]]; then
- tag_commit="${sha}"
- fi
- done <<< "${tag_refs}"
-
- if [[ -z "${tag_commit}" ]]; then
- echo "::error::Could not resolve the commit for release tag ${ref}."
- exit 1
- fi
-
- checkout_commit="$(git -C "${CPFLOW_SOURCE_DIR}" rev-parse HEAD)"
- if [[ "${checkout_commit}" != "${tag_commit}" ]]; then
- echo "::error::control-plane-flow workflow ref ${CONTROL_PLANE_FLOW_REF} resolved to ${checkout_commit}, but release tag ${ref} points to ${tag_commit}. Use the real release tag ref, not a moving branch, or leave CPFLOW_VERSION unset."
- exit 1
- fi
- }
-
- is_rubygems_version() {
- [[ "${1}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9A-Za-z]+)*$ ]]
- }
-
- validate_cpflow_version_pin() {
- [[ -z "${CPFLOW_VERSION}" ]] && return 0
-
- local actual_version
- local expected_version
-
- if ! is_rubygems_version "${CPFLOW_VERSION}"; then
- echo "::error::CPFLOW_VERSION must be a RubyGems version usable by 'gem install cpflow -v', such as 5.0.0 or 5.0.0.rc.1. Do not include a leading v, and use dot-separated prereleases instead of dash-separated prereleases."
- exit 1
- fi
-
- actual_version="$(normalize_version "${CPFLOW_VERSION}")"
- expected_version="$(normalize_release_ref "${CONTROL_PLANE_FLOW_REF}")"
-
- if [[ -z "${expected_version}" ]]; then
- echo "::error::CPFLOW_VERSION can only be used when the control-plane-flow reusable workflow is pinned to a release tag like v${CPFLOW_VERSION}. Dot and dash prerelease tags are accepted, for example v5.0.0.rc.1 or v5.0.0-rc.1. Current workflow ref: ${CONTROL_PLANE_FLOW_REF:-}. Leave CPFLOW_VERSION unset when testing a commit SHA or branch so cpflow is built from the same source as the reusable workflow."
- exit 1
- fi
-
- verify_release_ref_matches_checkout "${CONTROL_PLANE_FLOW_REF}"
-
- if [[ "${actual_version}" != "${expected_version}" ]]; then
- echo "::error::CPFLOW_VERSION must match the control-plane-flow reusable workflow tag. CPFLOW_VERSION=${CPFLOW_VERSION}, normalized CPFLOW_VERSION=${actual_version}, workflow ref=${CONTROL_PLANE_FLOW_REF}, expected CPFLOW_VERSION=${expected_version}."
- exit 1
- fi
- }
-
- validate_cpflow_version_pin
-
- npm_global_prefix="${HOME}/.npm-global"
- mkdir -p "${npm_global_prefix}"
- echo "${npm_global_prefix}/bin" >> "$GITHUB_PATH"
- export PATH="${npm_global_prefix}/bin:${PATH}"
-
- npm install --global --prefix "${npm_global_prefix}" "@controlplane/cli@${CPLN_CLI_VERSION}"
- cpln --version
-
- if [[ -n "${CPFLOW_VERSION}" ]]; then
- gem install cpflow -v "${CPFLOW_VERSION}" --no-document
- else
- cpflow_source_dir="$(cd "${CPFLOW_SOURCE_DIR}" && pwd)"
- if [[ ! -f "${cpflow_source_dir}/cpflow.gemspec" ]]; then
- echo "::error::CPFLOW_SOURCE_DIR (${cpflow_source_dir}) does not contain cpflow.gemspec" >&2
- exit 1
- fi
-
- cpflow_gem="$(mktemp -t cpflow-XXXXXX.gem)"
- trap 'rm -f "${cpflow_gem}"' EXIT
- (
- cd "${cpflow_source_dir}"
- gem build cpflow.gemspec --output "${cpflow_gem}"
- )
- gem install "${cpflow_gem}" --no-document
- fi
-
- cpflow --version
-
- - name: Setup Control Plane profile and registry login
- shell: bash
- env:
- # Pass the token via CPLN_TOKEN so cpln picks it up from the environment
- # rather than `--token`, which would leak it into /proc//cmdline and ps output.
- CPLN_TOKEN: ${{ inputs.token }}
- ORG: ${{ inputs.org }}
- run: |
- set -euo pipefail
-
- if [[ -z "$CPLN_TOKEN" ]]; then
- echo "Error: Control Plane token not provided" >&2
- exit 1
- fi
-
- if [[ -z "$ORG" ]]; then
- echo "Error: Control Plane organization not provided" >&2
- exit 1
- fi
-
- # `cpln profile update` lists `create` as an alias (cpln profile --help) and is
- # idempotent: it creates the profile if missing and updates it otherwise. Calling
- # update directly avoids parsing the CLI's "already exists" English error text,
- # which would silently swallow a real failure if the wording ever changed.
- cpln profile update default --org "$ORG"
- cpln image docker-login --org "$ORG"
-
- # Keep the token available to later cpflow/cpln steps without passing it
- # on the command line. GitHub masks secret values in logs, and the env file
- # itself is not echoed.
- delim="CPLN_TOKEN_DELIM_$(openssl rand -hex 8)"
- {
- echo "CPLN_TOKEN<<${delim}"
- echo "${CPLN_TOKEN}"
- echo "${delim}"
- } >> "$GITHUB_ENV"
diff --git a/.github/actions/cpflow-validate-config/action.yml b/.github/actions/cpflow-validate-config/action.yml
deleted file mode 100644
index 925013899..000000000
--- a/.github/actions/cpflow-validate-config/action.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: Validate cpflow GitHub configuration
-description: >-
- Validates that required secrets and repository variables are set before a workflow
- proceeds. Pass each value via `env:` with the same NAME as the secret or variable,
- then list the required entries in `required` as `type:NAME` pairs (type is `secret`
- or `variable`). When `pull_request_friendly: true` and the current event is a
- pull request event, missing config writes a step summary and exits 0 with
- `ready=false` instead of failing the job.
-
-inputs:
- required:
- description: |
- Newline-separated `type:NAME` pairs. Type is `secret` or `variable`. The
- caller MUST export the matching values via `env:` using the same NAME.
- required: true
- pull_request_friendly:
- description: When "true" and event is pull_request/pull_request_target, write summary and exit 0 with ready=false.
- required: false
- default: "false"
-
-outputs:
- ready:
- description: '"true" when all values are set, "false" when missing in PR-friendly mode.'
- value: ${{ steps.check.outputs.ready }}
-
-runs:
- using: composite
- steps:
- - name: Check required secrets and variables
- id: check
- shell: bash
- env:
- CPFLOW_REQUIRED: ${{ inputs.required }}
- CPFLOW_PR_FRIENDLY: ${{ inputs.pull_request_friendly }}
- CPFLOW_EVENT_NAME: ${{ github.event_name }}
- run: |
- set -euo pipefail
-
- missing=()
- while IFS= read -r entry; do
- entry="${entry%$'\r'}"
- entry="${entry## }"
- entry="${entry%% }"
- [[ -z "${entry}" ]] && continue
-
- type="${entry%%:*}"
- name="${entry#*:}"
-
- # Reject names that are not plain SHELL_VAR identifiers before doing the
- # indirect lookup below. Without this guard, ${!name} would expand whatever
- # bash nameref/transformation a hand-edited generated workflow snuck in
- # (e.g. `BASH_FUNC_foo%%`). Callers today are the generated templates, but
- # the generated file lives in the user's repo and can be hand-edited.
- if [[ ! "${name}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
- echo "Invalid config entry name: ${name}" >&2
- exit 1
- fi
-
- # Indirect bash lookup: reads the env var named by ${name} (e.g. CPLN_TOKEN_STAGING)
- # so the value never has to round-trip through workflow logs.
- if [[ -z "${!name:-}" ]]; then
- missing+=("${type}:${name}")
- fi
- done <<< "${CPFLOW_REQUIRED}"
-
- if [[ ${#missing[@]} -eq 0 ]]; then
- echo "ready=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- if [[ "${CPFLOW_PR_FRIENDLY}" == "true" && ( "${CPFLOW_EVENT_NAME}" == "pull_request" || "${CPFLOW_EVENT_NAME}" == "pull_request_target" ) ]]; then
- echo "ready=false" >> "$GITHUB_OUTPUT"
- {
- echo "Control Plane review app automation is not configured yet."
- echo
- echo "Missing required GitHub configuration:"
- printf -- '- `%s`\n' "${missing[@]}"
- echo
- echo "Pushes to this pull request will skip review app deploys until the repository is configured."
- } >> "$GITHUB_STEP_SUMMARY"
- exit 0
- fi
-
- printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2
- exit 1
diff --git a/.github/actions/cpflow-wait-for-health/action.yml b/.github/actions/cpflow-wait-for-health/action.yml
deleted file mode 100644
index 21141a482..000000000
--- a/.github/actions/cpflow-wait-for-health/action.yml
+++ /dev/null
@@ -1,171 +0,0 @@
-name: Wait for Control Plane workload health
-description: >-
- Polls Control Plane until the latest workload version is ready, then checks
- the standard workload endpoint and ready location endpoints with curl. Exits
- success when the HTTP response status is in the accepted list. Fails non-zero
- (and reports `healthy=false`) once retries are exhausted.
-
-inputs:
- workload_name:
- description: Workload to query (e.g. `rails`).
- required: true
- app_name:
- description: GVC / Control Plane app name the workload belongs to.
- required: true
- org:
- description: Control Plane organization.
- required: true
- max_retries:
- description: Number of attempts before giving up.
- required: false
- default: "24"
- interval_seconds:
- description: Seconds to sleep between attempts.
- required: false
- default: "15"
- accepted_statuses:
- description: >-
- Space-separated list of HTTP status codes considered healthy. The default
- `200 301 302` accepts redirects because curl is invoked without `-L`, so a
- root path that auth-redirects looks like a redirect, not a failure.
- required: false
- default: "200 301 302"
- curl_max_time:
- description: Per-request curl timeout, seconds.
- required: false
- default: "10"
-
-outputs:
- healthy:
- description: '"true" once a healthy response was observed; "false" otherwise.'
- value: ${{ steps.poll.outputs.healthy }}
- endpoint:
- description: The workload or location endpoint that returned an accepted status.
- value: ${{ steps.poll.outputs.endpoint }}
-
-runs:
- using: composite
- steps:
- - name: Poll workload endpoint
- id: poll
- shell: bash
- env:
- CPFLOW_WORKLOAD_NAME: ${{ inputs.workload_name }}
- CPFLOW_APP_NAME: ${{ inputs.app_name }}
- CPFLOW_ORG: ${{ inputs.org }}
- CPFLOW_MAX_RETRIES: ${{ inputs.max_retries }}
- CPFLOW_INTERVAL_SECONDS: ${{ inputs.interval_seconds }}
- CPFLOW_ACCEPTED_STATUSES: ${{ inputs.accepted_statuses }}
- CPFLOW_CURL_MAX_TIME: ${{ inputs.curl_max_time }}
- run: |
- set -euo pipefail
-
- read -r -a accepted_statuses <<< "${CPFLOW_ACCEPTED_STATUSES}"
-
- check_endpoint() {
- local endpoint="$1"
- local endpoint_type="$2"
- local http_status
-
- http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || true)"
- http_status="${http_status:-000}"
- echo "${endpoint_type}: ${endpoint}, HTTP status: ${http_status}"
-
- for accepted in "${accepted_statuses[@]}"; do
- if [[ "${http_status}" == "${accepted}" ]]; then
- {
- echo "healthy=true"
- echo "endpoint=${endpoint}"
- } >> "$GITHUB_OUTPUT"
- return 0
- fi
- done
-
- return 1
- }
-
- summarize_cpln_error() {
- local error_text="$1"
-
- case "${error_text}" in
- *401*|*403*|*[Uu]nauthorized*|*[Ff]orbidden*) echo "authorization failure" ;;
- *404*|*"not found"*|*"Not Found"*) echo "resource not found" ;;
- *429*|*"rate limit"*|*"Rate Limit"*) echo "rate limited" ;;
- *"timed out"*|*[Tt]imeout*) echo "request timeout" ;;
- *) echo "CLI error (details suppressed; ${#error_text} stderr bytes captured)" ;;
- esac
- }
-
- for attempt in $(seq 1 "${CPFLOW_MAX_RETRIES}"); do
- echo "Health check attempt ${attempt}/${CPFLOW_MAX_RETRIES}"
-
- if ! workload_json="$(cpln workload get "${CPFLOW_WORKLOAD_NAME}" --gvc "${CPFLOW_APP_NAME}" --org "${CPFLOW_ORG}" -o json 2>&1)"; then
- echo "::error::Workload '${CPFLOW_WORKLOAD_NAME}' not found in GVC '${CPFLOW_APP_NAME}'. Set PRIMARY_WORKLOAD to the correct workload name." >&2
- printf '%s\n' "${workload_json}" >&2
- echo "healthy=false" >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- workload_ready="$(echo "${workload_json}" | jq -r '.status.ready // false')"
- latest_ready="$(echo "${workload_json}" | jq -r '.status.readyLatest // false')"
- readiness_status="$(echo "${workload_json}" | jq -r '.health.readiness // "unknown"')"
- workload_endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')"
-
- if [[ "${workload_ready}" != "true" || "${latest_ready}" != "true" ]]; then
- echo "Workload status: ready=${workload_ready}, readyLatest=${latest_ready}, readiness=${readiness_status}; waiting for latest deployment."
- else
- if [[ -n "${workload_endpoint}" ]] && check_endpoint "${workload_endpoint}" "Workload endpoint"; then
- exit 0
- fi
-
- location_endpoint_count=0
- deployments_listed=false
- deployments_settled=false
- deployments_stderr="$(mktemp)"
- if deployments_json="$(cpln workload get-deployments "${CPFLOW_WORKLOAD_NAME}" --gvc "${CPFLOW_APP_NAME}" --org "${CPFLOW_ORG}" -o json 2>"${deployments_stderr}")"; then
- deployments_listed=true
- unsettled_location_count="$(
- printf '%s\n' "${deployments_json}" |
- jq '[.items[]? | select(.status.ready != true or (.status.deploying // false) == true)] | length'
- )"
-
- if [[ "${unsettled_location_count}" -gt 0 ]]; then
- echo "Deployment locations are not all ready (${unsettled_location_count} unsettled); waiting before endpoint fallback."
- else
- deployments_settled=true
- while IFS= read -r location_endpoint; do
- location_endpoint_count=$((location_endpoint_count + 1))
- if [[ -n "${location_endpoint}" &&
- "${location_endpoint}" != "${workload_endpoint}" ]] &&
- check_endpoint "${location_endpoint}" "Location endpoint"; then
- rm -f "${deployments_stderr}"
- exit 0
- fi
- done < <(
- printf '%s\n' "${deployments_json}" |
- jq -r '.items[]? | .status.endpoint // empty' |
- awk '!seen[$0]++'
- )
- fi
- else
- deployments_error="$(<"${deployments_stderr}")"
- deployments_error_summary="$(summarize_cpln_error "${deployments_error}")"
- echo "::warning::Could not list location endpoints for workload '${CPFLOW_WORKLOAD_NAME}' (${deployments_error_summary}); retrying."
- fi
- rm -f "${deployments_stderr}"
-
- if [[ -z "${workload_endpoint}" &&
- "${deployments_listed}" == "true" &&
- "${deployments_settled}" == "true" &&
- "${location_endpoint_count}" -eq 0 ]]; then
- echo "Workload '${CPFLOW_WORKLOAD_NAME}' has no endpoint yet; waiting for one to be assigned."
- fi
- fi
-
- if [[ "${attempt}" -lt "${CPFLOW_MAX_RETRIES}" ]]; then
- sleep "${CPFLOW_INTERVAL_SECONDS}"
- fi
- done
-
- echo "healthy=false" >> "$GITHUB_OUTPUT"
- exit 1
diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md
index cd3f74959..e64cef73d 100644
--- a/.github/cpflow-help.md
+++ b/.github/cpflow-help.md
@@ -130,10 +130,9 @@ production org, using production-only secrets and values.
This repo uses immutable release SHAs and deliberately separates two cohorts:
-- Review-app deploy/delete callers: `v6.0.0` at
- `68b5d83152c02a70eb98ffdc7fd2072e28265fef`, with the local `cpflow` gem
- and lockfile on `6.0.0`. Upgrade the two callers together with the
- `.github/actions/cpflow-*` actions, which these workflows run from this repo.
+- Review-app deploy/delete callers: `v6.0.1` at
+ `6aa575780f2d9175755881b79cbb3253adbc9c1f`, with the local `cpflow` gem
+ and lockfile on `6.0.1`. Upgrade the two callers together.
- Staging, cleanup, help, and promotion: `v5.2.0` at
`1d1ec7f7af181c5c6cf07f512ce336dbdb367246`. Keep this cohort unchanged
unless its migration is separately reviewed.
diff --git a/.github/testing-github-actions.md b/.github/testing-github-actions.md
index f61cc4c61..48d347204 100644
--- a/.github/testing-github-actions.md
+++ b/.github/testing-github-actions.md
@@ -29,7 +29,7 @@ guide:
workflows load their matching shared actions automatically; do not add a
duplicate ref input to downstream wrappers.
-The review-app deploy/delete pair uses released **6.0.0** together; the other
+The review-app deploy/delete pair uses released **6.0.1** together; the other
generated callers and promotion actions retain their reviewed **5.2.0** cohort.
Do not replace these pins with release tags, `main`, or feature branches. Do not
use the all-wrapper pin helper or blanket regeneration for this split migration:
diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml
index 7a6d44543..d45d0561f 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@6aa575780f2d9175755881b79cbb3253adbc9c1f # v6.0.1
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..642c0fbe8 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@6aa575780f2d9175755881b79cbb3253adbc9c1f # v6.0.1
secrets:
CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
REVIEW_APP_DOCKER_BUILD_SECRETS: ${{ secrets.REVIEW_APP_DOCKER_BUILD_SECRETS }}
diff --git a/Gemfile b/Gemfile
index 23044f0fb..10e176437 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.4.6"
-gem "cpflow", "6.0.0", require: false
+gem "cpflow", "6.0.1", require: false
gem "react_on_rails_pro", "17.2.0.rc.2"
gem "shakapacker", "10.3.2"
diff --git a/Gemfile.lock b/Gemfile.lock
index ae8b758ad..ed9300cc5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -139,7 +139,7 @@ GEM
term-ansicolor (~> 1.6)
thor (>= 0.20.3, < 2.0)
tins (~> 1.16)
- cpflow (6.0.0)
+ cpflow (6.0.1)
dotenv (~> 3.1)
jwt (~> 3.1)
psych (~> 5.2)
@@ -520,7 +520,7 @@ DEPENDENCIES
capybara-screenshot
coffee-rails
coveralls_reborn (~> 0.25.0)
- cpflow (= 6.0.0)
+ cpflow (= 6.0.1)
database_cleaner
debug (>= 1.0.0)
factory_bot_rails
diff --git a/bin/check-cpflow-review-app-contract b/bin/check-cpflow-review-app-contract
index 22cf06f4c..ea261081e 100755
--- a/bin/check-cpflow-review-app-contract
+++ b/bin/check-cpflow-review-app-contract
@@ -8,14 +8,14 @@ require "yaml"
# Compare their behavior with the installed release's authoritative templates.
# This reads files only: it neither loads cpflow application code nor contacts APIs.
module CpflowReviewAppContract
- VERSION = "6.0.0"
- SHA = "68b5d83152c02a70eb98ffdc7fd2072e28265fef"
+ VERSION = "6.0.1"
+ SHA = "6aa575780f2d9175755881b79cbb3253adbc9c1f"
CALLERS = { "deploy" => "deploy", "delete" => "delete-review-app" }.freeze
def self.check(root)
- gem_root = Gem::Specification.find_by_name("cpflow", VERSION).full_gem_path
- template_root = File.join(gem_root, "lib/github_flow_templates")
- errors = dependency_errors(root) + action_errors(root, gem_root)
+ template_root = File.join(Gem::Specification.find_by_name("cpflow", VERSION).full_gem_path,
+ "lib/github_flow_templates")
+ errors = dependency_errors(root)
CALLERS.each do |operation, job_name|
path = ".github/workflows/cpflow-#{operation}-review-app.yml"
source = File.read(File.join(root, path))
@@ -41,24 +41,6 @@ module CpflowReviewAppContract
errors
end
- # The released callers run these actions from this repository, so they must be the release's own files.
- def self.action_errors(root, gem_root)
- list = lambda do |base|
- Dir.glob(".github/actions/cpflow-*/**/*", base: base).select { |path| File.file?(File.join(base, path)) }.sort
- end
- released = list.call(gem_root)
- errors = []
- errors << ".github/actions: cpflow action files differ from release" unless list.call(root) == released
- released.each do |path|
- local = File.join(root, path)
- next unless File.file?(local)
-
- errors << "#{path}: differs from release" unless File.binread(local) == File.binread(File.join(gem_root, path))
- errors << "#{path}: must be executable" if File.executable?(File.join(gem_root, path)) && !File.executable?(local)
- end
- errors
- end
-
def self.caller_errors(path, source, template, job_name)
actual = YAML.safe_load(source)
expected = YAML.safe_load(template)
diff --git a/spec/cpflow_review_app_contract_spec.rb b/spec/cpflow_review_app_contract_spec.rb
index fea5de801..846627fb0 100644
--- a/spec/cpflow_review_app_contract_spec.rb
+++ b/spec/cpflow_review_app_contract_spec.rb
@@ -10,7 +10,7 @@
RSpec.describe "Workflow release and security contracts" do
let(:root) { File.expand_path("..", __dir__) }
- let(:release_sha) { "68b5d83152c02a70eb98ffdc7fd2072e28265fef" }
+ let(:release_sha) { "6aa575780f2d9175755881b79cbb3253adbc9c1f" }
it "moves deploy and delete together to the released source" do
%w[deploy delete].each do |operation|
@@ -27,7 +27,7 @@
File.join(root, "bin/check-cpflow-review-app-contract"))
expect(status.success?).to be(true), output
- expect(output).to include("review-app release contract: v6.0.0")
+ expect(output).to include("review-app release contract: v6.0.1")
end
it "activates an isolated bundle when plain Ruby has no inherited bundle-exec setup" do
@@ -51,7 +51,7 @@
unsetenv_others: true)
expect(status.success?).to be(true), output
- expect(output).to include("review-app release contract: v6.0.0")
+ expect(output).to include("review-app release contract: v6.0.1")
end
end
@@ -65,12 +65,15 @@
FileUtils.cp(File.join(root, path), File.join(fixture, path))
end
%w[Gemfile Gemfile.lock].each { |path| FileUtils.cp(File.join(root, path), fixture) }
- FileUtils.cp_r(File.join(root, ".github/actions"), File.join(fixture, ".github/actions"))
example.run
ensure
FileUtils.remove_entry_secure(fixture)
end
+ it "accepts the unchanged callers" do
+ expect(CpflowReviewAppContract.check(fixture)).to be_empty
+ end
+
it "rejects a missing authenticated redispatch input" do
change_fixture("deploy", " reconcile_intent_run_id:", " unrelated_input:")
@@ -78,9 +81,9 @@
end
[
- ["a moving ref", "deploy", "68b5d83152c02a70eb98ffdc7fd2072e28265fef", "main", "released workflow ref"],
- ["a mismatched pair", "delete", "68b5d83152c02a70eb98ffdc7fd2072e28265fef", "a" * 40, "released workflow ref"],
- ["a stale release comment", "delete", "# v6.0.0", "# v5.2.0", "release comment"],
+ ["a moving ref", "deploy", "6aa575780f2d9175755881b79cbb3253adbc9c1f", "main", "released workflow ref"],
+ ["a mismatched pair", "delete", "6aa575780f2d9175755881b79cbb3253adbc9c1f", "a" * 40, "released workflow ref"],
+ ["a stale release comment", "delete", "# v6.0.1", "# v5.2.0", "release comment"],
["missing dispatch permission", "deploy", " actions: write\n", "", "permissions"],
["missing deletion permission", "delete", " deployments: write\n", "", "permissions"],
["excessive permissions", "deploy", " contents: read", " contents: write", "permissions"],
@@ -103,42 +106,18 @@
it "rejects an out-of-date local CLI dependency" do
path = File.join(fixture, "Gemfile")
- File.write(path, File.read(path).sub('gem "cpflow", "6.0.0"', 'gem "cpflow", "5.2.0"'))
+ File.write(path, File.read(path).sub('gem "cpflow", "6.0.1"', 'gem "cpflow", "5.2.0"'))
expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("Gemfile"))
end
it "rejects an out-of-date lockfile resolution" do
path = File.join(fixture, "Gemfile.lock")
- File.write(path, File.read(path).sub(" cpflow (6.0.0)", " cpflow (5.2.0)"))
+ File.write(path, File.read(path).sub(" cpflow (6.0.1)", " cpflow (5.2.0)"))
expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("Gemfile.lock"))
end
- it "rejects an edited local action" do
- path = File.join(fixture, ".github/actions/cpflow-setup-environment/action.yml")
- File.write(path, "#{File.read(path)}# edited\n")
-
- expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("differs from release"))
- end
-
- it "rejects a local action script that lost its executable bit" do
- FileUtils.chmod(0o644, File.join(fixture, ".github/actions/cpflow-delete-control-plane-app/delete-app.sh"))
-
- expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("must be executable"))
- end
-
- it "accepts the unchanged local actions" do
- expect(CpflowReviewAppContract.check(fixture)).to be_empty
- end
-
- it "rejects a missing or extra local action file" do
- FileUtils.rm(File.join(fixture, ".github/actions/cpflow-wait-for-health/action.yml"))
- FileUtils.touch(File.join(fixture, ".github/actions/cpflow-setup-environment/extra.sh"))
-
- expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("action files differ"))
- end
-
def change_fixture(operation, before, after)
path = File.join(fixture, ".github/workflows/cpflow-#{operation}-review-app.yml")
source = File.read(path)
@@ -221,130 +200,5 @@ def workflow(name)
expect(job.fetch("if")).to eq("github.event.pull_request.head.repo.full_name == github.repository")
end
-
- context "with isolated composite action command stubs" do
- let(:fixture) { Dir.mktmpdir("workflow-shell-inputs") }
- let(:setup_steps) do
- YAML.safe_load(File.read(File.join(root, ".github/actions/cpflow-setup-environment/action.yml")))
- .fetch("runs").fetch("steps")
- end
- let(:hostile_input) { 'value with spaces; $(printf injected > "$FIXTURE_ROOT/injected") " end' }
-
- around do |example|
- %w[sudo npm gem cpln cpflow].each do |command|
- path = File.join(fixture, command)
- script = <<~'SHELL'
- #!/bin/bash
- set -eu
- printf '%s\0' "$@" >> "$FIXTURE_ROOT/COMMAND_NAME.args"
- if [[ "COMMAND_NAME" == sudo ]]; then
- exec "$@"
- fi
- SHELL
- File.write(path, script.gsub("COMMAND_NAME", command))
- FileUtils.chmod(0o700, path)
- end
- example.run
- ensure
- FileUtils.remove_entry_secure(fixture)
- end
-
- def run_step(step, inputs = {}, system_path: false)
- bindings = step.fetch("env", {}).transform_values do |expression|
- match = /\A\$\{\{ inputs\.([a-z_]+) \}\}\z/.match(expression)
- raise "Unexpected test input binding" unless match
-
- inputs.fetch(match[1], "")
- end
- path = system_path ? "#{fixture}:/usr/bin:/bin" : fixture
- runner_files = { "GITHUB_PATH" => File.join(fixture, "github_path"),
- "GITHUB_ENV" => File.join(fixture, "github_env") }
- env = { "PATH" => path, "FIXTURE_ROOT" => fixture, "HOME" => fixture }.merge(runner_files, bindings)
- _output, status = Open3.capture2e(env, "/bin/bash", "-c", step.fetch("run"),
- chdir: fixture, unsetenv_others: true)
- expect(File).not_to exist(File.join(fixture, "injected"))
- status
- end
-
- def arguments_for(command)
- File.binread(File.join(fixture, "#{command}.args")).split("\0")
- end
-
- it "passes the CLI version input as a literal single argument without shell evaluation" do
- source = File.join(fixture, "cpflow-source")
- FileUtils.mkdir_p(source)
- FileUtils.touch(File.join(source, "cpflow.gemspec"))
-
- status = run_step(setup_steps.fetch(2),
- { "cpln_cli_version" => hostile_input, "cpflow_source_directory" => source },
- system_path: true)
-
- expect(status.success?).to be(true)
- expect(arguments_for("npm")).to eq(
- ["install", "--global", "--prefix", File.join(fixture, ".npm-global"), "@controlplane/cli@#{hostile_input}"]
- )
- expect(arguments_for("gem").first(3)).to eq(%w[build cpflow.gemspec --output])
- expect(arguments_for("cpln")).to eq(["--version"])
- expect(arguments_for("cpflow")).to eq(["--version"])
- end
-
- it "rejects a malformed cpflow version input before invoking a command" do
- status = run_step(setup_steps.fetch(2), { "cpflow_version" => hostile_input })
-
- expect(status.success?).to be(false)
- expect(Dir[File.join(fixture, "*.args")]).to be_empty
- end
-
- it "passes the org input literally and keeps the token off the command line" do
- status = run_step(setup_steps.fetch(3), { "token" => hostile_input, "org" => hostile_input },
- system_path: true)
-
- expect(status.success?).to be(true)
- expect(arguments_for("cpln")).to eq(
- ["profile", "update", "default", "--org", hostile_input, "image", "docker-login", "--org", hostile_input]
- )
- end
-
- %w[token org].each do |missing_input|
- it "rejects an empty #{missing_input} input before invoking a command" do
- inputs = { "token" => "fixture-value", "org" => "fixture-value", missing_input => "" }
-
- expect(run_step(setup_steps.fetch(3), inputs).success?).to be(false)
- expect(Dir[File.join(fixture, "*.args")]).to be_empty
- end
- end
-
- context "with the delete action" do
- let(:step) do
- action = YAML.safe_load(File.read(File.join(root,
- ".github/actions/cpflow-delete-control-plane-app/action.yml")))
- action.fetch("runs").fetch("steps").fetch(0)
- end
-
- def run_delete(action_path)
- FileUtils.mkdir_p(action_path)
- script = File.join(action_path, "delete-app.sh")
- File.write(script, "#!/bin/bash\nprintf called > \"$FIXTURE_ROOT/called\"\n")
- FileUtils.chmod(0o700, script)
- env = { "PATH" => fixture, "FIXTURE_ROOT" => fixture, "ACTION_PATH" => action_path }
- _output, status = Open3.capture2e(env, "/bin/bash", "-c", step.fetch("run"),
- chdir: fixture, unsetenv_others: true)
- status
- end
-
- it "runs the delete script from the action path bound through the environment" do
- expect(step.fetch("env").fetch("ACTION_PATH")).to eq("${{ github.action_path }}")
- expect(run_delete(File.join(fixture, "action")).success?).to be(true)
- expect(File.read(File.join(fixture, "called"))).to eq("called")
- end
-
- it "does not evaluate shell syntax in the action path" do
- run_delete(File.join(fixture, "action $(printf injected)"))
-
- expect(File).not_to exist(File.join(fixture, "injected"))
- expect(File).not_to exist(File.join(fixture, "called"))
- end
- end
- end
end
end