cpflow 6.0.0.rc.0 → 6.0.1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -38
  3. data/.agents/bin/README.md +7 -8
  4. data/.agents/legacy-workflow-policy.yml +11 -0
  5. data/.agents/shaka.md +20 -0
  6. data/.agents/trusted-actions.yml +12 -0
  7. data/.coderabbit.yaml +1 -0
  8. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  9. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  10. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  11. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  12. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +3 -10
  13. data/.github/workflows/cpflow-delete-review-app.yml +4 -11
  14. data/.github/workflows/cpflow-deploy-review-app.yml +19 -14
  15. data/.github/workflows/cpflow-deploy-staging.yml +17 -12
  16. data/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
  17. data/.github/workflows/rspec.yml +24 -0
  18. data/AGENTS.md +18 -3
  19. data/CHANGELOG.md +26 -3
  20. data/CONTRIBUTING.md +4 -4
  21. data/Gemfile.lock +1 -1
  22. data/README.md +5 -2
  23. data/docs/ai-github-flow-prompt.md +104 -8
  24. data/docs/ci-automation.md +258 -60
  25. data/docs/commands.md +32 -16
  26. data/docs/postgres-review-apps.md +90 -0
  27. data/docs/postgres.md +233 -375
  28. data/docs/secrets-and-env-values.md +42 -0
  29. data/docs/sidebars.ts +1 -0
  30. data/lib/command/ai_github_flow_prompt.rb +10 -13
  31. data/lib/command/ai_github_flow_prompt.txt +103 -0
  32. data/lib/command/apply_template.rb +36 -4
  33. data/lib/command/base.rb +56 -3
  34. data/lib/command/delete.rb +174 -4
  35. data/lib/command/deploy_image.rb +6 -0
  36. data/lib/command/generate.rb +247 -3
  37. data/lib/command/generate_github_actions.rb +15 -17
  38. data/lib/command/setup_app.rb +92 -10
  39. data/lib/command/update_github_actions.rb +107 -43
  40. data/lib/core/config.rb +73 -0
  41. data/lib/core/controlplane.rb +22 -2
  42. data/lib/core/controlplane_api.rb +28 -2
  43. data/lib/core/controlplane_api_direct.rb +17 -0
  44. data/lib/core/disposable_postgres_helpers.rb +167 -0
  45. data/lib/core/doctor_service.rb +7 -0
  46. data/lib/core/github_flow_readiness/checks.rb +2 -1
  47. data/lib/core/github_flow_readiness_service.rb +4 -0
  48. data/lib/core/repo_introspection.rb +112 -2
  49. data/lib/cpflow/version.rb +1 -1
  50. data/lib/generator_templates/.dockerignore +16 -0
  51. data/lib/generator_templates/Dockerfile +9 -5
  52. data/lib/generator_templates/entrypoint.sh +2 -0
  53. data/lib/generator_templates/templates/postgres.yml +9 -0
  54. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  55. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  56. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  57. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  58. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
  61. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  62. data/lib/github_flow_templates/bin/test-cpflow-github-flow +88 -47
  63. metadata +12 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73c78e337b3728ba8273db59f8f7d9540e855e986ba7aed509c3091a7cf806dd
4
- data.tar.gz: d399af718dd0ab5a103d13b2eb7bc3515d2839077280254c29563fa88f14559d
3
+ metadata.gz: 5339d75233bef2f62028093b5037a44155a56d2c4f39741efbc4861c47848e86
4
+ data.tar.gz: bd30c3c9da8a468b6e5cc9479ed6be3912066b70e03f021533a815498756f162
5
5
  SHA512:
