cpflow 4.2.0 → 5.0.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude.yml +5 -0
  4. data/.overcommit.yml +43 -3
  5. data/.rubocop.yml +3 -3
  6. data/CHANGELOG.md +28 -4
  7. data/CONTRIBUTING.md +6 -0
  8. data/Gemfile +8 -7
  9. data/Gemfile.lock +92 -72
  10. data/README.md +43 -15
  11. data/cpflow.gemspec +5 -5
  12. data/docs/ai-github-flow-prompt.md +61 -0
  13. data/docs/ci-automation.md +335 -28
  14. data/docs/commands.md +65 -4
  15. data/docs/releasing.md +153 -0
  16. data/lib/command/ai_github_flow_prompt.rb +47 -0
  17. data/lib/command/base.rb +14 -0
  18. data/lib/command/cleanup_images.rb +1 -1
  19. data/lib/command/cleanup_stale_apps.rb +1 -1
  20. data/lib/command/copy_image_from_upstream.rb +14 -3
  21. data/lib/command/exists.rb +13 -2
  22. data/lib/command/generate.rb +153 -4
  23. data/lib/command/generate_github_actions.rb +170 -0
  24. data/lib/command/generator_helpers.rb +31 -0
  25. data/lib/command/github_flow_readiness.rb +37 -0
  26. data/lib/command/run.rb +1 -1
  27. data/lib/command/terraform/generate.rb +1 -0
  28. data/lib/command/version.rb +1 -0
  29. data/lib/constants/exit_code.rb +1 -0
  30. data/lib/core/controlplane.rb +9 -7
  31. data/lib/core/controlplane_api_direct.rb +3 -3
  32. data/lib/core/github_flow_readiness/checks.rb +143 -0
  33. data/lib/core/github_flow_readiness_service.rb +453 -0
  34. data/lib/core/repo_introspection.rb +118 -0
  35. data/lib/core/terraform_config/dsl.rb +1 -1
  36. data/lib/core/terraform_config/local_variable.rb +1 -1
  37. data/lib/cpflow/version.rb +1 -1
  38. data/lib/cpflow.rb +65 -3
  39. data/lib/generator_templates/Dockerfile +59 -3
  40. data/lib/generator_templates/controlplane.yml +27 -39
  41. data/lib/generator_templates/entrypoint.sh +1 -1
  42. data/lib/generator_templates/release_script.sh +23 -0
  43. data/lib/generator_templates/templates/app.yml +5 -8
  44. data/lib/generator_templates/templates/rails.yml +2 -11
  45. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  46. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  47. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  48. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  49. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  50. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  51. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  52. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  53. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  54. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  58. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  61. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  62. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  66. data/rakelib/create_release.rake +662 -37
  67. data/script/check_command_docs +4 -2
  68. data/script/check_cpln_links +25 -11
  69. data/script/precommit/check_command_docs +22 -0
  70. data/script/precommit/check_cpln_links +21 -0
  71. data/script/precommit/check_trailing_newlines +68 -0
  72. data/script/precommit/get_changed_files +49 -0
  73. data/script/precommit/ruby_autofix +52 -0
  74. data/script/precommit/ruby_lint +33 -0
  75. metadata +52 -14
