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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/action.yml +5 -0
  3. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-detect-release-phase/action.yml +7 -0
  4. data/.github/actions/cpflow-setup-environment/action.yml +161 -0
  5. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +69 -0
  6. data/.github/workflows/cpflow-delete-review-app.yml +182 -0
  7. data/.github/workflows/cpflow-deploy-review-app.yml +507 -0
  8. data/.github/workflows/cpflow-deploy-staging.yml +168 -0
  9. data/.github/workflows/cpflow-help-command.yml +78 -0
  10. data/.github/workflows/cpflow-promote-staging-to-production.yml +510 -0
  11. data/.github/workflows/cpflow-review-app-help.yml +51 -0
  12. data/.github/workflows/rspec-shared.yml +3 -0
  13. data/.github/workflows/trigger-docs-site.yml +90 -0
  14. data/.rubocop.yml +14 -1
  15. data/CHANGELOG.md +43 -1
  16. data/CONTRIBUTING.md +27 -0
  17. data/Gemfile.lock +2 -2
  18. data/README.md +7 -3
  19. data/cpflow.gemspec +1 -1
  20. data/docs/ai-github-flow-prompt.md +1 -1
  21. data/docs/assets/cpflow-deploying.svg +46 -0
  22. data/docs/ci-automation.md +111 -8
  23. data/docs/commands.md +11 -5
  24. data/docs/thruster.md +149 -0
  25. data/docs/troubleshooting.md +8 -0
  26. data/lib/command/apply_template.rb +6 -2
  27. data/lib/command/base.rb +1 -0
  28. data/lib/command/cleanup_stale_apps.rb +53 -14
  29. data/lib/command/delete.rb +3 -1
  30. data/lib/command/deploy_image.rb +5 -2
  31. data/lib/command/generate.rb +7 -3
  32. data/lib/command/generate_github_actions.rb +21 -9
  33. data/lib/command/generator_helpers.rb +5 -1
  34. data/lib/command/info.rb +3 -1
  35. data/lib/command/run.rb +16 -1
  36. data/lib/command/test.rb +1 -3
  37. data/lib/core/controlplane.rb +17 -6
  38. data/lib/core/controlplane_api.rb +3 -1
  39. data/lib/core/controlplane_api_direct.rb +50 -27
  40. data/lib/core/doctor_service.rb +2 -2
  41. data/lib/core/github_flow_readiness_service.rb +26 -2
  42. data/lib/core/repo_introspection.rb +41 -3
  43. data/lib/core/shell.rb +3 -1
  44. data/lib/core/terraform_config/policy.rb +1 -1
  45. data/lib/cpflow/version.rb +1 -1
  46. data/lib/cpflow.rb +27 -13
  47. data/lib/generator_templates/templates/rails.yml +4 -0
  48. data/lib/generator_templates_sqlite/templates/rails.yml +4 -0
  49. data/lib/github_flow_templates/.github/cpflow-help.md +30 -1
  50. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +10 -44
  51. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +15 -114
  52. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +10 -413
  53. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +12 -123
  54. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +10 -33
  55. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +13 -475
  56. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +12 -30
  57. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +72 -0
  58. data/lib/github_flow_templates/bin/test-cpflow-github-flow +89 -0
  59. data/rakelib/create_release.rake +4 -4
  60. metadata +26 -17
  61. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +0 -98
  62. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-build-docker-image/action.yml +0 -0
  63. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/delete-app.sh +0 -0
  64. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-validate-config/action.yml +0 -0
  65. /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 RedactedDebugOutput
4
- SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
5
- HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
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
- def <<(msg)
8
- $stdout << redact(msg)
9
- end
8
+ def <<(msg)
9
+ $stdout << redact(msg)
10
+ end
11
+
12
+ private
10
13
 
11
- private
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
- def redact(msg)
14
- msg.lines.map { |line| redact_line(line) }.join
22
+ SAFE_HEADERS.any? { |h| h.casecmp(match[1]).zero? } ? line : "#{match[1]}: [REDACTED]\n"
23
+ end
15
24
  end