6
- metadata.gz: ad64de2a50dac54f6c9ee541ec8a2e80f13a36b81cedc2c58924c881ed80df8ff5f6c231255cae7328fbeeda80992e98fbab613f406f149478e7be2711a5a958
7
- data.tar.gz: 927713a8b5e829ec6c89aef86ba9020d686bf6b1647473efbb356f31f9ca590185b8c582c061afd555ad32efa6f6caf38c834fba03cf56a52b2a7970f239f2db
6
+ metadata.gz: 417be2b206c02fda8ff5f2374ba3874ebc61c05ac00af7a8ff163306f359a9f323c3af268fad415b3422603b59bb7d1572335af4e5de9b88a0a5a7e6659f485b
7
+ data.tar.gz: 72f2e06d1325fc09028ad8454454fda8dfd0b8fa1a0a075050b5fe997b7ed1653d1abfcb5ccc35382caa1737325b40a52b4575de11d7f4a3707ccfc8d4e9803f
@@ -1,39 +1,17 @@
1
- # Non-command agent-workflow configuration for portable shared skills.
2
- # This file is the canonical source for the non-command policy values below.
3
- # AGENTS.md directs portable skills here; it does not duplicate or override them.
4
- # Commands live as scripts in .agents/bin/ (see .agents/bin/README.md).
1
+ ---
2
+ version: 1
5
3
  base_branch: main
6
- merge_submission:
7
- mode: direct
8
- changelog: "CHANGELOG.md — Keep-a-Changelog; user-visible changes only"
9
- follow_up_prefix: "Follow-up:"
10
- review_gate: "AI reviewers are advisory unless they confirm a blocker; merge gate is the full `gh pr checks` list green (not --required) + all threads resolved + mergeable clean"
11
- approval_exempt: "at batch closeout, auto-merge ready low-risk PRs that pass the merge gate; keep high-risk (CI/workflow, build-config, dependency or runtime bumps, broad refactors, release) maintainer-gated"
12
- coordination_backend: "private shakacode/agent-coordination (claims/heartbeats namespaced by full repo name)"
13
- benchmark_labels: n/a
14
- merge_ledger: n/a
15
- ci_parity_environment: "n/a — reproduce CI-only failures from the matching job in .github/workflows/**"
16
- release_qa_runbook: ".agents/workflows/ai-rollout-e2e-test.md — use after publishing a cpflow gem that changes GitHub Actions, AI rollout prompts, readiness checks, generator output, or React on Rails deployment behavior"
17
- hosted_ci_trigger: "n/a — CI runs on every PR"
18
- ci_change_detector: n/a
19
-
20
- # Consumed by the portable untrusted-contributor-intake skill. These values must
21
- # be read from the trusted base ref, never from contributor-controlled PR content.
22
- untrusted_contributor_intake:
23
- trusted_github_host: github.com
24
- trusted_github_scheme: https
25
- trusted_github_repo: shakacode/control-plane-flow
26
- repo_prefix: CPF
27
-
28
- # External actions already used by this repository and reviewed for the closed
29
- # allowlist enforced by the GitHub Actions security scanner. Every use still
30
- # requires an immutable commit SHA and a same-line release-version comment.
31
- trusted_actions:
32
- - actions/checkout
33
- - actions/create-github-app-token
34
- - actions/github-script
35
- - actions/upload-artifact
36
- - anthropics/claude-code-action
37
- - docker/setup-buildx-action
38
- - peter-evans/repository-dispatch
39
- - ruby/setup-ruby
4
+ review:
5
+ required: none
6
+ # Local reviewer preference; independent from hosted review workflows.
7
+ local_review_agents:
8
+ - provider: anthropic
9
+ model_family: claude
10
+ - provider: openai
11
+ model_family: codex
12
+ merge:
13
+ preference: ask
14
+ branches:
15
+ name: "{login}-{host}/{issue}-{description}"
16
+ wip:
17
+ include_locations: false
@@ -1,9 +1,8 @@
1
1
  # Agent Workflow Scripts
2
2
 
3
- Standard entry points that portable agent-workflow skills call, so a skill can
4
- run `.agents/bin/<name>` in any repo without knowing this repo's specific
5
- commands. Each script is a thin, repo-owned wrapper. A script that is **absent**
6
- means that capability is n/a here.
3
+ Thin, repo-owned command wrappers. Shaka's typed contract uses `setup`,
4
+ `validate`, and `test`; the remaining scripts are additional repository helpers.
5
+ A script that is **absent** means that capability is n/a here.
7
6
 
8
7
  | Script | Purpose | This repo runs |
9
8
  | --- | --- | --- |
@@ -25,7 +24,7 @@ update that array and its inventory regression test; use a `.sh` or `.bash`
25
24
  suffix for automatic inclusion. Declared paths must remain tracked and readable.
26
25
  Unlisted extensionless files and non-shell files are outside this check.
