cpflow 5.0.0.rc.1 → 5.0.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.
- checksums.yaml +4 -4
- data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/action.yml +5 -0
- data/{lib/github_flow_templates/.github → .github}/actions/cpflow-detect-release-phase/action.yml +7 -0
- data/.github/actions/cpflow-setup-environment/action.yml +161 -0
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +69 -0
- data/.github/workflows/cpflow-delete-review-app.yml +182 -0
- data/.github/workflows/cpflow-deploy-review-app.yml +507 -0
- data/.github/workflows/cpflow-deploy-staging.yml +168 -0
- data/.github/workflows/cpflow-help-command.yml +78 -0
- data/.github/workflows/cpflow-promote-staging-to-production.yml +510 -0
- data/.github/workflows/cpflow-review-app-help.yml +51 -0
- data/.github/workflows/rspec-shared.yml +3 -0
- data/.github/workflows/trigger-docs-site.yml +90 -0
- data/.rubocop.yml +14 -1
- data/CHANGELOG.md +43 -1
- data/CONTRIBUTING.md +27 -0
- data/Gemfile.lock +2 -2
- data/README.md +7 -3
- data/cpflow.gemspec +1 -1
- data/docs/ai-github-flow-prompt.md +1 -1
- data/docs/assets/cpflow-deploying.svg +46 -0
- data/docs/ci-automation.md +111 -8
- data/docs/commands.md +11 -5
- data/docs/thruster.md +149 -0
- data/docs/troubleshooting.md +8 -0
- data/lib/command/apply_template.rb +6 -2
- data/lib/command/base.rb +1 -0
- data/lib/command/cleanup_stale_apps.rb +53 -14
- data/lib/command/delete.rb +3 -1
- data/lib/command/deploy_image.rb +5 -2
- data/lib/command/generate.rb +7 -3
- data/lib/command/generate_github_actions.rb +21 -9
- data/lib/command/generator_helpers.rb +5 -1
- data/lib/command/info.rb +3 -1
- data/lib/command/run.rb +16 -1
- data/lib/command/test.rb +1 -3
- data/lib/core/controlplane.rb +17 -6
- data/lib/core/controlplane_api.rb +3 -1
- data/lib/core/controlplane_api_direct.rb +50 -27
- data/lib/core/doctor_service.rb +2 -2
- data/lib/core/github_flow_readiness_service.rb +26 -2
- data/lib/core/repo_introspection.rb +41 -3
- data/lib/core/shell.rb +3 -1
- data/lib/core/terraform_config/policy.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +27 -13
- data/lib/generator_templates/templates/rails.yml +4 -0
- data/lib/generator_templates_sqlite/templates/rails.yml +4 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +30 -1
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +10 -44
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +15 -114
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +10 -413
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +12 -123
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +10 -33
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +13 -475
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +12 -30
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +72 -0
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +89 -0
- data/rakelib/create_release.rake +4 -4
- metadata +26 -17
- data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +0 -98
- /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-build-docker-image/action.yml +0 -0
- /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/delete-app.sh +0 -0
- /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-validate-config/action.yml +0 -0
- /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-wait-for-health/action.yml +0 -0
|
@@ -1,28 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
class ControlplaneApiDirect
|
|
4
|
+
class RedactedDebugOutput
|
|
5
|
+
SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
|
|
6
|
+
HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
def <<(msg)
|
|
9
|
+
$stdout << redact(msg)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
def redact(msg)
|
|
15
|
+
msg.lines.map { |line| redact_line(line) }.join
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def redact_line(line)
|
|
19
|
+
match = line.match(HEADER_REGEX)
|
|
20
|
+
return line.gsub(/[\w\-._]{50,}/, "[REDACTED]") unless match
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
SAFE_HEADERS.any? { |h| h.casecmp(match[1]).zero? } ? line : "#{match[1]}: [REDACTED]\n"
|
|
23
|
+
end
|
|
15
24
|
end
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
class ForbiddenError < StandardError
|
|
27
|
+
attr_reader :url
|
|
28
|
+
|
|
29
|
+
def initialize(url:, response:)
|
|
30
|
+
@url = url
|
|
31
|
+
org = ControlplaneApiDirect.parse_org(url)
|
|
32
|
+
message =
|
|
33
|
+
if org
|
|
34
|
+
"Double check your org #{org}. #{response}"
|
|
35
|
+
else
|
|
36
|
+
"Control Plane API request to #{url} was forbidden. #{response}"
|
|
37
|
+
end
|
|
20
38
|
|
|
21
|
-
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
22
41
|
end
|
|
23
|
-
end
|
|
24
42
|
|
|
25
|
-
class ControlplaneApiDirect
|
|
26
43
|
API_METHODS = {
|
|
27
44
|
get: Net::HTTP::Get,
|
|
28
45
|
patch: Net::HTTP::Patch,
|
|
@@ -32,12 +49,6 @@ class ControlplaneApiDirect
|
|
|
32
49
|
}.freeze
|
|
33
50
|
API_HOSTS = { api: "https://api.cpln.io", logs: "https://logs.cpln.io" }.freeze
|
|
34
51
|
|
|
35
|
-
# API_TOKEN_REGEX = Regexp.union(
|
|
36
|
-
# /^[\w.]{155}$/, # CPLN_TOKEN format
|
|
37
|
-
# /^[\w\-._]{1134}$/ # 'cpln profile token' format
|
|
38
|
-
# ).freeze
|
|
39
|
-
|
|
40
|
-
API_TOKEN_REGEX = /^[\w\-._]+$/
|
|
41
52
|
API_TOKEN_EXPIRY_SECONDS = 300
|
|
42
53
|
|
|
43
54
|
class << self
|
|
@@ -52,7 +63,7 @@ class ControlplaneApiDirect
|
|
|
52
63
|
|
|
53
64
|
refresh_api_token if should_refresh_api_token?
|
|
54
65
|
|
|
55
|
-
request["Authorization"] =
|
|
66
|
+
request["Authorization"] = authorization_header
|
|
56
67
|
request.body = body.to_json if body
|
|
57
68
|
|
|
58
69
|
Shell.debug(method.upcase, "#{uri} #{body&.to_json}")
|
|
@@ -71,8 +82,7 @@ class ControlplaneApiDirect
|
|
|
71
82
|
when Net::HTTPNotFound
|
|
72
83
|
nil
|
|
73
84
|
when Net::HTTPForbidden
|
|
74
|
-
|
|
75
|
-
raise("Double check your org #{org}. #{response} #{response.body}")
|
|
85
|
+
raise ForbiddenError.new(url: url, response: response)
|
|
76
86
|
else
|
|
77
87
|
raise("#{response} #{response.body}")
|
|
78
88
|
end
|
|
@@ -87,6 +97,13 @@ class ControlplaneApiDirect
|
|
|
87
97
|
end
|
|
88
98
|
end
|
|
89
99
|
|
|
100
|
+
def authorization_header
|
|
101
|
+
token = api_token[:token]
|
|
102
|
+
return token if token.match?(/\ABearer\s+/i)
|
|
103
|
+
|
|
104
|
+
"Bearer #{token}"
|
|
105
|
+
end
|
|
106
|
+
|
|
90
107
|
# rubocop:disable Style/ClassVars
|
|
91
108
|
def api_token # rubocop:disable Metrics/MethodLength
|
|
92
109
|
return @@api_token if defined?(@@api_token)
|
|
@@ -101,7 +118,11 @@ class ControlplaneApiDirect
|
|
|
101
118
|
comes_from_profile: true
|
|
102
119
|
}
|
|
103
120
|
end
|
|
104
|
-
|
|
121
|
+
token = @@api_token[:token]
|
|
122
|
+
# Allow any token that does not contain line breaks. Scoped service-account
|
|
123
|
+
# tokens include punctuation such as '/', '+', ':', and '=', so format
|
|
124
|
+
# validation is deferred to the Control Plane API.
|
|
125
|
+
return @@api_token if token && !token.empty? && !token.match?(/[\r\n]/)
|
|
105
126
|
|
|
106
127
|
raise "Unknown API token format. " \
|
|
107
128
|
"Please re-run 'cpln profile login' or set the correct CPLN_TOKEN env variable."
|
|
@@ -112,7 +133,9 @@ class ControlplaneApiDirect
|
|
|
112
133
|
return false unless api_token[:comes_from_profile]
|
|
113
134
|
|
|
114
135
|
payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
|
|
115
|
-
|
|
136
|
+
return false unless payload.is_a?(Hash) && payload["exp"]
|
|
137
|
+
|
|
138
|
+
difference_in_seconds = payload["exp"].to_i - Time.now.to_i
|
|
116
139
|
|
|
117
140
|
difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
|
|
118
141
|
rescue JWT::DecodeError
|
|
@@ -129,6 +152,6 @@ class ControlplaneApiDirect
|
|
|
129
152
|
# rubocop:enable Style/ClassVars
|
|
130
153
|
|
|
131
154
|
def self.parse_org(url)
|
|
132
|
-
url.match(%r{^/org/([^/]+)})[1
|
|
155
|
+
url.match(%r{^/org/([^/]+)})&.[](1)
|
|
133
156
|
end
|
|
134
157
|
end
|
data/lib/core/doctor_service.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "bundler"
|
|
4
4
|
require "cgi"
|
|
5
5
|
require "net/http"
|
|
6
|
+
require "uri"
|
|
6
7
|
require "yaml"
|
|
7
8
|
|
|
8
9
|
require_relative "repo_introspection"
|
|
@@ -225,6 +226,9 @@ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
|
|
|
225
226
|
version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/)
|
|
226
227
|
end
|
|
227
228
|
|
|
229
|
+
# Returns true/false/nil — `nil` means "registry lookup failed" so partition_dependencies
|
|
230
|
+
# can route those into :unknown rather than :unavailable.
|
|
231
|
+
# rubocop:disable Style/ReturnNilInPredicateMethodDefinition, Naming/PredicateMethod
|
|
228
232
|
def rubygems_requirement_available?(dependency)
|
|
229
233
|
versions = fetch_rubygems_versions(dependency[:name])
|
|
230
234
|
return nil unless versions
|
|
@@ -232,13 +236,19 @@ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
|
|
|
232
236
|
requirement = dependency[:requirement]
|
|
233
237
|
versions.any? { |version| requirement.satisfied_by?(Gem::Version.new(version)) }
|
|
234
238
|
end
|
|
239
|
+
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition, Naming/PredicateMethod
|
|
235
240
|
|
|
241
|
+
# Same tri-state semantics as rubygems_requirement_available? — `nil` means lookup failed.
|
|
242
|
+
# The body is a single `include?` call, which `Naming/PredicateMethod` recognises as
|
|
243
|
+
# boolean-safe, so only the nil-return cop needs suppressing here.
|
|
244
|
+
# rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
236
245
|
def npm_dependency_available?(dependency)
|
|
237
246
|
versions = fetch_npm_versions(dependency[:name])
|
|
238
247
|
return nil unless versions
|
|
239
248
|
|
|
240
249
|
versions.include?(dependency[:exact_version])
|
|
241
250
|
end
|
|
251
|
+
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition
|
|
242
252
|
|
|
243
253
|
# Fan out registry lookups across a small thread pool. Each HTTP call has a 5s timeout
|
|
244
254
|
# (see `http_get`), and the join deadline below bounds cases such as DNS resolution
|
|
@@ -368,8 +378,8 @@ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
|
|
|
368
378
|
end
|
|
369
379
|
|
|
370
380
|
def http_get(uri)
|
|
371
|
-
http =
|
|
372
|
-
http.use_ssl =
|
|
381
|
+
http = build_http_client(uri)
|
|
382
|
+
http.use_ssl = uri.scheme == "https"
|
|
373
383
|
http.open_timeout = 5
|
|
374
384
|
http.read_timeout = 5
|
|
375
385
|
http.get(uri.request_uri)
|
|
@@ -378,6 +388,20 @@ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
|
|
|
378
388
|
nil
|
|
379
389
|
end
|
|
380
390
|
|
|
391
|
+
def build_http_client(uri)
|
|
392
|
+
proxy_uri = uri.find_proxy
|
|
393
|
+
return Net::HTTP.new(uri.host, uri.port) unless proxy_uri
|
|
394
|
+
|
|
395
|
+
Net::HTTP.new(
|
|
396
|
+
uri.host,
|
|
397
|
+
uri.port,
|
|
398
|
+
proxy_uri.hostname,
|
|
399
|
+
proxy_uri.port,
|
|
400
|
+
proxy_uri.user,
|
|
401
|
+
proxy_uri.password
|
|
402
|
+
)
|
|
403
|
+
end
|
|
404
|
+
|
|
381
405
|
def load_gem_dependencies
|
|
382
406
|
lockfile_path = root_path.join("Gemfile.lock")
|
|
383
407
|
return [] unless lockfile_path.file?
|
|
@@ -45,7 +45,7 @@ module RepoIntrospection
|
|
|
45
45
|
path = File.join(root, "Gemfile")
|
|
46
46
|
return unless File.file?(path)
|
|
47
47
|
|
|
48
|
-
ruby_lines = File.readlines(path, chomp: true).
|
|
48
|
+
ruby_lines = File.readlines(path, chomp: true).grep(RUBY_VERSION_DIRECTIVE_PREFIX)
|
|
49
49
|
ruby_line = ruby_lines.find { |line| line.match?(RUBY_VERSION_DIRECTIVE_PATTERN) }
|
|
50
50
|
warn_dynamic_ruby_directive if ruby_lines.any? && ruby_line.nil?
|
|
51
51
|
return unless ruby_line
|
|
@@ -84,10 +84,48 @@ module RepoIntrospection
|
|
|
84
84
|
production = parsed["production"]
|
|
85
85
|
return false unless production.is_a?(Hash)
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
sqlite_database_config?(production)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Determines whether a database config hash uses SQLite. Handles both
|
|
91
|
+
# the single-database shape (top-level `adapter`/`url`) and Rails 6.1+ multi-database
|
|
92
|
+
# shape where each connection sits one level deeper (`primary:`, `cache:`, etc.).
|
|
93
|
+
# Returns false on any explicit non-SQLite adapter so a mixed config (e.g. Postgres
|
|
94
|
+
# primary + SQLite cache) keeps the Postgres scaffold rather than a volume scaffold.
|
|
95
|
+
def self.sqlite_database_config?(config)
|
|
96
|
+
return false unless config.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
direct_result = direct_sqlite_database_config?(config)
|
|
99
|
+
return direct_result unless direct_result.nil?
|
|
100
|
+
|
|
101
|
+
nested_sqlite_database_config?(config)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns true/false when a direct `url` or `adapter` key is conclusive, or nil
|
|
105
|
+
# when neither key is present so the caller can check nested sub-configs.
|
|
106
|
+
# rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- ternary nil/true/false is load-bearing for the caller
|
|
107
|
+
def self.direct_sqlite_database_config?(config)
|
|
108
|
+
url = config["url"]
|
|
88
109
|
return sqlite_database_url?(url) if url.is_a?(String) && !url.strip.empty?
|
|
89
110
|
|
|
90
|
-
sqlite_adapter_in_hash?(
|
|
111
|
+
return sqlite_adapter_in_hash?(config) if config["adapter"].is_a?(String)
|
|
112
|
+
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition
|
|
116
|
+
|
|
117
|
+
def self.nested_sqlite_database_config?(config)
|
|
118
|
+
# In Rails multi-database configs, hash values with adapter/url keys are named
|
|
119
|
+
# connections such as primary:, cache:, or queue:. Scalar and incidental hash
|
|
120
|
+
# settings are ignored.
|
|
121
|
+
sub_configs = config.values.select { |value| database_connection_config?(value) }
|
|
122
|
+
return false if sub_configs.empty?
|
|
123
|
+
|
|
124
|
+
sub_configs.all? { |sub| sqlite_database_config?(sub) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.database_connection_config?(config)
|
|
128
|
+
config.is_a?(Hash) && (config.key?("adapter") || config.key?("url"))
|
|
91
129
|
end
|
|
92
130
|
|
|
93
131
|
def self.safe_load_database_yml(raw_contents)
|
data/lib/core/shell.rb
CHANGED
|
@@ -27,7 +27,9 @@ class Shell
|
|
|
27
27
|
shell.set_color(message, color_key)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# Prompts the user and returns whether they confirmed. Side-effecting on stdout/stdin,
|
|
31
|
+
# so the method name intentionally lacks `?` despite returning a boolean.
|
|
32
|
+
def self.confirm(message) # rubocop:disable Naming/PredicateMethod
|
|
31
33
|
shell.yes?("#{message} (y/N)")
|
|
32
34
|
end
|
|
33
35
|
|
|
@@ -141,7 +141,7 @@ module TerraformConfig
|
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
def validate_term!(term)
|
|
144
|
-
return if (%i[property rel tag] & term.keys).
|
|
144
|
+
return if (%i[property rel tag] & term.keys).one?
|
|
145
145
|
|
|
146
146
|
raise ArgumentError,
|
|
147
147
|
"Each term in `target_query.spec.terms` must contain exactly one of the following attributes: " \
|
data/lib/cpflow/version.rb
CHANGED
data/lib/cpflow.rb
CHANGED
|
@@ -307,7 +307,7 @@ module Cpflow
|
|
|
307
307
|
end
|
|
308
308
|
|
|
309
309
|
begin
|
|
310
|
-
Cpflow::Cli.validate_options!(options)
|
|
310
|
+
Cpflow::Cli.validate_options!(options, command_options: command_options)
|
|
311
311
|
|
|
312
312
|
config = Config.new(args, options, required_options)
|
|
313
313
|
|
|
@@ -333,25 +333,39 @@ module Cpflow
|
|
|
333
333
|
check_unknown_options!(except: @commands_with_extra_options)
|
|
334
334
|
stop_on_unknown_option!
|
|
335
335
|
|
|
336
|
-
def self.validate_options!(options
|
|
336
|
+
def self.validate_options!(options, command_options: ::Command::Base.all_options)
|
|
337
|
+
option_definitions = option_definitions_for(command_options)
|
|
338
|
+
|
|
337
339
|
options.each do |name, value|
|
|
338
340
|
normalized_name = ::Helpers.normalize_option_name(name)
|
|
339
341
|
raise "No value provided for option #{normalized_name}." if value.to_s.strip.empty?
|
|
340
342
|
|
|
341
|
-
option =
|
|
342
|
-
if option
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
option = option_definitions.find { |current_option| current_option[:name].to_s == name }
|
|
344
|
+
validate_option!(option, normalized_name, value) if option
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.option_definitions_for(command_options)
|
|
349
|
+
(::Command::Base.common_options + command_options).uniq { |option| option[:name] }
|
|
350
|
+
end
|
|
351
|
+
private_class_method :option_definitions_for
|
|
348
352
|
|
|
349
|
-
|
|
350
|
-
|
|
353
|
+
def self.validate_option!(option, normalized_name, value)
|
|
354
|
+
warn_deprecated_option(option, normalized_name) if option[:new_name]
|
|
351
355
|
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
params = option[:params]
|
|
357
|
+
return unless params[:valid_regex]
|
|
358
|
+
|
|
359
|
+
raise "Invalid value provided for option #{normalized_name}." unless value.match?(params[:valid_regex])
|
|
360
|
+
end
|
|
361
|
+
private_class_method :validate_option!
|
|
362
|
+
|
|
363
|
+
def self.warn_deprecated_option(option, normalized_name)
|
|
364
|
+
normalized_new_name = ::Helpers.normalize_option_name(option[:new_name])
|
|
365
|
+
::Shell.warn_deprecated("Option #{normalized_name} is deprecated, please use #{normalized_new_name} instead.")
|
|
366
|
+
$stderr.puts
|
|
354
367
|
end
|
|
368
|
+
private_class_method :warn_deprecated_option
|
|
355
369
|
|
|
356
370
|
def self.show_info_header(config) # rubocop:disable Metrics/MethodLength
|
|
357
371
|
return if @showed_info_header
|
|
@@ -12,6 +12,10 @@ spec:
|
|
|
12
12
|
memory: 512Mi
|
|
13
13
|
ports:
|
|
14
14
|
- number: 3000
|
|
15
|
+
# Keep this as `http` even when fronting Rails with Thruster — the Control Plane
|
|
16
|
+
# load balancer still serves HTTP/2 to end users. Setting `http2` here causes
|
|
17
|
+
# `502 Bad Gateway` with "protocol error".
|
|
18
|
+
# See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details.
|
|
15
19
|
protocol: http
|
|
16
20
|
defaultOptions:
|
|
17
21
|
autoscaling:
|
|
@@ -10,6 +10,10 @@ spec:
|
|
|
10
10
|
memory: 512Mi
|
|
11
11
|
ports:
|
|
12
12
|
- number: 3000
|
|
13
|
+
# Keep this as `http` even when fronting Rails with Thruster — the Control Plane
|
|
14
|
+
# load balancer still serves HTTP/2 to end users. Setting `http2` here causes
|
|
15
|
+
# `502 Bad Gateway` with "protocol error".
|
|
16
|
+
# See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details.
|
|
13
17
|
protocol: http
|
|
14
18
|
volumes:
|
|
15
19
|
- path: /app/db
|
|
@@ -47,13 +47,42 @@ You asked for review app help. These commands are generated by [cpflow](https://
|
|
|
47
47
|
| `STAGING_APP_NAME` | yes | App name in `controlplane.yml` used as the staging deploy target. |
|
|
48
48
|
| `PRODUCTION_APP_NAME` | yes (for promote) | App name in `controlplane.yml` used as the production deploy target. |
|
|
49
49
|
| `REVIEW_APP_PREFIX` | yes | Prefix for per-PR review app names (e.g. `review-app`). |
|
|
50
|
+
| `REVIEW_APP_DEPLOYING_ICON_URL` | optional | Custom image URL for the animated deploying icon in review-app PR comments. Set to `none` to use the text fallback icon. |
|
|
50
51
|
| `STAGING_APP_BRANCH` | optional | Custom staging branch. Custom branches must also appear in `cpflow-deploy-staging.yml`'s push filter. |
|
|
51
52
|
| `PRIMARY_WORKLOAD` | optional | Workload polled for health and rollback (defaults to `rails`). |
|
|
52
53
|
| `DOCKER_BUILD_EXTRA_ARGS` | optional | Newline-delimited extra docker build tokens (e.g. `--build-arg=FOO=bar`). |
|
|
53
54
|
| `DOCKER_BUILD_SSH_KNOWN_HOSTS` | optional | SSH known_hosts entries when SSH build hosts are not GitHub.com. |
|
|
54
55
|
| `HEALTH_CHECK_ACCEPTED_STATUSES` | optional | Space-separated HTTP statuses considered healthy on promote (default `200 301 302`). |
|
|
55
56
|
| `CPLN_CLI_VERSION` | optional | Pin a specific `@controlplane/cli` version; falls back to the action default when unset. |
|
|
56
|
-
| `CPFLOW_VERSION` | optional | Pin a
|
|
57
|
+
| `CPFLOW_VERSION` | optional | Pin a published RubyGems version such as `5.0.0`. Leave unset for normal generated workflows so the setup action builds `cpflow` from the same `control-plane-flow` GitHub ref used by the reusable workflow. |
|
|
58
|
+
|
|
59
|
+
</details>
|
|
60
|
+
|
|
61
|
+
<details>
|
|
62
|
+
<summary>Advanced: testing unreleased control-plane-flow changes</summary>
|
|
63
|
+
|
|
64
|
+
Generated workflow wrappers have two related pins:
|
|
65
|
+
|
|
66
|
+
- The `uses: shakacode/control-plane-flow/...@<ref>` GitHub ref selects the reusable workflow code.
|
|
67
|
+
- The `control_plane_flow_ref: <ref>` input tells the setup action which `control-plane-flow` source to check out and build when `CPFLOW_VERSION` is empty.
|
|
68
|
+
|
|
69
|
+
For normal releases, point both pins at a release tag such as `v5.0.0`.
|
|
70
|
+
You may leave `CPFLOW_VERSION` unset, or set it to the matching RubyGems version
|
|
71
|
+
without the leading `v`, such as `5.0.0`.
|
|
72
|
+
|
|
73
|
+
For temporary downstream testing of an upstream PR before a gem is released, pin
|
|
74
|
+
both values to the exact 40-character commit SHA and leave `CPFLOW_VERSION`
|
|
75
|
+
unset:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha>
|
|
79
|
+
bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Do not leave downstream apps pinned to a moving branch such as `main`. A branch
|
|
83
|
+
can pick up beta or work-in-progress changes without a downstream PR changing.
|
|
84
|
+
Use commit SHAs for short-lived PR testing, then switch to the release tag once
|
|
85
|
+
the gem and tag exist.
|
|
57
86
|
|
|
58
87
|
</details>
|
|
59
88
|
|
|
@@ -8,49 +8,15 @@ on:
|
|
|
8
8
|
permissions:
|
|
9
9
|
contents: read
|
|
10
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
11
|
jobs:
|
|
20
12
|
cleanup:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
13
|
+
# Keep the @ref in `uses:` and `control_plane_flow_ref` below in sync: the
|
|
14
|
+
# first selects the reusable workflow, the second selects its shared actions.
|
|
15
|
+
uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@__CPFLOW_GITHUB_ACTIONS_REF__
|
|
16
|
+
with:
|
|
17
|
+
control_plane_flow_ref: __CPFLOW_GITHUB_ACTIONS_REF__
|
|
18
|
+
# `secrets: inherit` passes all caller repository secrets to the trusted
|
|
19
|
+
# upstream workflow. The upstream workflow only reads the named secrets it
|
|
20
|
+
# references, but GitHub does not enforce that boundary. Strict consumers can
|
|
21
|
+
# set CPFLOW_GITHUB_ACTIONS_REF to an immutable commit SHA.
|
|
22
|
+
secrets: inherit
|
|
@@ -17,19 +17,11 @@ permissions:
|
|
|
17
17
|
issues: write
|
|
18
18
|
pull-requests: write
|
|
19
19
|
|
|
20
|
-
concurrency:
|
|
21
|
-
group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
22
|
-
# Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave
|
|
23
|
-
# partial state behind. Let the in-progress deletion finish before the next run starts.
|
|
24
|
-
cancel-in-progress: false
|
|
25
|
-
|
|
26
|
-
env:
|
|
27
|
-
APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
28
|
-
CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }}
|
|
29
|
-
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
30
|
-
|
|
31
20
|
jobs:
|
|
32
21
|
delete-review-app:
|
|
22
|
+
# pull_request_target is intentional: fork PR-close events need access to
|
|
23
|
+
# staging secrets to delete review apps and update PR comments. The upstream
|
|
24
|
+
# reusable workflow checks out trusted base-branch action code, not fork code.
|
|
33
25
|
if: |
|
|
34
26
|
(github.event_name == 'issue_comment' &&
|
|
35
27
|
github.event.issue.pull_request &&
|
|
@@ -37,106 +29,15 @@ jobs:
|
|
|
37
29
|
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) ||
|
|
38
30
|
(github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
|
|
39
31
|
github.event_name == 'workflow_dispatch'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
uses: actions/checkout@v4
|
|
53
|
-
with:
|
|
54
|
-
# Delete only invokes `cpln`/`cpflow`; no git push happens, so drop the
|
|
55
|
-
# GITHUB_TOKEN credential helper to keep the token out of .git/config under
|
|
56
|
-
# `pull_request_target`, which has access to repository secrets.
|
|
57
|
-
persist-credentials: false
|
|
58
|
-
|
|
59
|
-
- name: Validate required secrets and variables
|
|
60
|
-
uses: ./.github/actions/cpflow-validate-config
|
|
61
|
-
env:
|
|
62
|
-
CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
|
|
63
|
-
CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
|
|
64
|
-
REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
|
|
65
|
-
with:
|
|
66
|
-
required: |
|
|
67
|
-
secret:CPLN_TOKEN_STAGING
|
|
68
|
-
variable:CPLN_ORG_STAGING
|
|
69
|
-
variable:REVIEW_APP_PREFIX
|
|
70
|
-
pull_request_friendly: "true"
|
|
71
|
-
|
|
72
|
-
- name: Setup environment
|
|
73
|
-
uses: ./.github/actions/cpflow-setup-environment
|
|
74
|
-
with:
|
|
75
|
-
token: ${{ secrets.CPLN_TOKEN_STAGING }}
|
|
76
|
-
org: ${{ vars.CPLN_ORG_STAGING }}
|
|
77
|
-
cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
|
|
78
|
-
cpflow_version: ${{ vars.CPFLOW_VERSION }}
|
|
79
|
-
|
|
80
|
-
- name: Set workflow links
|
|
81
|
-
uses: actions/github-script@v7
|
|
82
|
-
with:
|
|
83
|
-
script: |
|
|
84
|
-
const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
85
|
-
core.exportVariable("WORKFLOW_URL", workflowUrl);
|
|
86
|
-
core.exportVariable(
|
|
87
|
-
"CONSOLE_URL",
|
|
88
|
-
`https://console.cpln.io/console/org/${process.env.CPLN_ORG}/-info`
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
- name: Create initial PR comment
|
|
92
|
-
id: create-comment
|
|
93
|
-
uses: actions/github-script@v7
|
|
94
|
-
with:
|
|
95
|
-
script: |
|
|
96
|
-
const comment = await github.rest.issues.createComment({
|
|
97
|
-
owner: context.repo.owner,
|
|
98
|
-
repo: context.repo.repo,
|
|
99
|
-
issue_number: Number(process.env.PR_NUMBER),
|
|
100
|
-
body: "🗑️ Deleting Control Plane review app..."
|
|
101
|
-
});
|
|
102
|
-
core.setOutput("comment-id", comment.data.id);
|
|
103
|
-
|
|
104
|
-
- name: Delete review app
|
|
105
|
-
uses: ./.github/actions/cpflow-delete-control-plane-app
|
|
106
|
-
with:
|
|
107
|
-
app_name: ${{ env.APP_NAME }}
|
|
108
|
-
cpln_org: ${{ vars.CPLN_ORG_STAGING }}
|
|
109
|
-
review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }}
|
|
110
|
-
|
|
111
|
-
- name: Finalize delete status
|
|
112
|
-
if: always()
|
|
113
|
-
uses: actions/github-script@v7
|
|
114
|
-
with:
|
|
115
|
-
script: |
|
|
116
|
-
const commentId = Number("${{ steps.create-comment.outputs.comment-id }}");
|
|
117
|
-
const success = "${{ job.status }}" === "success";
|
|
118
|
-
const body = success
|
|
119
|
-
? [
|
|
120
|
-
`✅ Review app for PR #${process.env.PR_NUMBER} is deleted`,
|
|
121
|
-
"",
|
|
122
|
-
`[Open organization console](${process.env.CONSOLE_URL})`,
|
|
123
|
-
`[View workflow logs](${process.env.WORKFLOW_URL})`
|
|
124
|
-
].join("\n")
|
|
125
|
-
: [
|
|
126
|
-
`❌ Failed to delete review app for PR #${process.env.PR_NUMBER}`,
|
|
127
|
-
"",
|
|
128
|
-
`[Open organization console](${process.env.CONSOLE_URL})`,
|
|
129
|
-
`[View workflow logs](${process.env.WORKFLOW_URL})`
|
|
130
|
-
].join("\n");
|
|
131
|
-
|
|
132
|
-
if (!Number.isFinite(commentId) || commentId <= 0) {
|
|
133
|
-
core.warning("Skipping delete status comment update because no comment id was created.");
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
await github.rest.issues.updateComment({
|
|
138
|
-
owner: context.repo.owner,
|
|
139
|
-
repo: context.repo.repo,
|
|
140
|
-
comment_id: commentId,
|
|
141
|
-
body
|
|
142
|
-
});
|
|
32
|
+
# This `if:` mirrors the upstream job guard to avoid a billable workflow_call
|
|
33
|
+
# when the event does not match. Keep both conditions in sync.
|
|
34
|
+
# Keep the @ref in `uses:` and `control_plane_flow_ref` below in sync: the
|
|
35
|
+
# first selects the reusable workflow, the second selects its shared actions.
|
|
36
|
+
uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@__CPFLOW_GITHUB_ACTIONS_REF__
|
|
37
|
+
with:
|
|
38
|
+
control_plane_flow_ref: __CPFLOW_GITHUB_ACTIONS_REF__
|
|
39
|
+
# `secrets: inherit` passes all caller repository secrets to the trusted
|
|
40
|
+
# upstream workflow. The upstream workflow only reads the named secrets it
|
|
41
|
+
# references, but GitHub does not enforce that boundary. Strict consumers can
|
|
42
|
+
# set CPFLOW_GITHUB_ACTIONS_REF to an immutable commit SHA.
|
|
43
|
+
secrets: inherit
|