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
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "uri"
4
5
 
5
- module RepoIntrospection
6
+ module RepoIntrospection # rubocop:disable Metrics/ModuleLength
6
7
  DEFAULT_APP_PREFIX = "my-app"
7
8
  RUBY_VERSION_DIRECTIVE_PATTERN = /^\s*ruby\s+['"\d]/
8
9
  RUBY_VERSION_DIRECTIVE_PREFIX = /^\s*ruby\s+/
@@ -87,6 +88,49 @@ module RepoIntrospection
87
88
  sqlite_database_config?(production)
88
89
  end
89
90
 
91
+ # Returns literal SQLite database paths from the production configuration.
92
+ # Dynamic ERB values are intentionally omitted because the generator cannot
93
+ # safely redirect a path that is only known at runtime.
94
+ def self.sqlite_database_paths_in_production(root)
95
+ path = File.join(root, "config/database.yml")
96
+ return [] unless File.file?(path)
97
+
98
+ parsed = safe_load_database_yml(File.read(path))
99
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
100
+ return [] unless production.is_a?(Hash) && sqlite_database_config?(production)
101
+
102
+ database_connection_configs(production).filter_map do |config|
103
+ sqlite_database_path(config)
104
+ end.uniq
105
+ end
106
+
107
+ def self.unresolved_sqlite_database_paths_in_production?(root)
108
+ path = File.join(root, "config/database.yml")
109
+ return false unless File.file?(path)
110
+
111
+ parsed = safe_load_database_yml(File.read(path))
112
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
113
+ return false unless production.is_a?(Hash) && sqlite_database_config?(production)
114
+
115
+ database_connection_configs(production).any? do |config|
116
+ sqlite_database_path(config).nil? && !sqlite_in_memory_database_config?(config)
117
+ end
118
+ end
119
+
120
+ def self.dynamic_database_url_in_production?(root)
121
+ path = File.join(root, "config/database.yml")
122
+ return false unless File.file?(path)
123
+
124
+ parsed = safe_load_database_yml(File.read(path))
125
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
126
+ return false unless production.is_a?(Hash)
127
+
128
+ database_connection_configs(production).any? do |config|
129
+ url = config["url"]
130
+ url.is_a?(String) && url.include?("__erb__") && !sqlite_database_url?(url)
131
+ end
132
+ end
133
+
90
134
  # Determines whether a database config hash uses SQLite. Handles both
91
135
  # the single-database shape (top-level `adapter`/`url`) and Rails 6.1+ multi-database
92
136
  # shape where each connection sits one level deeper (`primary:`, `cache:`, etc.).
@@ -106,7 +150,15 @@ module RepoIntrospection
106
150
  # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- ternary nil/true/false is load-bearing for the caller
107
151
  def self.direct_sqlite_database_config?(config)
108
152
  url = config["url"]
109
- return sqlite_database_url?(url) if url.is_a?(String) && !url.strip.empty?
153
+ if url.is_a?(String) && !url.strip.empty?
154
+ if url.include?("__erb__")
155
+ return true if sqlite_database_url?(url)
156
+
157
+ return sqlite_adapter_in_hash?(config)
158
+ end
159
+
160
+ return sqlite_database_url?(url)
161
+ end
110
162
 
111
163
  return sqlite_adapter_in_hash?(config) if config["adapter"].is_a?(String)
112
164
 
@@ -128,6 +180,64 @@ module RepoIntrospection
128
180
  config.is_a?(Hash) && (config.key?("adapter") || config.key?("url"))
129
181
  end
130
182
 
183
+ def self.database_connection_configs(config)
184
+ return [config] unless direct_sqlite_database_config?(config).nil?
185
+
186
+ config.values.select { |value| database_connection_config?(value) }
187
+ end
188
+
189
+ def self.sqlite_database_path(config)
190
+ return if sqlite_in_memory_database_config?(config)
191
+
192
+ url = config["url"]
193
+ if url.is_a?(String) && !url.strip.empty?
194
+ return sqlite_database_path_from_url(url) if sqlite_database_url?(url)
195
+
196
+ return
197
+ end
198
+
199
+ database = config["database"]
200
+ database.strip if literal_database_path?(database)
201
+ end
202
+
203
+ def self.sqlite_database_path_from_url(url)
204
+ parser = URI::RFC2396_Parser.new
205
+ uri = parser.parse(url.strip)
206
+ encoded_path = uri.opaque ? uri.opaque.split("?", 2).first : uri.path
207
+ encoded_path = sqlite_file_uri_path(encoded_path, parser)
208
+ parser.unescape(encoded_path) if literal_database_path?(encoded_path)
209
+ rescue URI::InvalidURIError
210
+ nil
211
+ end
212
+
213
+ def self.sqlite_file_uri_path(path, parser)
214
+ return path unless path&.downcase&.start_with?("file:")
215
+
216
+ uri = parser.parse(path)
217
+ uri.opaque || uri.path
218
+ end
219
+
220
+ def self.sqlite_in_memory_database_config?(config)
221
+ value = config["url"] || config["database"]
222
+ return false unless value.is_a?(String)
223
+
224
+ identifier = value.strip.sub(/\Asqlite3?:/i, "")
225
+ encoded_path, query = identifier.split("?", 2)
226
+ path = URI::RFC2396_Parser.new.unescape(encoded_path)
227
+ path == ":memory:" || path == "file::memory:" || memory_mode_query?(query)
228
+ end
229
+
230
+ def self.memory_mode_query?(query)
231
+ query.to_s.split("&").any? do |pair|
232
+ key, value = pair.split("=", 2)
233
+ key.casecmp?("mode") && value&.casecmp?("memory")
234
+ end
235
+ end
236
+
237
+ def self.literal_database_path?(value)
238
+ value.is_a?(String) && !value.strip.empty? && !value.include?("__erb__")
239
+ end
240
+
131
241
  def self.safe_load_database_yml(raw_contents)
132
242
  # ERB conditionals can change YAML structure, so avoid guessing. Output-only
133
243
  # ERB is stubbed as a scalar so common Rails defaults like `pool: <%= ... %>`
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "6.0.0.rc.0"
4
+ VERSION = "6.0.1"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -0,0 +1,16 @@
1
+ .git
2
+ .github
3
+ .env*
4
+ !.env.example
5
+ .bundle
6
+ .ruby-lsp
7
+ coverage
8
+ log/*
9
+ node_modules
10
+ storage/*
11
+ tmp/*
12
+ vendor/bundle
13
+ config/master.key
14
+ config/credentials/*.key
15
+ db/*.sqlite3
16
+ db/*.sqlite3-*
@@ -38,9 +38,13 @@ RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \
38
38
  # install ruby gems
39
39
  COPY Gemfile* ./
40
40
 
41
- RUN bundle config set without 'development test' && \
41
+ RUN apt-get update -qq && \
42
+ apt-get install --no-install-recommends -y build-essential && \
43
+ bundle config set without 'development test' && \
42
44
  bundle config set with 'production' && \
43
- bundle install --jobs=3 --retry=3
45
+ bundle install --jobs=3 --retry=3 && \
46
+ apt-get purge -y --auto-remove build-essential && \
47
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
44
48
 
45
49
  COPY . ./
46
50
 
@@ -75,11 +79,11 @@ RUN if [ -f package.json ]; then \
75
79
 
76
80
  ENV RAILS_ENV=production
77
81
 
78
- # compiling assets requires any value for ENV of SECRET_KEY_BASE
79
- ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK
82
+ # Asset compilation requires a non-blank secret, but the placeholder must not
83
+ # remain in the final image environment.
80
84
 
81
85
  __ASSET_PRECOMPILE_HOOK_RUN__
82
- RUN rails assets:precompile
86
+ RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK rails assets:precompile
83
87
 
84
88
  # add entrypoint
85
89
  COPY .controlplane/entrypoint.sh ./
@@ -2,6 +2,8 @@
2
2
  set -e
3
3
  # Runs before the main command
4
4
 
5
+ __SQLITE_DATABASE_SETUP__
6
+
5
7
  is_rails_server_command() {
6
8
  if [ "${1:-}" = "env" ]; then
7
9
  shift
@@ -19,6 +19,9 @@ spec:
19
19
  ---
20
20
  kind: secret
21
21
  name: "{{APP_NAME}}-pg"
22
+ # Only newly created dynamically named review-app helpers acquire this marker.
23
+ tags:
24
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
22
25
  description: ''
23
26
  type: dictionary
24
27
  data:
@@ -28,6 +31,9 @@ data:
28
31
  ---
29
32
  kind: secret
30
33
  name: "{{APP_NAME}}-pg-script"
34
+ # Only newly created dynamically named review-app helpers acquire this marker.
35
+ tags:
36
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
31
37
  type: opaque
32
38
  data:
33
39
  encoding: base64
@@ -98,6 +104,9 @@ description: "{{APP_NAME}}-pg-identity"
98
104
  ---
99
105
  kind: policy
100
106
  name: "{{APP_NAME}}-pg-access"
107
+ # Only newly created dynamically named review-app helpers acquire this marker.
108
+ tags:
109
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
101
110
  description: "{{APP_NAME}}-pg-access"
102
111
  bindings:
103
112
  - permissions:
@@ -1,7 +1,8 @@
1
1
  # Keys beginning with "cpln_" correspond to your settings in Control Plane.
2
2
  #
3
3
  # Generated baseline for a Rails app that uses SQLite in production. This setup
4
- # keeps `/app/db` and `/app/storage` on persistent volumes.
4
+ # keeps SQLite data under `/app/data` and Active Storage under `/app/storage`
5
+ # on persistent volumes without hiding migrations shipped in `/app/db`.
5
6
 
6
7
  allow_org_override_by_env: true
7
8
  allow_app_override_by_env: true
@@ -12,7 +12,7 @@ error_exit() {
12
12
 
13
13
  log "Running release_script.sh per controlplane.yml"
14
14
 
15
- mkdir -p db storage
15
+ mkdir -p data storage
16
16
 
17
17
  if [ -x ./bin/rails ]; then
18
18
  log "Run DB migrations"
@@ -16,7 +16,8 @@ spec:
16
16
  # See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details.
17
17
  protocol: http
18
18
  volumes:
19
- - path: /app/db
19
+ # Keep migrations and schema from the image visible under /app/db.
20
+ - path: /app/data
20
21
  recoveryPolicy: retain
21
22
  uri: cpln://volumeset/app-db
22
23
  - path: /app/storage
@@ -132,21 +132,30 @@ production org, using production-only secrets and values.
132
132
 
133
133
  ## Version Locking
134
134
 
135
- Generated wrappers pin Control Plane Flow with a release tag, for example
136
- `__CPFLOW_GITHUB_ACTIONS_REF__`. Reusable review-app, staging, cleanup, and
137
- helper workflows pin the tag in their `uses:` ref. Production promotion pins
138
- the same tag in the `Checkout control-plane-flow actions` step so the
135
+ Freshly generated wrappers start with a Control Plane Flow release tag, for
136
+ example `__CPFLOW_GITHUB_ACTIONS_REF__`. Before review and merge, use the pin
137
+ helper below to replace that tag with its immutable commit SHA and retain the
138
+ reviewed tag in a readable comment. Reusable review-app, staging, cleanup, and
139
+ helper workflows then use that SHA. Production promotion uses the same SHA and
140
+ tag in the `Checkout control-plane-flow actions` step so the
139
141
  caller-owned job can keep `environment: production` and receive production
140
142
  environment secrets directly.
141
143
 
142
144
  Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same
143
- checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the
144
- release tag your wrappers are pinned to: a `CPFLOW_VERSION=__CPFLOW_MINOR_SERIES__` runtime
145
- override goes with a wrapper pinned to `uses: ...@v__CPFLOW_MINOR_SERIES__` (substitute the
146
- release you pinned above).
145
+ checked-out upstream source. `CPFLOW_VERSION` is supported only while the
146
+ wrapper itself uses an actual release-tag ref; it must match that tag. Keep it
147
+ unset after replacing the wrapper ref with an immutable SHA because the
148
+ checked-out source already determines the runtime version.
147
149
 
148
- After updating the `cpflow` gem in this repo, update the generated wrappers in
149
- the same PR:
150
+ If an existing `bin/test-cpflow-github-flow` differs from cpflow's generated
151
+ validator, first preserve its downstream checks in executable
152
+ `bin/test-cpflow-github-flow-custom`, then remove or rename the old generated
153
+ validator before retrying the update.
154
+
155
+ After updating the `cpflow` gem in this repo, refresh the generated
156
+ validation helpers in the same PR. The default command
157
+ preserves every top-level workflow, including its refs, triggers, permissions, and
158
+ downstream customizations:
150
159
 
151
160
  ```sh
152
161
  cpflow update-github-actions
@@ -160,12 +169,30 @@ bundle exec cpflow update-github-actions
160
169
  bin/test-cpflow-github-flow bundle exec cpflow
161
170
  ```
162
171
 
172
+ Replace workflow wrappers only after explicitly selecting and reviewing them:
173
+
174
+ ```sh
175
+ cpflow update-github-actions --workflows FILE...
176
+ ```
177
+
178
+ Reapply required downstream customizations after a selected replacement, then
179
+ pin and validate the complete flow.
180
+
181
+ For a reviewed release, resolve its tag to the exact commit SHA, then pin and
182
+ validate that immutable source:
183
+
184
+ ```sh
185
+ bin/pin-cpflow-github-ref --version vX.Y.Z <40-character-control-plane-flow-commit-sha>
186
+ bin/test-cpflow-github-flow
187
+ ```
188
+
163
189
  Do not leave downstream apps pinned to a moving branch such as `main`. For a
164
190
  short-lived test of an unreleased upstream PR, pin to a full 40-character commit
165
- SHA and leave `CPFLOW_VERSION` unset:
191
+ SHA, use the upstream source's current in-progress version tag as the readable
192
+ `--version` label, and leave `CPFLOW_VERSION` unset:
166
193
 
167
194
  ```sh
168
- bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha>
195
+ bin/pin-cpflow-github-ref --version vX.Y.Z <40-character-control-plane-flow-commit-sha>
169
196
  bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow
170
197
  ```
171
198
 
@@ -42,4 +42,5 @@ jobs:
42
42
  uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@__CPFLOW_GITHUB_ACTIONS_REF__
43
43
  secrets:
44
44
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
45
+ REVIEW_APP_DOCKER_BUILD_SECRETS: ${{ secrets.REVIEW_APP_DOCKER_BUILD_SECRETS }}
45
46
  DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
@@ -21,4 +21,5 @@ jobs:
21
21
  staging_app_branch_default: "__STAGING_BRANCH_DEFAULT__"
22
22
  secrets:
23
23
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
24
+ DOCKER_BUILD_SECRETS: ${{ secrets.DOCKER_BUILD_SECRETS }}
24
25
  DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
@@ -46,18 +46,84 @@ concurrency:
46
46
  cancel-in-progress: false
47
47
 
48
48
  jobs:
49
+ preview-promotion:
50
+ if: github.event.inputs.confirm_promotion == 'promote'
51
+ runs-on: ubuntu-latest
52
+ timeout-minutes: 10
53
+ # No production Environment or production token: this finishes before approval.
54
+ outputs:
55
+ primary_workload: ${{ steps.preview.outputs.primary_workload }}
56
+ staging_image: ${{ steps.preview.outputs.staging_image }}
57
+ production_commit: ${{ steps.preview.outputs.production_commit }}
58
+ steps:
59
+ - name: Checkout repository
60
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
61
+ with:
62
+ persist-credentials: false
63
+
64
+ - name: Checkout control-plane-flow actions
65
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
66
+ with:
67
+ repository: shakacode/control-plane-flow
68
+ ref: __CPFLOW_GITHUB_ACTIONS_REF__
69
+ path: .cpflow
70
+ persist-credentials: false
71
+
72
+ - name: Normalize staging org
73
+ id: staging-org
74
+ env:
75
+ STAGING_ORG: ${{ vars.CPLN_ORG_STAGING }}
76
+ shell: bash
77
+ run: |
78
+ set -euo pipefail
79
+ org="${STAGING_ORG#"${STAGING_ORG%%[![:space:]]*}"}"
80
+ org="${org%"${org##*[![:space:]]}"}"
81
+ if ! [[ "$org" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
82
+ echo "::error::CPLN_ORG_STAGING must be a valid Control Plane org name."
83
+ exit 1
84
+ fi
85
+ echo "org=$org" >> "$GITHUB_OUTPUT"
86
+
87
+ - name: Setup staging environment
88
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
89
+ with:
90
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
91
+ org: ${{ steps.staging-org.outputs.org }}
92
+ working_directory: .cpflow
93
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
94
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
95
+ # The setup action validates CPFLOW_VERSION against this full workflow ref.
96
+ control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@__CPFLOW_GITHUB_ACTIONS_REF__
97
+
98
+ - name: Preview commits before production approval
99
+ id: preview
100
+ uses: ./.cpflow/.github/actions/cpflow-preview-promotion
101
+ env:
102
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103
+ GH_REPO: ${{ github.repository }}
104
+ CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }}
105
+ with:
106
+ staging_app_name: ${{ vars.STAGING_APP_NAME }}
107
+ staging_org: ${{ steps.staging-org.outputs.org }}
108
+ primary_workload: ${{ vars.PRIMARY_WORKLOAD }}
109
+
49
110
  promote-to-production:
111
+ needs: preview-promotion
50
112
  if: github.event.inputs.confirm_promotion == 'promote'
51
113
  runs-on: ubuntu-latest
52
114
  # This normal caller-repo job declares the protected production Environment
53
115
  # directly, so GitHub exposes environment secrets in this job after the
54
116
  # environment gate. Do not move production promotion back behind a
55
117
  # cross-repo reusable workflow; environment secrets are not available there.
56
- environment: production
118
+ # The URL gives the deployment a "View deployment" link on GitHub.
119
+ environment:
120
+ name: production
121
+ url: ${{ steps.health-check.outputs.endpoint }}
57
122
  timeout-minutes: 45
58
123
  outputs:
59
124
  staging_app_name: ${{ steps.release-context.outputs.staging_app_name }}
60
125
  production_app_name: ${{ steps.release-context.outputs.production_app_name }}
126
+ staging_commit: ${{ steps.copy-image.outputs.staging_commit }}
61
127
 
62
128
  steps:
63
129
  - name: Checkout repository
@@ -90,7 +156,7 @@ jobs:
90
156
  fi
91
157
 
92
158
  - name: Validate required secrets and variables
93
- uses: ./.github/actions/cpflow-validate-config
159
+ uses: ./.cpflow/.github/actions/cpflow-validate-config
94
160
  # Pass secrets via env so the composite action checks indirect shell
95
161
  # variables instead of interpolating secret values into a run script.
96
162
  env:
@@ -171,7 +237,7 @@ jobs:
171
237
  } >> "$GITHUB_OUTPUT"
172
238
 
173
239
  - name: Setup production environment
174
- uses: ./.github/actions/cpflow-setup-environment
240
+ uses: ./.cpflow/.github/actions/cpflow-setup-environment
175
241
  with:
176
242
  token: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
177
243
  org: ${{ steps.cpln-orgs.outputs.production }}
@@ -237,7 +303,7 @@ jobs:
237
303
 
238
304
  - name: Detect release phase support
239
305
  id: release-phase
240
- uses: ./.github/actions/cpflow-detect-release-phase
306
+ uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
241
307
  with:
242
308
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
243
309
 
@@ -440,6 +506,33 @@ jobs:
440
506
 
441
507
  echo "image=${staging_image}" >> "$GITHUB_OUTPUT"
442
508
 
509
+ - name: Verify approved promotion snapshot
510
+ id: verify-preview
511
+ env:
512
+ PREVIEW_WORKLOAD: ${{ needs.preview-promotion.outputs.primary_workload }}
513
+ CURRENT_PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
514
+ PREVIEW_IMAGE: ${{ needs.preview-promotion.outputs.staging_image }}
515
+ PREVIEW_PRODUCTION_COMMIT: ${{ needs.preview-promotion.outputs.production_commit }}
516
+ CURRENT_STAGING_IMAGE: ${{ steps.staging-image.outputs.image }}
517
+ CURRENT_PRODUCTION_IMAGE: ${{ steps.capture-current.outputs.current_image }}
518
+ shell: bash
519
+ run: |
520
+ set -euo pipefail
521
+ if [[ -z "$PREVIEW_WORKLOAD" || "$PREVIEW_WORKLOAD" != "$CURRENT_PRIMARY_WORKLOAD" ]]; then
522
+ echo "::error::The production primary workload differs from the preview. Set PRIMARY_WORKLOAD as a repository variable shared by both jobs and rerun."
523
+ exit 1
524
+ fi
525
+ if [[ -z "$PREVIEW_IMAGE" || "$CURRENT_STAGING_IMAGE" != "$PREVIEW_IMAGE" ]]; then
526
+ echo "::error::Staging changed since the preview. Rerun promotion and review the new comparison."
527
+ exit 1
528
+ fi
529
+ production_tag="${CURRENT_PRODUCTION_IMAGE%%@*}"
530
+ production_commit="${production_tag##*_}"
531
+ if [[ -n "$PREVIEW_PRODUCTION_COMMIT" && "$production_commit" != "$PREVIEW_PRODUCTION_COMMIT" ]]; then
532
+ echo "::error::Live production does not match its recorded release. Reconcile the production release record, then rerun promotion."
533
+ exit 1
534
+ fi
535
+
443
536
  - name: Set up Docker Buildx
444
537
  uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
445
538
 
@@ -476,22 +569,26 @@ jobs:
476
569
  exit 1
477
570
  fi
478
571
 
479
- if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
572
+ staging_image_without_digest="${staging_image%%@*}"
573
+ if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image_without_digest}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
480
574
  echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion."
481
575
  exit 1
482
576
  fi
483
577
 
484
578
  staging_tag=""
485
- if [[ "${staging_image}" == *@* ]]; then
486
- staging_tag="${staging_image##*@}"
487
- elif [[ "${staging_image}" == *:* ]]; then
488
- staging_tag="${staging_image##*:}"
579
+ if [[ "${staging_image_without_digest}" == *:* ]]; then
580
+ staging_tag="${staging_image_without_digest##*:}"
489
581
  fi
490
582
  staging_commit=""
491
583
  if [[ "${staging_tag}" == *_* ]]; then
492
584
  staging_commit="${staging_tag##*_}"
493
585
  else
494
- echo "::warning::Staging image '${staging_image}' did not include a '_<commit>' suffix; production image tag will omit the commit suffix."
586
+ echo "::error::Staging image '${staging_image}' does not include the commit suffix required for a traceable production release."
587
+ exit 1
588
+ fi
589
+ if ! [[ "${staging_commit}" =~ ^[0-9a-f]{40}$ ]]; then
590
+ echo "::error::Staging image '${staging_image}' has an invalid commit suffix; expected a full lowercase Git commit SHA."
591
+ exit 1
495
592
  fi
496
593
 
497
594
  # The workflow-level concurrency group serializes this sequence so two
@@ -566,8 +663,10 @@ jobs:
566
663
  fi
567
664
 
568
665
  echo "image=${production_image}" >> "$GITHUB_OUTPUT"
666
+ echo "staging_commit=${staging_commit}" >> "$GITHUB_OUTPUT"
569
667
 
570
668
  - name: Deploy image to production
669
+ id: deploy-production
571
670
  env:
572
671
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
573
672
  CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
@@ -576,6 +675,16 @@ jobs:
576
675
  run: |
577
676
  set -euo pipefail
578
677
 
678
+ deployment_marker_dir="$(mktemp -d)"
679
+ export CPFLOW_DEPLOYMENT_STARTED_FILE="${deployment_marker_dir}/started"
680
+ record_deployment_state() {
681
+ if [[ -f "${CPFLOW_DEPLOYMENT_STARTED_FILE}" ]]; then
682
+ echo "deployment_started=true" >> "${GITHUB_OUTPUT}"
683
+ fi
684
+ rm -rf "${deployment_marker_dir}"
685
+ }
686
+ trap record_deployment_state EXIT
687
+
579
688
  deploy_args=(-a "${PRODUCTION_APP_NAME}")
580
689
  if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
581
690
  deploy_args+=("${RELEASE_PHASE_FLAG}")
@@ -589,7 +698,7 @@ jobs:
589
698
 
590
699
  - name: Wait for deployment health
591
700
  id: health-check
592
- uses: ./.github/actions/cpflow-wait-for-health
701
+ uses: ./.cpflow/.github/actions/cpflow-wait-for-health
593
702
  with:
594
703
  workload_name: ${{ steps.workloads.outputs.primary }}
595
704
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
@@ -599,7 +708,11 @@ jobs:
599
708
  accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }}
600
709
 
601
710
  - name: Roll back on failure
602
- if: failure() && steps.capture-current.outcome == 'success'
711
+ if: >-
712
+ failure() &&
713
+ steps.deploy-production.outputs.deployment_started == 'true' &&
714
+ steps.capture-current.outputs.rollback_state != '' &&
715
+ steps.capture-current.outputs.rollback_state != '{}'
603
716
  env:
604
717
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
605
718
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
@@ -661,7 +774,11 @@ jobs:
661
774
  fi
662
775
 
663
776
  - name: Wait for rollback readiness
664
- if: failure() && steps.capture-current.outcome == 'success'
777
+ if: >-
778
+ failure() &&
779
+ steps.deploy-production.outputs.deployment_started == 'true' &&
780
+ steps.capture-current.outputs.rollback_state != '' &&
781
+ steps.capture-current.outputs.rollback_state != '{}'
665
782
  env:
666
783
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
667
784
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
@@ -770,6 +887,7 @@ jobs:
770
887
  GITHUB_RUN_ID: ${{ github.run_id }}
771
888
  STAGING_APP_NAME: ${{ needs.promote-to-production.outputs.staging_app_name }}
772
889
  PRODUCTION_APP_NAME: ${{ needs.promote-to-production.outputs.production_app_name }}
890
+ STAGING_COMMIT: ${{ needs.promote-to-production.outputs.staging_commit }}
773
891
  shell: bash
774
892
  run: |
775
893
  set -euo pipefail
@@ -779,5 +897,6 @@ jobs:
779
897
  release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}"
780
898
 
781
899
  gh release create "${release_tag}" \
900
+ --target "${STAGING_COMMIT}" \
782
901
  --title "Production Release ${release_date} ${timestamp}" \
783
902
  --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}."