27
26
 
28
- Canonical non-command policy, including the release-QA runbook reference, lives
29
- in [`../agent-workflow.yml`](../agent-workflow.yml). [`../../AGENTS.md`](../../AGENTS.md)
30
- is the thin discovery pointer for portable shared skills and does not duplicate
31
- or override that policy.
27
+ Canonical typed Shaka policy lives in
28
+ [`../agent-workflow.yml`](../agent-workflow.yml). The GitHub Actions dependency allowlist lives in
29
+ [`../trusted-actions.yml`](../trusted-actions.yml). Additional agent-binding repository policy,
30
+ including the release-QA runbook reference, lives in [`../../AGENTS.md`](../../AGENTS.md).
@@ -0,0 +1,11 @@
1
+ ---
2
+ # Trusted policy retained for tools that have not migrated to Shaka's typed
3
+ # repository contract. This file is not part of the Shaka seam. Read every
4
+ # value from a trusted base ref established before reading candidate content.
5
+ # Missing, unreadable, or invalid values must fail closed.
6
+ untrusted_contributor_intake:
7
+ trusted_github_host: github.com
8
+ trusted_github_scheme: https
9
+ trusted_github_repo: shakacode/control-plane-flow
10
+ repo_prefix: CPF
11
+ coordination_backend: "private shakacode/agent-coordination (claims/heartbeats namespaced by full repo name)"
data/.agents/shaka.md ADDED
@@ -0,0 +1,20 @@
1
+ # Shaka configuration
2
+
3
+ Shaka package / skill release: `0.1.0.pre.1`.
4
+
5
+ ## Migration reference
6
+
7
+ This repo pins its Shaka reader source to the revision containing the [migration
8
+ procedure at `8431a718cfd91e9ce7cb4276baae076848e05d13`](https://github.com/shakacode/shaka/blob/8431a718cfd91e9ce7cb4276baae076848e05d13/skills/shaka/references/migration.md).
9
+ At that Shaka revision, `skills/shaka/lib/shaka/version.rb` defines the package
10
+ release and `docs/settings.md` documents the typed seam. The migration procedure
11
+ describes key changes and the two-step validation used for first adoption.
12
+
13
+ ## Check configuration edits
14
+
15
+ Run from the repository root:
16
+
17
+ `shaka seam check --root . --local`
18
+
19
+ Checks the current checkout's YAML and fixed-script paths and executable bits. It
20
+ does not execute the wrappers and grants no trusted policy authority.
@@ -0,0 +1,12 @@
1
+ ---
2
+ # External GitHub Actions repositories reviewed for use by this repository.
3
+ # Keep this list synchronized with the workflow dependency policy spec.
4
+ trusted_actions:
5
+ - actions/checkout
6
+ - actions/create-github-app-token
7
+ - actions/github-script
8
+ - actions/upload-artifact
9
+ - anthropics/claude-code-action
10
+ - docker/setup-buildx-action
11
+ - peter-evans/repository-dispatch
12
+ - ruby/setup-ruby
data/.coderabbit.yaml CHANGED
@@ -1,5 +1,6 @@
1
1
  reviews:
2
2
  profile: chill
3
+ high_level_summary: false
3
4
  review_status: false
4
5
  review_progress: false
5
6
  commit_status: false
@@ -17,6 +17,9 @@ inputs:
17
17
  docker_build_extra_args:
18
18
  description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar.
19
19
  required: false
20
+ docker_build_secrets:
21
+ description: Optional newline-delimited id=value secrets, passed through BuildKit secret mounts.
22
+ required: false
20
23
  docker_build_ssh_key:
21
24
  description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
22
25
  required: false
@@ -31,17 +34,56 @@ inputs:
31
34
  runs:
32
35
  using: composite
33
36
  steps:
37
+ - name: Prepare Docker build secrets
38
+ id: build-secrets
39
+ if: ${{ inputs.docker_build_secrets != '' }}
40
+ shell: bash
41
+ env:
42
+ DOCKER_BUILD_SECRETS: ${{ inputs.docker_build_secrets }}
43
+ run: |
44
+ set -euo pipefail
45
+ umask 077
46
+ secret_directory="$(mktemp -d "${RUNNER_TEMP}/cpflow-build-secrets.XXXXXX")"
47
+ secrets_ready=false
48
+ trap 'if [[ "${secrets_ready}" != "true" ]]; then rm -rf "${secret_directory}"; fi' EXIT
49
+ while IFS= read -r entry || [[ -n "${entry}" ]]; do
50
+ entry="${entry%$'\r'}"
51
+ [[ -n "${entry}" ]] || continue
52
+ if [[ "${entry}" != *=* ]]; then
53
+ echo "Docker build secrets must use id=value entries." >&2
54
+ exit 1
55
+ fi
56
+ secret_id="${entry%%=*}"
57
+ secret_value="${entry#*=}"
58
+ if [[ ! "${secret_id}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || [[ -z "${secret_value}" ]]; then
59
+ echo "Docker build secrets require a valid id and a nonempty value." >&2
60
+ exit 1
61
+ fi
62
+ if [[ -e "${secret_directory}/${secret_id}" ]]; then
63
+ echo "Duplicate Docker build secret id." >&2
64
+ exit 1
65
+ fi
66
+ # GitHub masks the stored id=value entry; register the extracted value too.
67
+ masked_value="${secret_value//%/%25}"
68
+ masked_value="${masked_value//$'\r'/%0D}"
69
+ masked_value="${masked_value//$'\n'/%0A}"
70
+ printf '::add-mask::%s\n' "${masked_value}"
71
+ printf '%s' "${secret_value}" > "${secret_directory}/${secret_id}"
72
+ done <<< "${DOCKER_BUILD_SECRETS}"
73
+ echo "directory=${secret_directory}" >> "${GITHUB_OUTPUT}"
74
+ secrets_ready=true
75
+
34
76
  # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present
35
77
  # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any
36
78
  # command runs, so keeping the key out of env there avoids even admin-triggered exposure.
37
79
  - name: Prepare SSH agent for Docker build
80
+ id: prepare-build-ssh
38
81
  if: ${{ inputs.docker_build_ssh_key != '' }}
39
82
  shell: bash
40
83
  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.
84
+ # Pass the key via env so ssh-add can read it from stdin without persisting it to
85
+ # disk. Scope is this step only — the build step below receives the agent socket,
86
+ # not DOCKER_BUILD_SSH_KEY.
45
87
  DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }}
46
88
  DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }}