@@ -0,0 +1,131 @@
1
+ name: Build Docker Image
2
+ description: Builds and pushes the app image for a Control Plane workload
3
+
4
+ inputs:
5
+ app_name:
6
+ description: Name of the application
7
+ required: true
8
+ org:
9
+ description: Control Plane organization name
10
+ required: true
11
+ commit:
12
+ description: Commit SHA to tag the image with
13
+ required: true
14
+ pr_number:
15
+ description: Pull request number for status messaging
16
+ required: false
17
+ docker_build_extra_args:
18
+ description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar.
19
+ required: false
20
+ docker_build_ssh_key:
21
+ description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
22
+ required: false
23
+ docker_build_ssh_known_hosts:
24
+ description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys.
25
+ required: false
26
+ working_directory:
27
+ description: Directory containing the app .controlplane config and Docker build context
28
+ required: false
29
+ default: "."
30
+
31
+ runs:
32
+ using: composite
33
+ steps:
34
+ # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present
35
+ # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any
36
+ # command runs, so keeping the key out of env there avoids even admin-triggered exposure.
37
+ - name: Prepare SSH agent for Docker build
38
+ if: ${{ inputs.docker_build_ssh_key != '' }}
39
+ shell: bash
40
+ env:
41
+ # Pass the key via env so the file write is a single printf call rather than a
42
+ # heredoc with a fixed terminator (a heredoc would silently truncate the key if
43
+ # any line of the key value happened to match the terminator). Scope is still
44
+ # this step only — the build step below does not receive DOCKER_BUILD_SSH_KEY.
45
+ DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }}
46
+ DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }}
47
+ run: |
48
+ set -euo pipefail
49
+
50
+ umask 077
51
+ mkdir -p ~/.ssh
52
+ chmod 700 ~/.ssh
53
+
54
+ if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then
55
+ printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
56
+ else
57
+ printf '%s\n' \
58
+ 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \
59
+ 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \
60
+ 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \
61
+ > ~/.ssh/known_hosts
62
+ fi
63
+ chmod 600 ~/.ssh/known_hosts
64
+
65
+ printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" > ~/.ssh/cpflow_build_key
66
+ chmod 600 ~/.ssh/cpflow_build_key
67
+
68
+ - name: Build Docker image
69
+ shell: bash
70
+ env:
71
+ APP_NAME: ${{ inputs.app_name }}
72
+ COMMIT_SHA: ${{ inputs.commit }}
73
+ CONTROL_PLANE_ORG: ${{ inputs.org }}
74
+ DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
75
+ PR_NUMBER: ${{ inputs.pr_number }}
76
+ WORKING_DIRECTORY: ${{ inputs.working_directory }}
77
+ run: |
78
+ set -euo pipefail
79
+
80
+ PR_INFO=""
81
+ docker_build_args=()
82
+ ssh_agent_started=false
83
+ build_ssh_prepped=false
84
+
85
+ cleanup_build_ssh() {
86
+ if [[ "${ssh_agent_started}" == "true" ]]; then
87
+ ssh-agent -k >/dev/null || true
88
+ fi
89
+ rm -f "${HOME}/.ssh/cpflow_build_key"
90
+ # Only remove known_hosts if this action's prep step wrote it. On self-hosted
91
+ # or reused runners we must not touch a user-managed file we did not create,
92
+ # so the flag is set inside the same prep-detection branch below.
93
+ if [[ "${build_ssh_prepped}" == "true" ]]; then
94
+ rm -f "${HOME}/.ssh/known_hosts"
95
+ fi
96
+ }
97
+ trap cleanup_build_ssh EXIT
98
+ cd "${WORKING_DIRECTORY}"
99
+
100
+ if [[ -n "${PR_NUMBER}" ]]; then
101
+ PR_INFO=" for PR #${PR_NUMBER}"
102
+ fi
103
+
104
+ if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then
105
+ while IFS= read -r arg; do
106
+ arg="${arg%$'\r'}"
107
+ [[ -n "${arg}" ]] || continue
108
+
109
+ if [[ "${arg}" =~ [[:space:]] ]]; then
110
+ echo "docker_build_extra_args entries must be single docker-build tokens. " \
111
+ "Use key=value forms like --build-arg=FOO=bar." >&2
112
+ exit 1
113
+ fi
114
+
115
+ docker_build_args+=("${arg}")
116
+ done <<< "${DOCKER_BUILD_EXTRA_ARGS}"
117
+ fi
118
+
119
+ if [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then
120
+ # Mark prep-step ownership so cleanup_build_ssh only removes known_hosts
121
+ # when this action wrote it (see trap above).
122
+ build_ssh_prepped=true
123
+ eval "$(ssh-agent -s)"
124
+ ssh_agent_started=true
125
+ ssh-add "${HOME}/.ssh/cpflow_build_key"
126
+ docker_build_args+=("--ssh=default")
127
+ fi
128
+
129
+ echo "🏗️ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..."
130
+ cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}"
131
+ echo "✅ Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})"
@@ -0,0 +1,24 @@
1
+ name: Delete Control Plane App
2
+ description: Deletes a Control Plane app and all associated resources
3
+
4
+ inputs:
5
+ app_name:
6
+ description: Name of the application to delete
7
+ required: true
8
+ cpln_org:
9
+ description: Control Plane organization name
10
+ required: true
11
+ review_app_prefix:
12
+ description: Prefix used for review app names
13
+ required: true
14
+
15
+ runs:
16
+ using: composite
17
+ steps:
18
+ - name: Delete application
19
+ shell: bash
20
+ run: ${{ github.action_path }}/delete-app.sh
21
+ env:
22
+ APP_NAME: ${{ inputs.app_name }}
23
+ CPLN_ORG: ${{ inputs.cpln_org }}
24
+ REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }}
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+
3
+ set -euo pipefail
4
+
5
+ : "${APP_NAME:?APP_NAME environment variable is required}"
6
+ : "${CPLN_ORG:?CPLN_ORG environment variable is required}"
7
+ : "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}"
8
+
9
+ expected_prefix="${REVIEW_APP_PREFIX}-"
10
+ if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then
11
+ echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2
12
+ echo "App name: $APP_NAME" >&2
13
+ echo "Expected prefix: ${expected_prefix}" >&2
14
+ exit 1
15
+ fi
16
+
17
+ echo "🔍 Checking if application exists: $APP_NAME"
18
+ exists_output=""
19
+ set +e
20
+ exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"
21
+ exists_status=$?
22
+ set -e
23
+
24
+ case "$exists_status" in
25
+ 0)
26
+ ;;
27
+ 3)
28
+ if [[ -n "$exists_output" ]]; then
29
+ printf '%s\n' "$exists_output"
30
+ fi
31
+ echo "⚠️ Application does not exist: $APP_NAME"
32
+ exit 0
33
+ ;;
34
+ *)
35
+ echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2
36
+ if [[ -n "$exists_output" ]]; then
37
+ printf '%s\n' "$exists_output" >&2
38
+ fi
39
+ exit "$exists_status"
40
+ ;;
41
+ esac
42
+
43
+ if [[ -n "$exists_output" ]]; then
44
+ printf '%s\n' "$exists_output"
45
+ fi
46
+
47
+ echo "🗑️ Deleting application: $APP_NAME"
48
+ cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
49
+
50
+ echo "✅ Successfully deleted application: $APP_NAME"
@@ -0,0 +1,62 @@
1
+ name: Detect release phase support
2
+ description: >-
3
+ Inspects .controlplane/controlplane.yml for an app and emits `flag=--run-release-phase`
4
+ when a `release_script:` is configured. Outputs an empty `flag` otherwise.
5
+
6
+ inputs:
7
+ app_name:
8
+ description: cpflow app name to inspect
9
+ required: true
10
+ working_directory:
11
+ description: Directory containing .controlplane/controlplane.yml
12
+ required: false
13
+ default: "."
14
+
15
+ outputs:
16
+ flag:
17
+ description: Either `--run-release-phase` or empty
18
+ value: ${{ steps.detect.outputs.flag }}
19
+
20
+ runs:
21
+ using: composite
22
+ steps:
23
+ - name: Detect release phase support
24
+ id: detect
25
+ shell: bash
26
+ env:
27
+ APP_NAME: ${{ inputs.app_name }}
28
+ WORKING_DIRECTORY: ${{ inputs.working_directory }}
29
+ run: |
30
+ set -euo pipefail
31
+ cd "${WORKING_DIRECTORY}"
32
+
33
+ release_script="$(ruby - "${APP_NAME}" <<'RUBY'
34
+ require "yaml"
35
+
36
+ app_name = ARGV.fetch(0)
37
+ data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
38
+ apps = data["apps"] || {}
39
+ app_config = apps[app_name]
40
+
41
+ unless app_config
42
+ app_config = apps.find do |name, config|
43
+ config.is_a?(Hash) &&
44
+ config["match_if_app_name_starts_with"] &&
45
+ app_name.start_with?(name)
46
+ end&.last
47
+ end
48
+
49
+ unless app_config.is_a?(Hash)
50
+ warn "Error: app '#{app_name}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
51
+ exit 1
52
+ end
53
+
54
+ puts app_config["release_script"].to_s
55
+ RUBY
56
+ )"
57
+
58
+ if [[ -n "${release_script}" ]]; then
59
+ echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT"
60
+ else
61
+ echo "flag=" >> "$GITHUB_OUTPUT"
62
+ fi
@@ -0,0 +1,98 @@
1
+ name: Setup Control Plane Environment
2
+ description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile
3
+
4
+ inputs:
5
+ token:
6
+ description: Control Plane token
7
+ required: true
8
+ org:
9
+ description: Control Plane organization
10
+ required: true
11
+ ruby_version:
12
+ description: >-
13
+ Ruby version used for cpflow. When empty (the default), ruby/setup-ruby auto-detects
14
+ from .ruby-version, .tool-versions, or the Gemfile.
15
+ required: false
16
+ default: ""
17
+ cpln_cli_version:
18
+ description: >-
19
+ @controlplane/cli version. Empty string falls back to the action's pinned default
20
+ so callers can pass `${{ vars.CPLN_CLI_VERSION }}` unconditionally.
21
+ required: false
22
+ default: ""
23
+ cpflow_version:
24
+ description: >-
25
+ cpflow gem version. Empty string falls back to the action's pinned default
26
+ so callers can pass `${{ vars.CPFLOW_VERSION }}` unconditionally.
27
+ required: false
28
+ default: ""
29
+
30
+ runs:
31
+ using: composite
32
+ # Third-party actions are pinned to floating major tags (`@v4`, `@v1`, `@v7`) rather than
33
+ # immutable SHAs. SHA pinning is GitHub's stronger security recommendation, but for
34
+ # generated templates that ship into many downstream repositories floating tags are
35
+ # easier for users to keep current and Dependabot/Renovate already cover the SHA-pinning
36
+ # workflow for repositories that opt in. Repositories with stricter supply-chain
37
+ # requirements should replace each `uses: actions/...@vN` with the corresponding
38
+ # immutable commit SHA.
39
+ steps:
40
+ - name: Set up Ruby
41
+ uses: ruby/setup-ruby@v1
42
+ with:
43
+ ruby-version: ${{ inputs.ruby_version }}
44
+
45
+ - name: Install Control Plane CLI and cpflow gem
46
+ shell: bash
47
+ env:
48
+ CPLN_CLI_VERSION: ${{ inputs.cpln_cli_version }}
49
+ CPFLOW_VERSION: ${{ inputs.cpflow_version }}
50
+ run: |
51
+ set -euo pipefail
52
+
53
+ # Bump these defaults when a new release lands that you want to roll out by default.
54
+ # Override per-repo by setting `CPLN_CLI_VERSION` / `CPFLOW_VERSION` repo variables;
55
+ # an empty input falls back to the action's pinned default below.
56
+ default_cpln_cli_version="3.3.1"
57
+ default_cpflow_version="__CPFLOW_VERSION__"
58
+
59
+ CPLN_CLI_VERSION="${CPLN_CLI_VERSION:-${default_cpln_cli_version}}"
60
+ CPFLOW_VERSION="${CPFLOW_VERSION:-${default_cpflow_version}}"
61
+
62
+ npm_global_prefix="${HOME}/.npm-global"
63
+ mkdir -p "${npm_global_prefix}"
64
+ echo "${npm_global_prefix}/bin" >> "$GITHUB_PATH"
65
+ export PATH="${npm_global_prefix}/bin:${PATH}"
66
+
67
+ npm install --global --prefix "${npm_global_prefix}" "@controlplane/cli@${CPLN_CLI_VERSION}"
68
+ cpln --version
69
+
70
+ gem install cpflow -v "${CPFLOW_VERSION}" --no-document
71
+ cpflow --version
72
+
73
+ - name: Setup Control Plane profile and registry login
74
+ shell: bash
75
+ env:
76
+ # Pass the token via CPLN_TOKEN so cpln picks it up from the environment
77
+ # rather than `--token`, which would leak it into /proc/<pid>/cmdline and ps output.
78
+ CPLN_TOKEN: ${{ inputs.token }}
79
+ ORG: ${{ inputs.org }}
80
+ run: |
81
+ set -euo pipefail
82
+
83
+ if [[ -z "$CPLN_TOKEN" ]]; then
84
+ echo "Error: Control Plane token not provided" >&2
85
+ exit 1
86
+ fi
87
+
88
+ if [[ -z "$ORG" ]]; then
89
+ echo "Error: Control Plane organization not provided" >&2
90
+ exit 1
91
+ fi
92
+
93
+ # `cpln profile update` lists `create` as an alias (cpln profile --help) and is
94
+ # idempotent: it creates the profile if missing and updates it otherwise. Calling
95
+ # update directly avoids parsing the CLI's "already exists" English error text,
96
+ # which would silently swallow a real failure if the wording ever changed.
97
+ cpln profile update default --org "$ORG"
98
+ cpln image docker-login --org "$ORG"
@@ -0,0 +1,85 @@
1
+ name: Validate cpflow GitHub configuration
2
+ description: >-
3
+ Validates that required secrets and repository variables are set before a workflow
4
+ proceeds. Pass each value via `env:` with the same NAME as the secret or variable,
5
+ then list the required entries in `required` as `type:NAME` pairs (type is `secret`
6
+ or `variable`). When `pull_request_friendly: true` and the current event is a
7
+ pull request event, missing config writes a step summary and exits 0 with
8
+ `ready=false` instead of failing the job.
9
+
10
+ inputs:
11
+ required:
12
+ description: |
13
+ Newline-separated `type:NAME` pairs. Type is `secret` or `variable`. The
14
+ caller MUST export the matching values via `env:` using the same NAME.
15
+ required: true
16
+ pull_request_friendly:
17
+ description: When "true" and event is pull_request/pull_request_target, write summary and exit 0 with ready=false.
18
+ required: false
19
+ default: "false"
20
+
21
+ outputs:
22
+ ready:
23
+ description: '"true" when all values are set, "false" when missing in PR-friendly mode.'
24
+ value: ${{ steps.check.outputs.ready }}
25
+
26
+ runs:
27
+ using: composite
28
+ steps:
29
+ - name: Check required secrets and variables
30
+ id: check
31
+ shell: bash
32
+ env:
33
+ CPFLOW_REQUIRED: ${{ inputs.required }}
34
+ CPFLOW_PR_FRIENDLY: ${{ inputs.pull_request_friendly }}
35
+ CPFLOW_EVENT_NAME: ${{ github.event_name }}
36
+ run: |
37
+ set -euo pipefail
38
+
39
+ missing=()
40
+ while IFS= read -r entry; do
41
+ entry="${entry%$'\r'}"
42
+ entry="${entry## }"
43
+ entry="${entry%% }"
44
+ [[ -z "${entry}" ]] && continue
45
+
46
+ type="${entry%%:*}"
47
+ name="${entry#*:}"
48
+
49
+ # Reject names that are not plain SHELL_VAR identifiers before doing the
50
+ # indirect lookup below. Without this guard, ${!name} would expand whatever
51
+ # bash nameref/transformation a hand-edited generated workflow snuck in
52
+ # (e.g. `BASH_FUNC_foo%%`). Callers today are the generated templates, but
53
+ # the generated file lives in the user's repo and can be hand-edited.
54
+ if [[ ! "${name}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
55
+ echo "Invalid config entry name: ${name}" >&2
56
+ exit 1
57
+ fi
58
+
59
+ # Indirect bash lookup: reads the env var named by ${name} (e.g. CPLN_TOKEN_STAGING)
60
+ # so the value never has to round-trip through workflow logs.
61
+ if [[ -z "${!name:-}" ]]; then
62
+ missing+=("${type}:${name}")
63
+ fi
64
+ done <<< "${CPFLOW_REQUIRED}"
65
+
66
+ if [[ ${#missing[@]} -eq 0 ]]; then
67
+ echo "ready=true" >> "$GITHUB_OUTPUT"
68
+ exit 0
69
+ fi
70
+
71
+ if [[ "${CPFLOW_PR_FRIENDLY}" == "true" && ( "${CPFLOW_EVENT_NAME}" == "pull_request" || "${CPFLOW_EVENT_NAME}" == "pull_request_target" ) ]]; then
72
+ echo "ready=false" >> "$GITHUB_OUTPUT"
73
+ {
74
+ echo "Control Plane review app automation is not configured yet."
75
+ echo
76
+ echo "Missing required GitHub configuration:"
77
+ printf -- '- `%s`\n' "${missing[@]}"
78
+ echo
79
+ echo "Pushes to this pull request will skip review app deploys until the repository is configured."
80
+ } >> "$GITHUB_STEP_SUMMARY"
81
+ exit 0
82
+ fi
83
+
84
+ printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2
85
+ exit 1
@@ -0,0 +1,92 @@
1
+ name: Wait for Control Plane workload health
2
+ description: >-
3
+ Polls the workload's status endpoint with curl and exits success when the
4
+ HTTP response status is in the accepted list. Fails non-zero (and reports
5
+ `healthy=false`) once retries are exhausted.
6
+
7
+ inputs:
8
+ workload_name:
9
+ description: Workload to query (e.g. `rails`).
10
+ required: true
11
+ app_name:
12
+ description: GVC / Control Plane app name the workload belongs to.
13
+ required: true
14
+ org:
15
+ description: Control Plane organization.
16
+ required: true
17
+ max_retries:
18
+ description: Number of attempts before giving up.
19
+ required: false
20
+ default: "24"
21
+ interval_seconds:
22
+ description: Seconds to sleep between attempts.
23
+ required: false
24
+ default: "15"
25
+ accepted_statuses:
26
+ description: >-
27
+ Space-separated list of HTTP status codes considered healthy. The default
28
+ `200 301 302` accepts redirects because curl is invoked without `-L`, so a
29
+ root path that auth-redirects looks like a redirect, not a failure.
30
+ required: false
31
+ default: "200 301 302"
32
+ curl_max_time:
33
+ description: Per-request curl timeout, seconds.
34
+ required: false
35
+ default: "10"
36
+
37
+ outputs:
38
+ healthy:
39
+ description: '"true" once a healthy response was observed; "false" otherwise.'
40
+ value: ${{ steps.poll.outputs.healthy }}
41
+
42
+ runs:
43
+ using: composite
44
+ steps:
45
+ - name: Poll workload endpoint
46
+ id: poll
47
+ shell: bash
48
+ env:
49
+ CPFLOW_WORKLOAD_NAME: ${{ inputs.workload_name }}
50
+ CPFLOW_APP_NAME: ${{ inputs.app_name }}
51
+ CPFLOW_ORG: ${{ inputs.org }}
52
+ CPFLOW_MAX_RETRIES: ${{ inputs.max_retries }}
53
+ CPFLOW_INTERVAL_SECONDS: ${{ inputs.interval_seconds }}
54
+ CPFLOW_ACCEPTED_STATUSES: ${{ inputs.accepted_statuses }}
55
+ CPFLOW_CURL_MAX_TIME: ${{ inputs.curl_max_time }}
56
+ run: |
57
+ set -euo pipefail
58
+
59
+ read -r -a accepted_statuses <<< "${CPFLOW_ACCEPTED_STATUSES}"
60
+
61
+ for attempt in $(seq 1 "${CPFLOW_MAX_RETRIES}"); do
62
+ echo "Health check attempt ${attempt}/${CPFLOW_MAX_RETRIES}"
63
+
64
+ if ! workload_json="$(cpln workload get "${CPFLOW_WORKLOAD_NAME}" --gvc "${CPFLOW_APP_NAME}" --org "${CPFLOW_ORG}" -o json 2>&1)"; then
65
+ echo "::error::Workload '${CPFLOW_WORKLOAD_NAME}' not found in GVC '${CPFLOW_APP_NAME}'. Set PRIMARY_WORKLOAD to the correct workload name." >&2
66
+ printf '%s\n' "${workload_json}" >&2
67
+ echo "healthy=false" >> "$GITHUB_OUTPUT"
68
+ exit 1
69
+ fi
70
+
71
+ endpoint="$(echo "${workload_json}" | jq -r '.status.endpoint // empty')"
72
+ if [[ -n "${endpoint}" ]]; then
73
+ http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${CPFLOW_CURL_MAX_TIME}" "${endpoint}" 2>/dev/null || echo 000)"
74
+ echo "Endpoint: ${endpoint}, HTTP status: ${http_status}"
75
+
76
+ for accepted in "${accepted_statuses[@]}"; do
77
+ if [[ "${http_status}" == "${accepted}" ]]; then
78
+ echo "healthy=true" >> "$GITHUB_OUTPUT"
79
+ exit 0
80
+ fi
81
+ done
82
+ else
83
+ echo "Workload '${CPFLOW_WORKLOAD_NAME}' has no endpoint yet; waiting for one to be assigned."
84
+ fi
85
+
86
+ if [[ "${attempt}" -lt "${CPFLOW_MAX_RETRIES}" ]]; then
87
+ sleep "${CPFLOW_INTERVAL_SECONDS}"
88
+ fi
89
+ done
90
+
91
+ echo "healthy=false" >> "$GITHUB_OUTPUT"
92
+ exit 1
@@ -0,0 +1,47 @@
1
+ # Control Plane GitHub Flow
2
+
3
+ ## PR commands
4
+
5
+ `/deploy-review-app`
6
+ - Creates the review app if it does not exist
7
+ - Builds the PR commit image
8
+ - Deploys the image and comments with the review URL
9
+ - Comment body must be exactly `/deploy-review-app` — no surrounding text, trailing whitespace, or trailing newline. The trigger uses an exact-equality match, so a comment like `please /deploy-review-app now` or `/deploy-review-app ` (with a trailing space) silently no-ops.
10
+
11
+ `/delete-review-app`
12
+ - Deletes the review app when the PR is done
13
+ - This also runs automatically when the PR closes
14
+ - Same exact-match rule as `/deploy-review-app`: the comment body must be exactly `/delete-review-app`.
15
+
16
+ ## Repository secrets
17
+
18
+ | Name | Required | Notes |
19
+ | --- | --- | --- |
20
+ | `CPLN_TOKEN_STAGING` | yes | Service-account token scoped to the staging org. |
21
+ | `CPLN_TOKEN_PRODUCTION` | yes (for promote) | Service-account token scoped to the production org. |
22
+ | `DOCKER_BUILD_SSH_KEY` | optional | Private SSH key used when Docker builds fetch private deps via `RUN --mount=type=ssh`. |
23
+
24
+ ## Repository variables
25
+
26
+ | Name | Required | Notes |
27
+ | --- | --- | --- |
28
+ | `CPLN_ORG_STAGING` | yes | Control Plane org for staging and review apps. |
29
+ | `CPLN_ORG_PRODUCTION` | yes (for promote) | Control Plane org for production. |
30
+ | `STAGING_APP_NAME` | yes | App name in `controlplane.yml` used as the staging deploy target. |
31
+ | `PRODUCTION_APP_NAME` | yes (for promote) | App name in `controlplane.yml` used as the production deploy target. |
32
+ | `REVIEW_APP_PREFIX` | yes | Prefix for per-PR review app names (e.g. `review-app`). |
33
+ | `STAGING_APP_BRANCH` | optional | Custom staging branch. Custom branches must also appear in `cpflow-deploy-staging.yml`'s push filter. |
34
+ | `PRIMARY_WORKLOAD` | optional | Workload polled for health and rollback (defaults to `rails`). |
35
+ | `DOCKER_BUILD_EXTRA_ARGS` | optional | Newline-delimited extra docker build tokens (e.g. `--build-arg=FOO=bar`). |
36
+ | `DOCKER_BUILD_SSH_KNOWN_HOSTS` | optional | SSH known_hosts entries when SSH build hosts are not GitHub.com. |
37
+ | `HEALTH_CHECK_ACCEPTED_STATUSES` | optional | Space-separated HTTP statuses considered healthy on promote (default `200 301 302`). |
38
+ | `CPLN_CLI_VERSION` | optional | Pin a specific `@controlplane/cli` version; falls back to the action default when unset. |
39
+ | `CPFLOW_VERSION` | optional | Pin a specific cpflow gem version; falls back to the generated default when unset. |
40
+
41
+ ## Workflow behavior
42
+
43
+ - Review apps are opt-in and created with `/deploy-review-app`
44
+ - New commits redeploy existing review apps automatically
45
+ - Pushes to the staging branch deploy staging automatically
46
+ - Promotion to production is manual via the Actions tab
47
+ - A nightly workflow removes stale review apps
@@ -0,0 +1,56 @@
1
+ name: Cleanup Stale Review Apps
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ schedule:
6
+ - cron: "0 0 * * *"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ # Single global group: only one cleanup sweep at a time. Independent of review-app
13
+ # deploy/delete groups (different keys), so cleanup will not block per-PR work.
14
+ group: cpflow-cleanup-stale-review-apps
15
+ # A cancelled `cpflow cleanup-stale-apps` can leave half-deleted review apps; let
16
+ # the in-flight run finish before the next scheduled tick begins.
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ cleanup:
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 30
23
+ steps:
24
+ - name: Checkout repository
25
+ uses: actions/checkout@v4
26
+ with:
27
+ persist-credentials: false
28
+
29
+ - name: Validate required secrets and variables
30
+ uses: ./.github/actions/cpflow-validate-config
31
+ env:
32
+ CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
33
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
34
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
35
+ with:
36
+ required: |
37
+ secret:CPLN_TOKEN_STAGING
38
+ variable:CPLN_ORG_STAGING
39
+ variable:REVIEW_APP_PREFIX
40
+
41
+ - name: Setup environment
42
+ uses: ./.github/actions/cpflow-setup-environment
43
+ with:
44
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
45
+ org: ${{ vars.CPLN_ORG_STAGING }}
46
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
47
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
48
+
49
+ - name: Remove stale review apps
50
+ env:
51
+ REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
52
+ CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
53
+ shell: bash
54
+ run: |
55
+ set -euo pipefail
56
+ cpflow cleanup-stale-apps -a "${REVIEW_APP_PREFIX}" --org "${CPLN_ORG_STAGING}" --yes