From 1e696d48fc3b2126c2efe268d30f1bb7a41b06dd Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Thu, 8 Oct 2026 23:50:26 -1000 Subject: [PATCH 1/5] Upgrade cpflow to 6.0.0 for review apps Move the cpflow gem and the review-app deploy and delete callers to 6.0.0, pinned to the v6.0.0 commit. From 6.0.0 those workflows run the composite actions checked in under .github/actions/cpflow-*, so replace the three stale copies with the eight actions the 6.0.0 gem generates. Staging, cleanup, help and production promotion stay on their reviewed v5.2.0 pin, and the customized validator is kept. Adapt the action security specs to the 6.0.0 step layout. Co-Authored-By: Claude Opus 5.5 --- .../docs/testing-cpflow-github-actions.md | 15 +- .controlplane/readme.md | 8 +- .controlplane/shakacode-team.md | 2 +- .../cpflow-build-docker-image/action.yml | 239 ++++++++++++++--- .../action.yml | 7 +- .../delete-app.sh | 10 +- .../cpflow-detect-release-phase/action.yml | 69 +++++ .../cpflow-preview-promotion/action.yml | 82 ++++++ .../preview-promotion.sh | 90 +++++++ .../cpflow-resolve-review-config/action.yml | 137 ++++++++++ .../cpflow-setup-environment/action.yml | 250 ++++++++++++++++-- .../actions/cpflow-validate-config/action.yml | 85 ++++++ .../actions/cpflow-wait-for-health/action.yml | 171 ++++++++++++ .github/cpflow-help.md | 7 +- .github/testing-github-actions.md | 2 +- .../workflows/cpflow-delete-review-app.yml | 2 +- .../workflows/cpflow-deploy-review-app.yml | 3 +- Gemfile | 2 +- Gemfile.lock | 4 +- bin/check-cpflow-review-app-contract | 4 +- spec/cpflow_review_app_contract_spec.rb | 112 +++++--- 21 files changed, 1176 insertions(+), 125 deletions(-) create mode 100644 .github/actions/cpflow-detect-release-phase/action.yml create mode 100644 .github/actions/cpflow-preview-promotion/action.yml create mode 100755 .github/actions/cpflow-preview-promotion/preview-promotion.sh create mode 100644 .github/actions/cpflow-resolve-review-config/action.yml create mode 100644 .github/actions/cpflow-validate-config/action.yml create mode 100644 .github/actions/cpflow-wait-for-health/action.yml diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index 259e078f3..6f36dac0d 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -33,15 +33,20 @@ helper separately when validating workflow changes. ## Released Review-App Pair -The deploy/delete callers and local CLI use cpflow **5.3.0**. Both callers pin -`b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6` with a readable `v5.3.0` +The deploy/delete callers and local CLI use cpflow **6.0.0**. Both callers pin +`68b5d83152c02a70eb98ffdc7fd2072e28265fef` with a readable `v6.0.0` 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 -5.3.0 gem's caller templates as its source of truth, without loading cpflow code. +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 older +cohort still loads its actions from its own pinned upstream source. Leave `CPFLOW_VERSION` unset for these SHA-pinned callers: upstream builds the CLI from the same pinned source. The variable-based RubyGems override requires @@ -49,8 +54,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, validator release constants, and fixture -tests together. Preserve the renderer assertions and separately review any +callers, the local dependency/lockfile, the checked-in local actions, 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 7bed8fa8b..efce2903c 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -633,9 +633,11 @@ 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 `v5.3.0` at - `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`. The bundled `cpflow` gem and - lockfile use `5.3.0`. +- 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. - 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 7d0f695e4..96a9e296d 100644 --- a/.controlplane/shakacode-team.md +++ b/.controlplane/shakacode-team.md @@ -141,7 +141,7 @@ 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 -**5.3.0**; other generated callers and promotion actions retain their reviewed +**6.0.0**; 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 diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml index 5b62a5546..606d0458f 100644 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -17,43 +17,190 @@ inputs: 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 - -outputs: - image_tag: - description: Fully qualified image tag - value: ${{ steps.build.outputs.image_tag }} + 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 - id: build shell: bash env: APP_NAME: ${{ inputs.app_name }} - COMMIT: ${{ inputs.commit }} + 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 }} - DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }} - DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }} - ORG: ${{ inputs.org }} 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 + if [[ -n "${PR_NUMBER}" ]]; then PR_INFO=" for PR #${PR_NUMBER}" fi - if [[ -n "$DOCKER_BUILD_EXTRA_ARGS" ]]; then + if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then while IFS= read -r arg; do arg="${arg%$'\r'}" [[ -n "${arg}" ]] || continue @@ -65,35 +212,51 @@ runs: fi docker_build_args+=("${arg}") - done <<< "$DOCKER_BUILD_EXTRA_ARGS" + done <<< "${DOCKER_BUILD_EXTRA_ARGS}" fi - if [[ -n "$DOCKER_BUILD_SSH_KEY" ]]; then - 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 - cat <<'EOF' > ~/.ssh/known_hosts - 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= - EOF - fi - - chmod 600 ~/.ssh/known_hosts - - eval "$(ssh-agent -s)" - trap 'ssh-agent -k >/dev/null' EXIT - ssh-add - <<< "$DOCKER_BUILD_SSH_KEY" - unset DOCKER_BUILD_SSH_KEY + if [[ "${build_ssh_prepped}" == "true" ]]; then docker_build_args+=("--ssh=default") fi - echo "๐Ÿ—๏ธ Building Docker image${PR_INFO} (commit ${COMMIT})..." - cpflow build-image -a "$APP_NAME" --commit="$COMMIT" --org="$ORG" "${docker_build_args[@]}" + 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 - image_tag="${ORG}/${APP_NAME}:${COMMIT}" - echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" - echo "โœ… Docker image build successful${PR_INFO} (commit ${COMMIT})" + 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 index f011907f1..b65bb9f91 100644 --- a/.github/actions/cpflow-delete-control-plane-app/action.yml +++ b/.github/actions/cpflow-delete-control-plane-app/action.yml @@ -11,13 +11,18 @@ inputs: 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 - run: '"$ACTION_PATH/delete-app.sh"' + working-directory: ${{ inputs.working_directory }} + run: "${ACTION_PATH}/delete-app.sh" env: ACTION_PATH: ${{ github.action_path }} APP_NAME: ${{ inputs.app_name }} diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh index d1d55ba7f..9220b6eac 100755 --- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -28,14 +28,18 @@ case "$exists_status" in 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 - printf '%s\n' "$exists_output" >&2 - exit 1 + if [[ -n "$exists_output" ]]; then + printf '%s\n' "$exists_output" >&2 + fi + exit "$exists_status" ;; esac diff --git a/.github/actions/cpflow-detect-release-phase/action.yml b/.github/actions/cpflow-detect-release-phase/action.yml new file mode 100644 index 000000000..d235b15f6 --- /dev/null +++ b/.github/actions/cpflow-detect-release-phase/action.yml @@ -0,0 +1,69 @@ +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 new file mode 100644 index 000000000..235ab06d1 --- /dev/null +++ b/.github/actions/cpflow-preview-promotion/action.yml @@ -0,0 +1,82 @@ +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 new file mode 100755 index 000000000..2a07a75f6 --- /dev/null +++ b/.github/actions/cpflow-preview-promotion/preview-promotion.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This job has only the staging token. Production provenance comes from the +# successful production-* release record and is checked against the live image +# by the protected deployment job after approval. +export GH_HOST="${GITHUB_SERVER_URL#https://}" +export GH_ENTERPRISE_TOKEN="${GH_TOKEN:-}" +workload_json="$(cpln workload get "$PRIMARY_WORKLOAD" --gvc "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" -o json)" +staging_image="$(jq -r '.spec.containers[0].image // empty' <<< "$workload_json")" +if [[ "$staging_image" == /org/*/image/* ]]; then + staging_image="${staging_image##*/image/}" +elif [[ "$staging_image" == *.registry.cpln.io/* ]]; then + staging_image="${staging_image#*.registry.cpln.io/}" +fi +staging_tag="${staging_image%%@*}" +staging_commit="${staging_tag##*_}" +if [[ "$staging_image" == *$'\n'* || "$staging_image" == *$'\r'* ]] || ! [[ "$staging_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::The deployed staging image must include a full lowercase commit SHA suffix." + exit 1 +fi +echo "staging_image=$staging_image" >> "$GITHUB_OUTPUT" +{ + echo "## Commits ready for production" + echo + echo "Preview captured before production approval. Baseline: the last recorded successful production release; live production is verified after approval." + echo +} >> "$GITHUB_STEP_SUMMARY" + +# Limit release discovery to the latest 100 entries. Missing provenance is +# explicit rather than falling back to a moving branch or an unrelated release. +if ! releases="$(timeout 30s gh api "repos/${GH_REPO}/releases?per_page=100")"; then + echo "Comparison unavailable: GitHub could not read the production release history." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi +production_release="$(jq -r '[.[] | select(.draft == false and .prerelease == false) | select(.tag_name | test("^production-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}-[0-9]+$"))] | sort_by(.published_at // "") | reverse | .[0].tag_name // empty' <<< "$releases")" +if [[ -z "$production_release" ]]; then + echo "Comparison unavailable: no successful production release was found in the latest 100 releases." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi +# Resolve the tag itself: target_commitish can be a branch name on older releases. +if ! production_json="$(timeout 30s gh api "repos/${GH_REPO}/commits/${production_release}")"; then + echo "Comparison unavailable: GitHub could not resolve the recorded production release." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi +production_commit="$(jq -r '.sha // empty' <<< "$production_json")" +if ! [[ "$production_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Comparison unavailable: the recorded production release has no full commit SHA." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi +echo "production_commit=$production_commit" >> "$GITHUB_OUTPUT" + +repository_url="${GITHUB_SERVER_URL}/${GH_REPO}" +echo "[Recorded production release](${repository_url}/releases/tag/${production_release})" >> "$GITHUB_STEP_SUMMARY" +{ + echo "Recorded production release: [\`${production_commit:0:7}\`](${repository_url}/commit/${production_commit})" + echo "Staging: [\`${staging_commit:0:7}\`](${repository_url}/commit/${staging_commit})" + echo + echo "[Compare production to staging](${repository_url}/compare/${production_commit}...${staging_commit})" + echo +} >> "$GITHUB_STEP_SUMMARY" +if [[ "${production_commit}" == "${staging_commit}" ]]; then + echo "Production and staging use the same commit; there are no commits to promote." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi + +comparison="$(mktemp)" +trap 'rm -f "$comparison" "${comparison}.summary"' EXIT +# One bounded page; the compare link provides the complete diff and history. +if ! timeout 30s gh api "repos/${GH_REPO}/compare/${production_commit}...${staging_commit}?per_page=100&page=1" > "$comparison"; then + echo "Commit list unavailable: GitHub could not compare these deployed commits. Use the compare link above." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi + +if ! jq -r --arg repository_url "$repository_url" ' + "\(.ahead_by) commit(s) on staging that are not in the recorded production release.", "", + (if .status == "diverged" or .status == "behind" then + "Warning: staging is \(.status); production has \(.behind_by) commit(s) that are not on staging. Review the comparison before treating this as a forward promotion." + else empty end), "", + "", "", + (if .ahead_by > 100 then "Showing the first 100 commits; use the compare link for the complete list." else empty end) +' "$comparison" > "${comparison}.summary"; then + echo "Commit list unavailable: GitHub returned incomplete comparison data." >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi +cat "${comparison}.summary" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/cpflow-resolve-review-config/action.yml b/.github/actions/cpflow-resolve-review-config/action.yml new file mode 100644 index 000000000..b5404d373 --- /dev/null +++ b/.github/actions/cpflow-resolve-review-config/action.yml @@ -0,0 +1,137 @@ +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 index 24a2f7b21..9ed686ff3 100644 --- a/.github/actions/cpflow-setup-environment/action.yml +++ b/.github/actions/cpflow-setup-environment/action.yml @@ -1,5 +1,5 @@ name: Setup Control Plane Environment -description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile +description: Sets up Ruby, installs the Control Plane CLI and cpflow, and configures a default profile inputs: token: @@ -9,49 +9,251 @@ inputs: description: Control Plane organization required: true ruby_version: - description: Ruby version used for cpflow + 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: "3.4.6" + 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" + 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: "3.3.1" + default: "" cpflow_version: - description: cpflow gem 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: "5.1.1" + 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: ${{ inputs.ruby_version }} + 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 - sudo npm install -g "@controlplane/cli@$CPLN_CLI_VERSION" + # 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 - gem install cpflow -v "$CPFLOW_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: - TOKEN: ${{ inputs.token }} + # 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 "$TOKEN" ]]; then + if [[ -z "$CPLN_TOKEN" ]]; then echo "Error: Control Plane token not provided" >&2 exit 1 fi @@ -61,13 +263,19 @@ runs: exit 1 fi - create_output="" - if ! create_output="$(cpln profile create default --token "$TOKEN" --org "$ORG" 2>&1)"; then - if ! echo "$create_output" | grep -qi "already exists"; then - echo "$create_output" >&2 - exit 1 - fi - fi - - cpln profile update default --org "$ORG" --token "$TOKEN" + # `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 new file mode 100644 index 000000000..925013899 --- /dev/null +++ b/.github/actions/cpflow-validate-config/action.yml @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..21141a482 --- /dev/null +++ b/.github/actions/cpflow-wait-for-health/action.yml @@ -0,0 +1,171 @@ +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 dceebf06c..ceff0d2ba 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -131,9 +131,10 @@ production org, using production-only secrets and values. This repo uses immutable release SHAs and deliberately separates two cohorts: -- Review-app deploy/delete callers: `v5.3.0` at - `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`, with the local `cpflow` gem - and lockfile on `5.3.0`. Upgrade the two callers together. +- 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. - 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 9c5697d45..f61cc4c61 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 **5.3.0** together; the other +The review-app deploy/delete pair uses released **6.0.0** 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 9c127f38f..7a6d44543 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@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 # v5.3.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@68b5d83152c02a70eb98ffdc7fd2072e28265fef # v6.0.0 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 a9ae8a628..abf7243ce 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -39,7 +39,8 @@ 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@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 # v5.3.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@68b5d83152c02a70eb98ffdc7fd2072e28265fef # v6.0.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + REVIEW_APP_DOCKER_BUILD_SECRETS: ${{ secrets.REVIEW_APP_DOCKER_BUILD_SECRETS }} DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/Gemfile b/Gemfile index 65cd9a51c..23044f0fb 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.4.6" -gem "cpflow", "5.3.0", require: false +gem "cpflow", "6.0.0", 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 f6da13a2f..ae8b758ad 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 (5.3.0) + cpflow (6.0.0) dotenv (~> 3.1) jwt (~> 3.1) psych (~> 5.2) @@ -520,7 +520,7 @@ DEPENDENCIES capybara-screenshot coffee-rails coveralls_reborn (~> 0.25.0) - cpflow (= 5.3.0) + cpflow (= 6.0.0) 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 f9a92c91d..37530104e 100755 --- a/bin/check-cpflow-review-app-contract +++ b/bin/check-cpflow-review-app-contract @@ -8,8 +8,8 @@ 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 = "5.3.0" - SHA = "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" + VERSION = "6.0.0" + SHA = "68b5d83152c02a70eb98ffdc7fd2072e28265fef" CALLERS = { "deploy" => "deploy", "delete" => "delete-review-app" }.freeze def self.check(root) diff --git a/spec/cpflow_review_app_contract_spec.rb b/spec/cpflow_review_app_contract_spec.rb index d88de86e9..55d75b657 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) { "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" } + let(:release_sha) { "68b5d83152c02a70eb98ffdc7fd2072e28265fef" } 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: v5.3.0") + expect(output).to include("review-app release contract: v6.0.0") 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: v5.3.0") + expect(output).to include("review-app release contract: v6.0.0") end end @@ -77,9 +77,9 @@ end [ - ["a moving ref", "deploy", "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6", "main", "released workflow ref"], - ["a mismatched pair", "delete", "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6", "a" * 40, "released workflow ref"], - ["a stale release comment", "delete", "# v5.3.0", "# v5.2.0", "release comment"], + ["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"], ["missing dispatch permission", "deploy", " actions: write\n", "", "permissions"], ["missing deletion permission", "delete", " deployments: write\n", "", "permissions"], ["excessive permissions", "deploy", " contents: read", " contents: write", "permissions"], @@ -102,14 +102,14 @@ it "rejects an out-of-date local CLI dependency" do path = File.join(fixture, "Gemfile") - File.write(path, File.read(path).sub('gem "cpflow", "5.3.0"', 'gem "cpflow", "5.2.0"')) + File.write(path, File.read(path).sub('gem "cpflow", "6.0.0"', '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 (5.3.0)", " cpflow (5.2.0)")) + File.write(path, File.read(path).sub(" cpflow (6.0.0)", " cpflow (5.2.0)")) expect(CpflowReviewAppContract.check(fixture)).to include(a_string_including("Gemfile.lock")) end @@ -224,14 +224,17 @@ def workflow(name) FileUtils.remove_entry_secure(fixture) end - def run_step(step, inputs = {}) + 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]) + inputs.fetch(match[1], "") end - env = { "PATH" => fixture, "FIXTURE_ROOT" => fixture }.merge(bindings) + 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")) @@ -242,55 +245,80 @@ def arguments_for(command) File.binread(File.join(fixture, "#{command}.args")).split("\0") end - it "passes version inputs as literal single arguments without shell evaluation" do - step = setup_steps.fetch(1) + 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(step, "cpln_cli_version" => hostile_input, "cpflow_version" => hostile_input) + 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", "-g", "@controlplane/cli@#{hostile_input}"]) - expect(arguments_for("gem")).to eq(["install", "cpflow", "-v", hostile_input]) + 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 "passes profile inputs literally without shell evaluation" do - expect(run_step(setup_steps.fetch(2), "token" => hostile_input, "org" => hostile_input).success?).to be(true) - expected_arguments = [ - "profile", "create", "default", "--token", hostile_input, "--org", hostile_input, - "profile", "update", "default", "--org", hostile_input, "--token", hostile_input, - "image", "docker-login", "--org", hostile_input - ] - expect(arguments_for("cpln")).to eq(expected_arguments) + 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(2), inputs).success?).to be(false) + expect(run_step(setup_steps.fetch(3), inputs).success?).to be(false) expect(Dir[File.join(fixture, "*.args")]).to be_empty end end - it "quotes the action path before executing the delete script" do - action = YAML.safe_load(File.read(File.join(root, - ".github/actions/cpflow-delete-control-plane-app/action.yml"))) - step = action.fetch("runs").fetch("steps").fetch(0) - action_path = File.join(fixture, "action $(printf injected)") - 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) - - expect(step.fetch("env").fetch("ACTION_PATH")).to eq("${{ github.action_path }}") - 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) + 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 - expect(status.success?).to be(true) - expect(File.read(File.join(fixture, "called"))).to eq("called") - expect(File).not_to exist(File.join(fixture, "injected")) + 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 From 4e978d7f7181ec9f23418b1bd344d1618f5c8e08 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Thu, 8 Oct 2026 23:54:28 -1000 Subject: [PATCH 2/5] Check that local cpflow actions match the release The 6.0.0 review-app callers run the composite actions checked in under .github/actions/cpflow-*. Make the contract check fail when those files differ from the bundled gem, and document the trust boundary and the variables that override pull request config. Co-Authored-By: Claude Opus 5.5 --- .../docs/testing-cpflow-github-actions.md | 14 +++++++++-- .controlplane/shakacode-team.md | 4 +++- bin/check-cpflow-review-app-contract | 23 ++++++++++++++++--- spec/cpflow_review_app_contract_spec.rb | 15 ++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index 6f36dac0d..49433349b 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -45,8 +45,18 @@ single divergent canary is rejected. The offline validator reads the installed 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 older -cohort still loads its actions from its own pinned upstream source. +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. + +GitHub checks these actions out at the same revision as the caller workflow +file, so they carry the caller's trust and not the pull request's application +code. 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. Leave `CPFLOW_VERSION` unset for these SHA-pinned callers: upstream builds the CLI from the same pinned source. The variable-based RubyGems override requires diff --git a/.controlplane/shakacode-team.md b/.controlplane/shakacode-team.md index 96a9e296d..ed408917e 100644 --- a/.controlplane/shakacode-team.md +++ b/.controlplane/shakacode-team.md @@ -142,7 +142,9 @@ 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. +**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. 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/bin/check-cpflow-review-app-contract b/bin/check-cpflow-review-app-contract index 37530104e..bae360b21 100755 --- a/bin/check-cpflow-review-app-contract +++ b/bin/check-cpflow-review-app-contract @@ -13,9 +13,9 @@ module CpflowReviewAppContract CALLERS = { "deploy" => "deploy", "delete" => "delete-review-app" }.freeze def self.check(root) - template_root = File.join(Gem::Specification.find_by_name("cpflow", VERSION).full_gem_path, - "lib/github_flow_templates") - errors = dependency_errors(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) CALLERS.each do |operation, job_name| path = ".github/workflows/cpflow-#{operation}-review-app.yml" source = File.read(File.join(root, path)) @@ -41,6 +41,23 @@ 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)) + 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 55d75b657..b4c22fd35 100644 --- a/spec/cpflow_review_app_contract_spec.rb +++ b/spec/cpflow_review_app_contract_spec.rb @@ -65,6 +65,7 @@ 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) @@ -114,6 +115,20 @@ 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 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) From 6abdacc61b5020c68261829b91889a756c8685eb Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 9 Oct 2026 00:14:24 -1000 Subject: [PATCH 3/5] Require executable action scripts and keep review variables set Make the contract check fail when a released action script loses its executable bit. Correct the setup docs that said to leave REVIEW_APP_PREFIX unset: this repo sets it and CPLN_ORG_STAGING so review apps never take them from a pull request's controlplane.yml. Co-Authored-By: Claude Opus 5.5 --- .controlplane/readme.md | 13 +++++++------ .controlplane/shakacode-team.md | 19 ++++++++++--------- bin/check-cpflow-review-app-contract | 1 + spec/cpflow_review_app_contract_spec.rb | 10 ++++++++++ 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index efce2903c..d1409f05f 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -156,11 +156,12 @@ production or long-lived staging secret dictionaries for review apps. ### Advanced Overrides -Most repos should leave these unset. They exist so forks and clones can test -against their own Control Plane org, prefix, workload, or toolchain: +This repository sets `CPLN_ORG_STAGING` and +`REVIEW_APP_PREFIX=qa-react-webpack-rails-tutorial`; keep both set so review +apps never take their org or prefix from a pull request's +`.controlplane/controlplane.yml`. Leave the rest unset unless a fork or clone +needs its own workload or toolchain: -- `CPLN_ORG_STAGING` -- `REVIEW_APP_PREFIX` - `PRIMARY_WORKLOAD` - `REVIEW_APP_DEPLOYING_ICON_URL` - `CPLN_CLI_VERSION` @@ -559,8 +560,8 @@ waits for Control Plane to report both `status.ready` and `status.readyLatest` before probing the public endpoint. The GitHub settings and Control Plane resources must match the app names in -`.controlplane/controlplane.yml`. For the standard review-app path, leave -`REVIEW_APP_PREFIX` unset and let the workflow infer +`.controlplane/controlplane.yml`. Keep the repository variable +`REVIEW_APP_PREFIX` set to `qa-react-webpack-rails-tutorial`; generated review apps are named `qa-react-webpack-rails-tutorial-`. If you have older review apps from the previous diff --git a/.controlplane/shakacode-team.md b/.controlplane/shakacode-team.md index ed408917e..2b03a10e1 100644 --- a/.controlplane/shakacode-team.md +++ b/.controlplane/shakacode-team.md @@ -13,10 +13,10 @@ Deployments are handled by Control Plane configuration in this repo and GitHub A ### Review Apps - Add a comment `+review-app-deploy` to any PR to deploy a review app -- Leave `REVIEW_APP_PREFIX` unset for the standard path. The workflow infers - `qa-react-webpack-rails-tutorial` from `.controlplane/controlplane.yml`, so - generated review apps use names like - `qa-react-webpack-rails-tutorial-1234`. +- Keep the repository variable `REVIEW_APP_PREFIX` set to + `qa-react-webpack-rails-tutorial`, so the prefix never comes from a pull + request's `.controlplane/controlplane.yml`. Generated review apps use names + like `qa-react-webpack-rails-tutorial-1234`. - New pushes to a PR redeploy only after the review app already exists. - Add `+review-app-delete` to delete a review app manually; closing the PR also deletes it automatically. Use `+review-app-help` for the command reference. @@ -58,11 +58,12 @@ Required repository variables for staging deploys: - `STAGING_APP_NAME=react-webpack-rails-tutorial-staging` - `STAGING_APP_BRANCH=master` -Review apps infer `CPLN_ORG_STAGING`, `REVIEW_APP_PREFIX`, and -`PRIMARY_WORKLOAD` from `.controlplane/controlplane.yml` and workflow defaults, -so those values do not need to be set just to test review apps. Set them only -when testing a fork or clone against a different Control Plane org, review-app -prefix, or public workload. +Review apps also need `REVIEW_APP_PREFIX=qa-react-webpack-rails-tutorial` and +the `CPLN_ORG_STAGING` value above. Both are set in this repository and must +stay set: without them the workflow infers the prefix and org from the pull +request's `.controlplane/controlplane.yml`. `PRIMARY_WORKLOAD` can stay unset. +Change these values only when testing a fork or clone against a different +Control Plane org, review-app prefix, or public workload. App secret dictionaries for review, staging, and production must include `SECRET_KEY_BASE`, `RENDERER_PASSWORD`, `ROLLING_DEPLOY_TOKEN`, and diff --git a/bin/check-cpflow-review-app-contract b/bin/check-cpflow-review-app-contract index bae360b21..22cf06f4c 100755 --- a/bin/check-cpflow-review-app-contract +++ b/bin/check-cpflow-review-app-contract @@ -54,6 +54,7 @@ module CpflowReviewAppContract 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 diff --git a/spec/cpflow_review_app_contract_spec.rb b/spec/cpflow_review_app_contract_spec.rb index b4c22fd35..fea5de801 100644 --- a/spec/cpflow_review_app_contract_spec.rb +++ b/spec/cpflow_review_app_contract_spec.rb @@ -122,6 +122,16 @@ 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")) From d0ce475ba483194493a8cae8a816bca704ca86fa Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 9 Oct 2026 00:16:35 -1000 Subject: [PATCH 4/5] Attribute the local action revision to the release workflow Say which checkout step supplies the local cpflow actions instead of implying GitHub resolves them on its own. Co-Authored-By: Claude Opus 5.5 --- .controlplane/docs/testing-cpflow-github-actions.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index 49433349b..c6a392544 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -49,9 +49,10 @@ 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. -GitHub checks these actions out at the same revision as the caller workflow -file, so they carry the caller's trust and not the pull request's application -code. The repository variables `REVIEW_APP_PREFIX` and `CPLN_ORG_STAGING` must +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 `REVIEW_APP_DOCKER_BUILD_SECRETS` secret, which this repo does not define. A From 4fe30da24c3791a42f41d84c1853d824e1f4757b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 9 Oct 2026 00:48:37 -1000 Subject: [PATCH 5/5] List the required review-app variables in the setup docs The readme and the PR help text still said no review-app variables are needed. Name REVIEW_APP_PREFIX and CPLN_ORG_STAGING as required and rewrap two long lines in the cpflow testing guide. Co-Authored-By: Claude Opus 5.5 --- .../docs/testing-cpflow-github-actions.md | 10 +++++----- .controlplane/readme.md | 14 +++++++++----- .github/cpflow-help.md | 7 +++---- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index c6a392544..8d19ec039 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -52,9 +52,9 @@ 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 +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 `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. @@ -66,8 +66,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 and separately review any -changes to other caller cohorts. +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 d1409f05f..4c6b905f6 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -40,11 +40,15 @@ heads because Docker builds use repository secrets; if a forked change needs a review app, first move the reviewed change to a trusted branch in this repository. -No review-app repository variables are required for the standard path. The -workflow infers `qa-react-webpack-rails-tutorial` and -`shakacode-open-source-examples-staging` from `.controlplane/controlplane.yml`, -because that file has one app with `match_if_app_name_starts_with: true`. -`PRIMARY_WORKLOAD` also stays unset because the public workload is `rails`. +Review apps need two repository variables. Without them the workflow infers +the prefix and org from the pull request's `.controlplane/controlplane.yml`: + +| Name | Value | +| --- | --- | +| `REVIEW_APP_PREFIX` | `qa-react-webpack-rails-tutorial` | +| `CPLN_ORG_STAGING` | `shakacode-open-source-examples-staging` | + +`PRIMARY_WORKLOAD` stays unset because the public workload is `rails`. For staging auto-deploys, also set these repository variables: diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index ceff0d2ba..cd3f74959 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -29,10 +29,9 @@ heads because Docker builds use repository secrets. If a forked change needs a review app, first move the reviewed change to a trusted branch in this repository. -No repository variables are required for the standard review-app path when -`.controlplane/controlplane.yml` has exactly one review app entry with -`match_if_app_name_starts_with: true`. cpflow infers the review-app prefix and -staging org from that config. +This repository sets the `REVIEW_APP_PREFIX` and `CPLN_ORG_STAGING` repository +variables. Keep both set: without them cpflow infers the review-app prefix and +staging org from the pull request's `.controlplane/controlplane.yml`. Review apps run pull request code. Any value mounted through `cpln://secret/...` can be read by that code after the workload starts, so keep