47
89
  run: |
@@ -51,6 +93,49 @@ runs:
51
93
  mkdir -p ~/.ssh
52
94
  chmod 700 ~/.ssh
53
95
 
96
+ known_hosts_backup="${HOME}/.ssh/cpflow_known_hosts_backup"
97
+ if [[ -e "${known_hosts_backup}" ]]; then
98
+ echo "Refusing to replace SSH known_hosts: a prior cpflow backup exists at ${known_hosts_backup}." >&2
99
+ echo "If no build is currently running on this runner, restore or remove that backup, then retry." >&2
100
+ exit 1
101
+ fi
102
+
103
+ known_hosts_backup_ready=false
104
+ known_hosts_replaced=false
105
+ ssh_agent_started=false
106
+ restore_known_hosts_on_prep_failure() {
107
+ status=$?
108
+ if [[ "${status}" -ne 0 ]]; then
109
+ if [[ "${ssh_agent_started}" == "true" ]]; then
110
+ ssh-agent -k >/dev/null || true
111
+ fi
112
+ if [[ "${known_hosts_backup_ready}" == "true" ]]; then
113
+ mv -f -- "${known_hosts_backup}" "${HOME}/.ssh/known_hosts"
114
+ else
115
+ rm -f "${known_hosts_backup}"
116
+ if [[ "${known_hosts_replaced}" == "true" ]]; then
117
+ rm -f "${HOME}/.ssh/known_hosts"
118
+ fi
119
+ fi
120
+ fi
121
+ exit "${status}"
122
+ }
123
+ trap restore_known_hosts_on_prep_failure EXIT
124
+
125
+ if [[ -L "${HOME}/.ssh/known_hosts" ]]; then
126
+ echo "Refusing to replace symlinked SSH known_hosts." >&2
127
+ exit 1
128
+ fi
129
+ if [[ -e "${HOME}/.ssh/known_hosts" ]]; then
130
+ if [[ ! -f "${HOME}/.ssh/known_hosts" ]]; then
131
+ echo "Refusing to replace non-file SSH known_hosts." >&2
132
+ exit 1
133
+ fi
134
+ cp -p -- "${HOME}/.ssh/known_hosts" "${known_hosts_backup}"
135
+ known_hosts_backup_ready=true
136
+ fi
137
+
138
+ known_hosts_replaced=true
54
139
  if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then
