diff --git a/.controlplane/docs/testing-cpflow-github-actions.md b/.controlplane/docs/testing-cpflow-github-actions.md index 259e078f3..8d19ec039 100644 --- a/.controlplane/docs/testing-cpflow-github-actions.md +++ b/.controlplane/docs/testing-cpflow-github-actions.md @@ -33,15 +33,31 @@ 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 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 +`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 @@ -49,9 +65,9 @@ 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 -changes to other caller cohorts. +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..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: @@ -156,11 +160,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 +564,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 @@ -633,9 +638,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..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 @@ -141,8 +142,10 @@ 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 -**5.2.0** cohort until a separately scoped migration. +**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. 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 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..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 @@ -131,9 +130,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..22cf06f4c 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 = "5.3.0" - SHA = "b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6" + VERSION = "6.0.0" + SHA = "68b5d83152c02a70eb98ffdc7fd2072e28265fef" 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,24 @@ 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 d88de86e9..fea5de801 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 @@ -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) @@ -77,9 +78,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,18 +103,42 @@ 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 + 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) @@ -224,14 +249,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 +270,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