16
25
 
17
- def redact_line(line)
18
- match = line.match(HEADER_REGEX)
19
- return line.gsub(/[\w\-._]{50,}/, "[REDACTED]") unless match
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
- SAFE_HEADERS.any? { |h| h.casecmp(match[1]).zero? } ? line : "#{match[1]}: [REDACTED]\n"
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"] = api_token[:token]
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
- org = self.class.parse_org(url)
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
- return @@api_token if @@api_token[:token].match?(API_TOKEN_REGEX)
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
- difference_in_seconds = payload["exp"] - Time.now.to_i
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
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ValidationError < StandardError; end
4
-
5
3
  class DoctorService
4
+ class ValidationError < StandardError; end
5
+
6
6
  extend Forwardable
7
7
 
8
8
  def_delegators :@command, :config, :progress
@@ -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 = Net::HTTP.new(uri.host, uri.port)
372
- http.use_ssl = true
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).select { |line| line.match?(RUBY_VERSION_DIRECTIVE_PREFIX) }
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
- url = production["url"]
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?(production)
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
- def self.confirm(message)
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).count == 1
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: " \
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.0.0.rc.1"
4
+ VERSION = "5.0.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
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) # rubocop:disable Metrics/MethodLength
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 = ::Command::Base.all_options.find { |current_option| current_option[:name].to_s == name }
342
- if option[:new_name]
343
- normalized_new_name = ::Helpers.normalize_option_name(option[:new_name])
344
- ::Shell.warn_deprecated("Option #{normalized_name} is deprecated, " \
345
- "please use #{normalized_new_name} instead.")
346
- $stderr.puts
347
- end
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
- params = option[:params]
350
- next unless params[:valid_regex]
353
+ def self.validate_option!(option, normalized_name, value)
354
+ warn_deprecated_option(option, normalized_name) if option[:new_name]
351
355
 
352
- raise "Invalid value provided for option #{normalized_name}." unless value.match?(params[:valid_regex])
353
- end
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 specific cpflow gem version; falls back to the generated default when unset. |
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
- runs-on: ubuntu-latest
22
- timeout-minutes: 30
23
- steps:
24
- - name: Checkout repository
25
- uses: actions/checkout@v4
26
- with:
27
- persist-credentials: false
28
-
29
- - name: Validate required secrets and variables
30
- uses: ./.github/actions/cpflow-validate-config
31
- env:
32
- CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
33
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
34
- REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
35
- with:
36
- required: |
37
- secret:CPLN_TOKEN_STAGING
38
- variable:CPLN_ORG_STAGING
39
- variable:REVIEW_APP_PREFIX
40
-
41
- - name: Setup environment
42
- uses: ./.github/actions/cpflow-setup-environment
43
- with:
44
- token: ${{ secrets.CPLN_TOKEN_STAGING }}
45
- org: ${{ vars.CPLN_ORG_STAGING }}
46
- cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
47
- cpflow_version: ${{ vars.CPFLOW_VERSION }}
48
-
49
- - name: Remove stale review apps
50
- env:
51
- REVIEW_APP_PREFIX: ${{ vars.REVIEW_APP_PREFIX }}
52
- CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }}
53
- shell: bash
54
- run: |
55
- set -euo pipefail
56
- cpflow cleanup-stale-apps -a "${REVIEW_APP_PREFIX}" --org "${CPLN_ORG_STAGING}" --yes
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
- runs-on: ubuntu-latest
41
- timeout-minutes: 15
42
-
43
- steps:
44
- # pull_request_target is intentional: PR-close events from forks need access
45
- # to staging secrets so this workflow can delete review apps and update PR
46
- # comments. This checkout is safe because it does not set `ref:`; GitHub checks
47
- # out the base branch's trusted workflow code, not the fork head. Do not add
48
- # `ref: ${{ github.event.pull_request.head.sha }}` here without re-evaluating
49
- # the trust boundary. All local composite actions below are therefore loaded from
50
- # trusted base-branch code; keep them that way when changing this workflow.
51
- - name: Checkout repository
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