55
140
  printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
56
141
  else
@@ -62,8 +147,15 @@ runs:
62
147
  fi
63
148
  chmod 600 ~/.ssh/known_hosts
64
149
 
65
- printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" > ~/.ssh/cpflow_build_key
66
- chmod 600 ~/.ssh/cpflow_build_key
150
+ eval "$(ssh-agent -s)"
151
+ ssh_agent_started=true
152
+ printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" | ssh-add -
153
+ {
154
+ echo "prepared=true"
155
+ echo "known_hosts_had_original=${known_hosts_backup_ready}"
156
+ echo "ssh_auth_sock=${SSH_AUTH_SOCK}"
157
+ echo "ssh_agent_pid=${SSH_AGENT_PID}"
158
+ } >> "${GITHUB_OUTPUT}"
67
159
 
68
160
  - name: Build Docker image
69
161
  shell: bash
@@ -71,27 +163,34 @@ runs:
71
163
  APP_NAME: ${{ inputs.app_name }}
72
164
  COMMIT_SHA: ${{ inputs.commit }}
73
165
  CONTROL_PLANE_ORG: ${{ inputs.org }}
166
+ DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
74
167
  DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
75
168
  PR_NUMBER: ${{ inputs.pr_number }}
169
+ CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
170
+ CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
171
+ SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
172
+ SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
76
173
  WORKING_DIRECTORY: ${{ inputs.working_directory }}
77
174
  run: |
78
175
  set -euo pipefail
79
176
 
80
177
  PR_INFO=""
81
178
  docker_build_args=()
82
- ssh_agent_started=false
83
- build_ssh_prepped=false
179
+ build_ssh_prepped="${CPFLOW_BUILD_SSH_PREPPED:-false}"
84
180
 
85
181
  cleanup_build_ssh() {
86
- if [[ "${ssh_agent_started}" == "true" ]]; then
87
- ssh-agent -k >/dev/null || true
182
+ if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
183
+ rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
88
184
  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
185
  if [[ "${build_ssh_prepped}" == "true" ]]; then
94
- rm -f "${HOME}/.ssh/known_hosts"
186
+ ssh-agent -k >/dev/null || true
187
+ # Restore a pre-existing known_hosts file on reused runners. When there was
188
+ # no original file, remove only the file created by this action.
189
+ if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
190
+ mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
191
+ else
192
+ rm -f "${HOME}/.ssh/known_hosts"
193
+ fi
95
194
  fi
96
195
  }
97
196
  trap cleanup_build_ssh EXIT
@@ -116,16 +215,48 @@ runs:
116
215
  done <<< "${DOCKER_BUILD_EXTRA_ARGS}"
117
216
  fi
118
217
 
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"
218
+ if [[ "${build_ssh_prepped}" == "true" ]]; then
126
219
  docker_build_args+=("--ssh=default")
127
220
  fi
128
221
 
222
+ if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
223
+ for secret_path in "${DOCKER_BUILD_SECRET_DIRECTORY}"/*; do
224
+ [[ -f "${secret_path}" ]] || continue
225
+ docker_build_args+=("--secret=id=${secret_path##*/},src=${secret_path}")
226
+ done
227
+ fi
228
+
129
229
  echo "🏗️ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..."
130
230
  cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}"
131
231
  echo "✅ Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})"
