diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml index 5b62a554..c5b4faf3 100644 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -23,37 +23,85 @@ inputs: 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: + # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present + # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any + # command runs, so keeping the key out of env there avoids even admin-triggered exposure. + - name: Prepare SSH agent for Docker build + if: ${{ inputs.docker_build_ssh_key != '' }} + shell: bash + env: + # Pass the key via env so the file write is a single printf call rather than a + # heredoc with a fixed terminator (a heredoc would silently truncate the key if + # any line of the key value happened to match the terminator). Scope is still + # this step only — the build step below does not receive DOCKER_BUILD_SSH_KEY. + DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }} + DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }} + run: | + set -euo pipefail + + umask 077 + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then + printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts + else + printf '%s\n' \ + 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \ + 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \ + 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \ + > ~/.ssh/known_hosts + fi + chmod 600 ~/.ssh/known_hosts + + printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" > ~/.ssh/cpflow_build_key + chmod 600 ~/.ssh/cpflow_build_key + - name: Build Docker image - id: build shell: bash env: APP_NAME: ${{ inputs.app_name }} - COMMIT: ${{ inputs.commit }} + COMMIT_SHA: ${{ inputs.commit }} + CONTROL_PLANE_ORG: ${{ inputs.org }} 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 }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} run: | set -euo pipefail PR_INFO="" docker_build_args=() + ssh_agent_started=false + build_ssh_prepped=false + + cleanup_build_ssh() { + if [[ "${ssh_agent_started}" == "true" ]]; then + ssh-agent -k >/dev/null || true + fi + rm -f "${HOME}/.ssh/cpflow_build_key" + # Only remove known_hosts if this action's prep step wrote it. On self-hosted + # or reused runners we must not touch a user-managed file we did not create, + # so the flag is set inside the same prep-detection branch below. + if [[ "${build_ssh_prepped}" == "true" ]]; then + rm -f "${HOME}/.ssh/known_hosts" + fi + } + trap cleanup_build_ssh EXIT + cd "${WORKING_DIRECTORY}" - if [[ -n "$PR_NUMBER" ]]; then + 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 +113,19 @@ 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 - + if [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then + # Mark prep-step ownership so cleanup_build_ssh only removes known_hosts + # when this action wrote it (see trap above). + build_ssh_prepped=true eval "$(ssh-agent -s)" - trap 'ssh-agent -k >/dev/null' EXIT - ssh-add - <<< "$DOCKER_BUILD_SSH_KEY" - unset DOCKER_BUILD_SSH_KEY + ssh_agent_started=true + ssh-add "${HOME}/.ssh/cpflow_build_key" docker_build_args+=("--ssh=default") fi - echo "🏗️ Building Docker image${PR_INFO} (commit ${COMMIT})..." - cpflow build-image -a "$APP_NAME" --commit="$COMMIT" --org="$ORG" "${docker_build_args[@]}" - - 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})" diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml index 63981dd5..b65bb9f9 100644 --- a/.github/actions/cpflow-delete-control-plane-app/action.yml +++ b/.github/actions/cpflow-delete-control-plane-app/action.yml @@ -11,14 +11,20 @@ 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: ${{ github.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 }} CPLN_ORG: ${{ inputs.cpln_org }} REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }} diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh index d1d55ba7..1ae19759 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,15 @@ case "$exists_status" in if [[ -n "$exists_output" ]]; then printf '%s\n' "$exists_output" fi - echo "⚠️ Application does not exist: $APP_NAME" exit 0 ;; *) echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2 - 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 00000000..d235b15f --- /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-resolve-review-config/action.yml b/.github/actions/cpflow-resolve-review-config/action.yml new file mode 100644 index 00000000..b5404d37 --- /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 0ab65963..9ed686ff 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,46 +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@v1 + 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@${{ inputs.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 ${{ inputs.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: + # 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 - TOKEN="${{ inputs.token }}" - ORG="${{ inputs.org }}" - - if [[ -z "$TOKEN" ]]; then + if [[ -z "$CPLN_TOKEN" ]]; then echo "Error: Control Plane token not provided" >&2 exit 1 fi @@ -58,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 00000000..92501389 --- /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 00000000..21141a48 --- /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 a66b58ef..ef7bc5fa 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -1,10 +1,9 @@ -# Review App Commands +# Review App Help +You asked for review app help. These commands are generated by [cpflow](https://github.com/shakacode/control-plane-flow). -For full setup, version-pinning, and troubleshooting details, see the upstream -[CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v5.2.0/docs/ci-automation.md). -## Pull Request Commands +## Review App Commands Comment with exactly one command, with no surrounding text or trailing spaces. A single trailing newline from GitHub's comment editor is accepted. @@ -15,13 +14,19 @@ A single trailing newline from GitHub's comment editor is accepted. | `+review-app-delete` | Deletes the review app. This also runs automatically when the PR closes. | | `+review-app-help` | Posts this help message on the PR. | -## Standard Setup +For complete setup, version-pinning, and troubleshooting guidance, see the upstream +[CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v6.0.0.rc.0/docs/ci-automation.md). -For the normal generated review-app path, GitHub needs one repository secret: +
+GitHub Actions setup options + +## GitHub Actions Secrets + +For the normal generated review-app path, GitHub Actions needs one secret: | Name | Where | Notes | | --- | --- | --- | -| `CPLN_TOKEN_STAGING` | Repository secret | Control Plane service-account token for the staging/review org. | +| `CPLN_TOKEN_STAGING` | GitHub Actions secret | Service-account token scoped to the staging Control Plane org on controlplane.com. | For public repositories, use a staging/review token that cannot access production Control Plane resources. Generated review-app deploys skip fork PR @@ -29,7 +34,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 +## GitHub Actions Variables + +No GitHub Actions 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. @@ -40,15 +47,6 @@ review-app secret dictionaries limited to disposable databases, review-only renderer credentials, and license values that are acceptable for review-app exposure. -This app uses a separate `node-renderer` workload for React on Rails Pro. -`deploy_order` deploys `node-renderer` first and waits for readiness before -deploying `rails` and `daily-task`. The renderer boot seed runs before the -renderer process starts, warming the cache before Rails sends SSR traffic to -the new image. - -Set `ROLLING_DEPLOY_TOKEN` in the app secret dictionary with the same care as -`RENDERER_PASSWORD`; review-app values must be review-safe. - For public demos, starter staging apps, and long-lived review apps, keep the app workload `type: standard` with one warm replica, set its autoscaling metric to `disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and @@ -63,10 +61,12 @@ Optional overrides exist for forks, clones, and unusual apps: | Name | Notes | | --- | --- | -| `CPLN_ORG_STAGING` | Override the staging/review Control Plane org inferred from `controlplane.yml`. | +| `CPLN_ORG_STAGING` | Control Plane org on controlplane.com for staging and review apps. Overrides the org inferred from `controlplane.yml`. | | `REVIEW_APP_PREFIX` | Override the review-app prefix inferred from `controlplane.yml`. | | `PRIMARY_WORKLOAD` | Public workload used for review URLs and health checks; defaults to `rails`. | +
+ ## Staging And Production Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`. @@ -127,10 +127,13 @@ gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true Before the first promotion, bootstrap the production app the same way in the production org, using production-only secrets and values. +
+Version locking and advanced options + ## Version Locking Generated wrappers pin Control Plane Flow with a release tag, for example -`v5.2.0`. Reusable review-app, staging, cleanup, and +`v6.0.0.rc.0`. Reusable review-app, staging, cleanup, and helper workflows pin the tag in their `uses:` ref. Production promotion pins the same tag in the `Checkout control-plane-flow actions` step so the caller-owned job can keep `environment: production` and receive production @@ -138,8 +141,8 @@ environment secrets directly. Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the -release tag your wrappers are pinned to: a `CPFLOW_VERSION=5.2.x` runtime -override goes with a wrapper pinned to `uses: ...@v5.2.x` (substitute the +release tag your wrappers are pinned to: a `CPFLOW_VERSION=6.0.x` runtime +override goes with a wrapper pinned to `uses: ...@v6.0.x` (substitute the release you pinned above). After updating the `cpflow` gem in this repo, update the generated wrappers in @@ -166,7 +169,7 @@ bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha> bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow ``` -## Advanced Variables +## Advanced GitHub Actions Variables Most apps do not need these: @@ -185,3 +188,5 @@ that copy the workflow before configuring Control Plane can remove `.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the wrapper-level `if:` guard shown in that file, for example `vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''`. + +
diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml index f4d9504a..992c4c68 100644 --- a/.github/workflows/cpflow-cleanup-stale-review-apps.yml +++ b/.github/workflows/cpflow-cleanup-stale-review-apps.yml @@ -12,6 +12,6 @@ jobs: cleanup: # Cleanup targets the current inferred review-app prefix. If you changed # naming conventions, manually delete review apps under the old prefix. - uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@v6.0.0.rc.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml index 53f87243..d8179df1 100644 --- a/.github/workflows/cpflow-delete-review-app.yml +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -1,5 +1,7 @@ name: Delete Review App +run-name: "Delete Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" + on: pull_request_target: types: [closed] @@ -11,9 +13,15 @@ on: description: Pull request number targeted for deletion required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write contents: read + deployments: write issues: write pull-requests: write @@ -22,6 +30,8 @@ jobs: # pull_request_target is intentional: fork PR-close events need access to # staging secrets to delete review apps and update PR comments. The upstream # reusable workflow checks out trusted base-branch action code, not fork code. + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && @@ -31,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@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@v6.0.0.rc.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 056ed624..58cc5997 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -13,15 +13,24 @@ on: description: Pull request number to deploy required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write contents: read deployments: write issues: write pull-requests: write jobs: + # The reusable job exposes `image_built`; downstream jobs can read + # `needs.deploy.outputs.image_built`. A value of `false` means this check did not validate the Docker image. deploy: + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || @@ -30,7 +39,7 @@ jobs: github.event.issue.pull_request && contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@v6.0.0.rc.0 secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index 13e2c644..e734a95b 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -16,7 +16,7 @@ permissions: jobs: deploy-staging: - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@v6.0.0.rc.0 with: staging_app_branch_default: "master" secrets: diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml index a1004606..41afdb5b 100644 --- a/.github/workflows/cpflow-help-command.yml +++ b/.github/workflows/cpflow-help-command.yml @@ -23,4 +23,4 @@ jobs: contains(fromJson('["+review-app-help","+review-app-help\n","+review-app-help\r\n"]'), github.event.comment.body) && contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || github.event_name == 'workflow_dispatch' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@v6.0.0.rc.0 diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index 1929f0a1..94560b9c 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -61,15 +61,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Checkout control-plane-flow actions - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: shakacode/control-plane-flow - ref: v5.2.0 + ref: v6.0.0.rc.0 path: .cpflow persist-credentials: false @@ -90,7 +90,7 @@ jobs: fi - name: Validate required secrets and variables - uses: ./.cpflow/.github/actions/cpflow-validate-config + uses: ./.github/actions/cpflow-validate-config # Pass secrets via env so the composite action checks indirect shell # variables instead of interpolating secret values into a run script. env: @@ -171,7 +171,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Setup production environment - uses: ./.cpflow/.github/actions/cpflow-setup-environment + uses: ./.github/actions/cpflow-setup-environment with: token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} org: ${{ steps.cpln-orgs.outputs.production }} @@ -179,7 +179,7 @@ jobs: cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} cpflow_version: ${{ vars.CPFLOW_VERSION }} # The setup action validates CPFLOW_VERSION against this full workflow ref. - control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@v5.2.0 + control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@v6.0.0.rc.0 # Runs after Setup production environment so the pinned Ruby (>= 3.1) is on PATH. # YAML.load_file(..., aliases: true) is not supported on Ruby 3.0 (system Ruby on ubuntu-22.04). @@ -237,7 +237,7 @@ jobs: - name: Detect release phase support id: release-phase - uses: ./.cpflow/.github/actions/cpflow-detect-release-phase + uses: ./.github/actions/cpflow-detect-release-phase with: app_name: ${{ vars.PRODUCTION_APP_NAME }} @@ -441,7 +441,7 @@ jobs: echo "image=${staging_image}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Copy image from staging id: copy-image @@ -589,7 +589,7 @@ jobs: - name: Wait for deployment health id: health-check - uses: ./.cpflow/.github/actions/cpflow-wait-for-health + uses: ./.github/actions/cpflow-wait-for-health with: workload_name: ${{ steps.workloads.outputs.primary }} app_name: ${{ vars.PRODUCTION_APP_NAME }} diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml index c844dad4..63b15e04 100644 --- a/.github/workflows/cpflow-review-app-help.yml +++ b/.github/workflows/cpflow-review-app-help.yml @@ -18,4 +18,4 @@ jobs: # to PR-open help. Remove it, or uncomment and adapt this guard, if forks or # clones should stay quiet until Control Plane is configured: # if: vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != '' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@v5.2.0 + uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@v6.0.0.rc.0 diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index 8ea97e0b..0a89e568 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -12,99 +12,74 @@ fi echo "==> cpflow github-flow-readiness" "${cpflow_cmd[@]}" github-flow-readiness -echo "==> check node renderer probe template" +echo "==> parse generated GitHub Actions YAML" ruby <<'RUBY' require "yaml" -template = File.read(".controlplane/templates/node-renderer.yml") -rendered = template.gsub(/\{\{[A-Z_]+\}\}/, "template-placeholder") -workload = YAML.safe_load(rendered) -spec = workload.fetch("spec") -container = spec.fetch("containers").fetch(0) - -expected_args = [ - "bash", - "-lc", - <<~'SHELL', - # The rake task warns and continues on bundle fetch misses so rollout - # does not wedge; unexpected task failures still stop the shell. - set -e - bundle exec rake react_on_rails_pro:pre_seed_renderer_cache - exec yarn node-renderer - SHELL -] -expected_ports = [{ "number" => 3800, "protocol" => "http2" }] -expected_env = [ - { "name" => "LOG_LEVEL", "value" => "info" }, - { "name" => "NODE_OPTIONS", "value" => "--max-old-space-size=256" }, -] -expected_readiness_probe = { - "tcpSocket" => { "port" => 3800 }, - "failureThreshold" => 3, - "periodSeconds" => 5, -} -expected_liveness_probe = { - "tcpSocket" => { "port" => 3800 }, - "failureThreshold" => 3, - "periodSeconds" => 10, - "initialDelaySeconds" => 120, -} -expected_default_options = { - "autoscaling" => { "maxScale" => 1 }, - "capacityAI" => true, -} -expected_firewall_config = { - "external" => { - "inboundAllowCIDR" => [], - "outboundAllowCIDR" => ["0.0.0.0/0"], - }, - "internal" => { "inboundAllowType" => "same-gvc" }, -} - -failures = [] -failures << "workload keys changed" unless workload.keys.sort == %w[kind name spec] -failures << "workload identity changed" unless workload.values_at("kind", "name") == %w[workload node-renderer] -failures << "spec keys changed" unless spec.keys.sort == %w[containers defaultOptions firewallConfig identityLink type] -failures << "workload type changed" unless spec.fetch("type") == "standard" -failures << "container keys changed" unless container.keys.sort == - %w[args cpu env image inheritEnv livenessProbe memory name ports readinessProbe] -failures << "unsupported startupProbe is present" if container.key?("startupProbe") -failures << "command changed" if container.key?("command") -failures << "renderer name changed" unless container.fetch("name") == "node-renderer" -failures << "renderer CPU changed" unless container.fetch("cpu") == "100m" -failures << "renderer memory changed" unless container.fetch("memory") == "512Mi" -failures << "renderer environment changed" unless container.fetch("env") == expected_env -failures << "environment inheritance changed" unless container.fetch("inheritEnv") == true -failures << "args changed" unless container.fetch("args") == expected_args -failures << "image changed" unless container.fetch("image") == "template-placeholder" -failures << "ports changed" unless container.fetch("ports") == expected_ports -failures << "readinessProbe changed" unless container.fetch("readinessProbe") == expected_readiness_probe -failures << "livenessProbe grace or thresholds changed" unless container.fetch("livenessProbe") == expected_liveness_probe -failures << "default autoscaling or capacityAI changed" unless spec.fetch("defaultOptions") == expected_default_options -failures << "firewall configuration changed" unless spec.fetch("firewallConfig") == expected_firewall_config -failures << "identity link changed" unless spec.fetch("identityLink") == "template-placeholder" - -cpu_match = /\A(?\d+)m\z/.match(container.fetch("cpu").to_s) -memory_match = /\A(?\d+)Mi\z/.match(container.fetch("memory").to_s) -if cpu_match && memory_match && cpu_match[:millicores].to_i.positive? - resource_ratio = memory_match[:mebibytes].to_f / cpu_match[:millicores].to_i - failures << format("provider resource ratio must be strictly below 8 (got %.2f)", resource_ratio) unless resource_ratio < 8 -else - failures << "renderer resources must use positive millicore CPU and MiB memory units" +documents = Dir[ + ".github/actions/**/action.yml", + ".github/actions/**/action.yaml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml" +].sort.to_h do |path| + [path, YAML.load_file(path, aliases: true)] end -abort failures.join("\n") unless failures.empty? -puts "node renderer template preserves provider resource ratio, supported probe grace, and stable runtime settings" -RUBY +documents.each_key do |path| + puts "parsed #{path}" +end -echo "==> parse generated GitHub Actions YAML" -ruby <<'RUBY' -require "yaml" +generated_local_action_pattern = %r{\A\./(?\.github/actions/cpflow-[a-z0-9]+(?:-[a-z0-9]+)*)\z} +local_action_references = [] +invalid_local_action_references = [] +visited_container_ids = {} +walk = lambda do |node, source_path| + if node.is_a?(Hash) || node.is_a?(Array) + next if visited_container_ids.key?(node.object_id) -Dir[".github/actions/**/action.yml", ".github/workflows/*.yml"].sort.each do |path| - YAML.load_file(path, aliases: true) - puts "parsed #{path}" + visited_container_ids[node.object_id] = true + end + + case node + when Hash + uses = node["uses"] + if uses.is_a?(String) && uses.start_with?("./.github/actions/cpflow-") + match = uses.match(generated_local_action_pattern) + if match + local_action_references << [source_path, match[:directory]] + else + invalid_local_action_references << + "#{source_path} has invalid generated local action reference #{uses}" + end + end + node.each_value { |value| walk.call(value, source_path) } + when Array + node.each { |value| walk.call(value, source_path) } + end +end +documents.each { |path, document| walk.call(document, path) } + +abort invalid_local_action_references.uniq.join("\n") unless invalid_local_action_references.empty? + +missing_actions = local_action_references.uniq.filter_map do |source_path, action_directory| + directory_is_local = begin + File.lstat(action_directory).directory? + rescue Errno::ENOENT + false + end + descriptor_is_local = %w[action.yml action.yaml].any? do |filename| + begin + File.lstat(File.join(action_directory, filename)).file? + rescue Errno::ENOENT + false + end + end + + "#{source_path} references missing local action #{action_directory}" unless directory_is_local && descriptor_is_local end + +abort missing_actions.join("\n") unless missing_actions.empty? +puts "all referenced local actions have checked-in descriptors" RUBY echo "==> check composite action input descriptions" @@ -130,7 +105,7 @@ require "yaml" CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z} PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-promote-staging-to-production\.yml@([^\s]+)\z} EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" -EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" +EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" refs = Hash.new { |hash, key| hash[key] = [] }