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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +16 -38
- data/.agents/bin/README.md +7 -8
- data/.agents/legacy-workflow-policy.yml +11 -0
- data/.agents/shaka.md +20 -0
- data/.agents/trusted-actions.yml +12 -0
- data/.coderabbit.yaml +1 -0
- data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
- data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
- data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
- data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +3 -10
- data/.github/workflows/cpflow-delete-review-app.yml +4 -11
- data/.github/workflows/cpflow-deploy-review-app.yml +19 -14
- data/.github/workflows/cpflow-deploy-staging.yml +17 -12
- data/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
- data/.github/workflows/rspec.yml +24 -0
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +26 -3
- data/CONTRIBUTING.md +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +5 -2
- data/docs/ai-github-flow-prompt.md +104 -8
- data/docs/ci-automation.md +258 -60
- data/docs/commands.md +32 -16
- data/docs/postgres-review-apps.md +90 -0
- data/docs/postgres.md +233 -375
- data/docs/secrets-and-env-values.md +42 -0
- data/docs/sidebars.ts +1 -0
- data/lib/command/ai_github_flow_prompt.rb +10 -13
- data/lib/command/ai_github_flow_prompt.txt +103 -0
- data/lib/command/apply_template.rb +36 -4
- data/lib/command/base.rb +56 -3
- data/lib/command/delete.rb +174 -4
- data/lib/command/deploy_image.rb +6 -0
- data/lib/command/generate.rb +247 -3
- data/lib/command/generate_github_actions.rb +15 -17
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +107 -43
- data/lib/core/config.rb +73 -0
- data/lib/core/controlplane.rb +22 -2
- data/lib/core/controlplane_api.rb +28 -2
- data/lib/core/controlplane_api_direct.rb +17 -0
- data/lib/core/disposable_postgres_helpers.rb +167 -0
- data/lib/core/doctor_service.rb +7 -0
- data/lib/core/github_flow_readiness/checks.rb +2 -1
- data/lib/core/github_flow_readiness_service.rb +4 -0
- data/lib/core/repo_introspection.rb +112 -2
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/.dockerignore +16 -0
- data/lib/generator_templates/Dockerfile +9 -5
- data/lib/generator_templates/entrypoint.sh +2 -0
- data/lib/generator_templates/templates/postgres.yml +9 -0
- data/lib/generator_templates_sqlite/controlplane.yml +2 -1
- data/lib/generator_templates_sqlite/release_script.sh +1 -1
- data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +88 -47
- 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
|
-
|
|
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: <%= ... %>`
|
data/lib/cpflow/version.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
79
|
-
|
|
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 ./
|
|
@@ -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/
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
136
|
-
`__CPFLOW_GITHUB_ACTIONS_REF__`.
|
|
137
|
-
helper
|
|
138
|
-
|
|
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.
|
|
144
|
-
release
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
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 }}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [[ "${
|
|
486
|
-
staging_tag="${
|
|
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 "::
|
|
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:
|
|
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:
|
|
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}."
|