232
+
233
+ - name: Clean up SSH build state
234
+ if: ${{ always() && inputs.docker_build_ssh_key != '' }}
235
+ shell: bash
236
+ env:
237
+ CPFLOW_BUILD_SSH_PREPPED: ${{ steps.prepare-build-ssh.outputs.prepared }}
238
+ CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL: ${{ steps.prepare-build-ssh.outputs.known_hosts_had_original }}
239
+ SSH_AUTH_SOCK: ${{ steps.prepare-build-ssh.outputs.ssh_auth_sock }}
240
+ SSH_AGENT_PID: ${{ steps.prepare-build-ssh.outputs.ssh_agent_pid }}
241
+ run: |
242
+ set +e
243
+
244
+ if [[ "${CPFLOW_BUILD_SSH_PREPPED:-false}" == "true" ]]; then
245
+ ssh-agent -k >/dev/null || true
246
+ if [[ -f "${HOME}/.ssh/cpflow_known_hosts_backup" ]]; then
247
+ mv -f -- "${HOME}/.ssh/cpflow_known_hosts_backup" "${HOME}/.ssh/known_hosts"
248
+ elif [[ "${CPFLOW_KNOWN_HOSTS_HAD_ORIGINAL:-}" == "false" ]]; then
249
+ rm -f "${HOME}/.ssh/known_hosts"
250
+ fi
251
+ fi
252
+
253
+ - name: Clean up Docker build secrets
254
+ if: ${{ always() && inputs.docker_build_secrets != '' }}
255
+ shell: bash
256
+ env:
257
+ DOCKER_BUILD_SECRET_DIRECTORY: ${{ steps.build-secrets.outputs.directory }}
258
+ run: |
259
+ set -euo pipefail
260
+ if [[ -n "${DOCKER_BUILD_SECRET_DIRECTORY:-}" ]]; then
261
+ rm -rf -- "${DOCKER_BUILD_SECRET_DIRECTORY}"
262
+ fi
@@ -29,6 +29,9 @@ case "$exists_status" in
29
29
  printf '%s\n' "$exists_output"
30
30
  fi
31
31
  echo "⚠️ Application does not exist: $APP_NAME"
32
+ echo "🧹 Checking for disposable review app resources: $APP_NAME"
33
+ cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes
34
+ echo "✅ Finished disposable review app cleanup: $APP_NAME"
32
35
  exit 0
33
36
  ;;
34
37
  *)
@@ -0,0 +1,82 @@
1
+ name: Preview Production Promotion
2
+ description: Compare the deployed staging image to the last recorded production release before approval
3
+ inputs:
4
+ staging_app_name:
5
+ required: true
6
+ description: Staging app to read
7
+ staging_org:
8
+ required: true
9
+ description: Normalized staging org
10
+ primary_workload:
11
+ required: false
12
+ default: ""
13
+ description: Optional primary workload override
14
+ outputs:
15
+ primary_workload:
16
+ description: Workload whose staging image was previewed
17
+ value: ${{ steps.workloads.outputs.primary }}
18
+ staging_image:
19
+ description: Captured staging image for approval
20
+ value: ${{ steps.preview.outputs.staging_image }}
21
+ production_commit:
22
+ description: Last recorded production release commit, if available
23
+ value: ${{ steps.preview.outputs.production_commit }}
24
+ runs:
25
+ using: composite
26
+ steps:
27
+ - name: Resolve primary workload
28
+ id: workloads
29
+ env:
30
+ STAGING_APP_NAME: ${{ inputs.staging_app_name }}
31
+ PRIMARY_WORKLOAD: ${{ inputs.primary_workload }}
32
+ shell: bash
33
+ run: |
34
+ set -euo pipefail
35
+
36
+ ruby - "${STAGING_APP_NAME}" "${PRIMARY_WORKLOAD}" >> "$GITHUB_OUTPUT" <<'RUBY'
37
+ require "yaml"
38
+
39
+ app = ARGV.fetch(0)
40
+ requested_primary = ARGV.fetch(1, "").to_s.strip
41
+ data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
42
+ apps = data["apps"] || {}
43
+ app_config = apps[app]
44
+
45
+ unless app_config
46
+ warn "Error: app '#{app}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
47
+ warn " Fix the STAGING_APP_NAME repository variable or add the app to controlplane.yml."
48
+ exit 1
49
+ end
50
+
51
+ workloads = Array(app_config["app_workloads"]).map(&:to_s).reject(&:empty?)
52
+ workloads = ["rails"] if workloads.empty?
53
+
54
+ primary =
55
+ if requested_primary.empty?
56
+ if workloads.length == 1
57
+ workloads.first
58
+ elsif workloads.include?("rails")
59
+ "rails"
60
+ else
61
+ warn "::error::PRIMARY_WORKLOAD is not configured and app '#{app}' has multiple workloads: #{workloads.join(', ')}."
62
+ warn " Set the PRIMARY_WORKLOAD repository variable to one of these workloads."
63
+ exit 1
64
+ end
65
+ elsif workloads.include?(requested_primary)
66
+ requested_primary
67
+ else
68
+ warn "::error::PRIMARY_WORKLOAD '#{requested_primary}' is not one of: #{workloads.join(', ')}."
69
+ exit 1
70
+ end
71
+
72
+ puts "primary=#{primary}"
73
+ RUBY
74
+ - name: Compare recorded production release to staging
75
+ id: preview
76
+ env:
77
+ ACTION_PATH: ${{ github.action_path }}
78
+ STAGING_APP_NAME: ${{ inputs.staging_app_name }}
79
+ CPLN_ORG_STAGING: ${{ inputs.staging_org }}
80
+ PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
81
+ shell: bash
82
+ run: bash "${ACTION_PATH}/preview-promotion.sh"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # This job has only the staging token. Production provenance comes from the
5
+ # successful production-* release record and is checked against the live image
6
+ # by the protected deployment job after approval.
7
+ export GH_HOST="${GITHUB_SERVER_URL#https://}"
8
+ export GH_ENTERPRISE_TOKEN="${GH_TOKEN:-}"
9
+ workload_json="$(cpln workload get "$PRIMARY_WORKLOAD" --gvc "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" -o json)"
10
+ staging_image="$(jq -r '.spec.containers[0].image // empty' <<< "$workload_json")"
11
+ if [[ "$staging_image" == /org/*/image/* ]]; then
12
+ staging_image="${staging_image##*/image/}"
13
+ elif [[ "$staging_image" == *.registry.cpln.io/* ]]; then
14
+ staging_image="${staging_image#*.registry.cpln.io/}"
15
+ fi
16
+ staging_tag="${staging_image%%@*}"
17
+ staging_commit="${staging_tag##*_}"
18
+ if [[ "$staging_image" == *$'\n'* || "$staging_image" == *$'\r'* ]] || ! [[ "$staging_commit" =~ ^[0-9a-f]{40}$ ]]; then
19
+ echo "::error::The deployed staging image must include a full lowercase commit SHA suffix."
20
+ exit 1
21
+ fi
22
+ echo "staging_image=$staging_image" >> "$GITHUB_OUTPUT"
23
+ {
24
+ echo "## Commits ready for production"
25
+ echo
26
+ echo "Preview captured before production approval. Baseline: the last recorded successful production release; live production is verified after approval."
27
+ echo
28
+ } >> "$GITHUB_STEP_SUMMARY"
29
+
30
+ # Limit release discovery to the latest 100 entries. Missing provenance is
31
+ # explicit rather than falling back to a moving branch or an unrelated release.
32
+ if ! releases="$(timeout 30s gh api "repos/${GH_REPO}/releases?per_page=100")"; then
33
+ echo "Comparison unavailable: GitHub could not read the production release history." >> "$GITHUB_STEP_SUMMARY"
34
+ exit 0
35
+ fi
36
+ 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")"
37
+ if [[ -z "$production_release" ]]; then
38
+ echo "Comparison unavailable: no successful production release was found in the latest 100 releases." >> "$GITHUB_STEP_SUMMARY"
39
+ exit 0
40
+ fi
41
+ # Resolve the tag itself: target_commitish can be a branch name on older releases.
42
+ if ! production_json="$(timeout 30s gh api "repos/${GH_REPO}/commits/${production_release}")"; then
43
+ echo "Comparison unavailable: GitHub could not resolve the recorded production release." >> "$GITHUB_STEP_SUMMARY"
44
+ exit 0
45
+ fi
46
+ production_commit="$(jq -r '.sha // empty' <<< "$production_json")"
47
+ if ! [[ "$production_commit" =~ ^[0-9a-f]{40}$ ]]; then
48
+ echo "Comparison unavailable: the recorded production release has no full commit SHA." >> "$GITHUB_STEP_SUMMARY"
49
+ exit 0
50
+ fi
51
+ echo "production_commit=$production_commit" >> "$GITHUB_OUTPUT"
52
+
53
+ repository_url="${GITHUB_SERVER_URL}/${GH_REPO}"
54
+ echo "[Recorded production release](${repository_url}/releases/tag/${production_release})" >> "$GITHUB_STEP_SUMMARY"
55
+ {
56
+ echo "Recorded production release: [\`${production_commit:0:7}\`](${repository_url}/commit/${production_commit})"
57
+ echo "Staging: [\`${staging_commit:0:7}\`](${repository_url}/commit/${staging_commit})"
58
+ echo
59
+ echo "[Compare production to staging](${repository_url}/compare/${production_commit}...${staging_commit})"
60
+ echo
61
+ } >> "$GITHUB_STEP_SUMMARY"
62
+ if [[ "${production_commit}" == "${staging_commit}" ]]; then
63
+ echo "Production and staging use the same commit; there are no commits to promote." >> "$GITHUB_STEP_SUMMARY"
64
+ exit 0
65
+ fi
66
+
67
+ comparison="$(mktemp)"
68
+ trap 'rm -f "$comparison" "${comparison}.summary"' EXIT
69
+ # One bounded page; the compare link provides the complete diff and history.
70
+ if ! timeout 30s gh api "repos/${GH_REPO}/compare/${production_commit}...${staging_commit}?per_page=100&page=1" > "$comparison"; then
71
+ echo "Commit list unavailable: GitHub could not compare these deployed commits. Use the compare link above." >> "$GITHUB_STEP_SUMMARY"
72
+ exit 0
73
+ fi
74
+
75
+ if ! jq -r --arg repository_url "$repository_url" '
76
+ "\(.ahead_by) commit(s) on staging that are not in the recorded production release.", "",
77
+ (if .status == "diverged" or .status == "behind" then
78
+ "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."
79
+ else empty end), "",
80
+ "<ul>",
81
+ (.commits[:100][] |
82
+ "<li><a href=\"\($repository_url)/commit/\(.sha)\">\(.sha[:7])</a> " +
83
+ (.commit.message | split("\n")[0] | @html) + "</li>"),
84
+ "</ul>", "",
85
+ (if .ahead_by > 100 then "Showing the first 100 commits; use the compare link for the complete list." else empty end)
86
+ ' "$comparison" > "${comparison}.summary"; then
87
+ echo "Commit list unavailable: GitHub returned incomplete comparison data." >> "$GITHUB_STEP_SUMMARY"
88
+ exit 0
89
+ fi
90
+ cat "${comparison}.summary" >> "$GITHUB_STEP_SUMMARY"
@@ -23,13 +23,6 @@ jobs:
23
23
  runs-on: ubuntu-latest
24
24
  timeout-minutes: 30
25
25
  steps:
26
- # No explicit ref: checkout's default resolves to this event's recorded commit
27
- # (GITHUB_SHA), which is trusted base-repository code and is exempt from checkout v7's fork-PR guard.
28
- - name: Checkout generated cpflow actions
29
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30
- with:
31
- persist-credentials: false
32
-
33
26
  - name: Checkout control-plane-flow actions
34
27
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35
28
  with:
@@ -39,7 +32,7 @@ jobs:
39
32
  persist-credentials: false
40
33
 
41
34
  - name: Validate required secrets and variables
42
- uses: ./.github/actions/cpflow-validate-config
35
+ uses: ./.cpflow/.github/actions/cpflow-validate-config
43
36
  env:
44
37
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
45
38
  with:
@@ -54,14 +47,14 @@ jobs:
54
47
 
55
48
  - name: Resolve review app config
56
49
  id: review-config
57
- uses: ./.github/actions/cpflow-resolve-review-config
50
+ uses: ./.cpflow/.github/actions/cpflow-resolve-review-config
58
51
  with:
59
52
  working_directory: app
60
53
  configured_cpln_org_staging: ${{ vars.CPLN_ORG_STAGING }}
61
54
  configured_review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }}
62
55
 
63
56
  - name: Setup environment
64
- uses: ./.github/actions/cpflow-setup-environment
57
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
65
58
  with:
66
59
  token: ${{ secrets.CPLN_TOKEN_STAGING }}
67
60
  org: ${{ steps.review-config.outputs.cpln_org }}