cpflow 4.2.0 → 5.0.0.rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/workflows/address-review.md +216 -0
  3. data/.claude/commands/address-review.md +547 -0
  4. data/.claude/commands/update-changelog.md +367 -0
  5. data/.github/workflows/claude.yml +5 -0
  6. data/.overcommit.yml +43 -3
  7. data/.rubocop.yml +3 -3
  8. data/CHANGELOG.md +28 -4
  9. data/CONTRIBUTING.md +28 -0
  10. data/Gemfile +8 -7
  11. data/Gemfile.lock +92 -72
  12. data/README.md +55 -20
  13. data/cpflow.gemspec +5 -5
  14. data/docs/ai-github-flow-prompt.md +61 -0
  15. data/docs/ci-automation.md +335 -28
  16. data/docs/commands.md +67 -4
  17. data/docs/migrating-heroku-to-control-plane.md +12 -0
  18. data/docs/postgres.md +5 -0
  19. data/docs/redis.md +6 -0
  20. data/docs/releasing.md +153 -0
  21. data/lib/command/ai_github_flow_prompt.rb +47 -0
  22. data/lib/command/base.rb +25 -0
  23. data/lib/command/cleanup_images.rb +1 -1
  24. data/lib/command/cleanup_stale_apps.rb +1 -1
  25. data/lib/command/copy_image_from_upstream.rb +14 -3
  26. data/lib/command/deploy_image.rb +40 -9
  27. data/lib/command/exists.rb +13 -2
  28. data/lib/command/generate.rb +153 -4
  29. data/lib/command/generate_github_actions.rb +170 -0
  30. data/lib/command/generator_helpers.rb +31 -0
  31. data/lib/command/github_flow_readiness.rb +37 -0
  32. data/lib/command/promote_app_from_upstream.rb +13 -2
  33. data/lib/command/run.rb +1 -1
  34. data/lib/command/terraform/generate.rb +1 -0
  35. data/lib/command/version.rb +1 -0
  36. data/lib/constants/exit_code.rb +1 -0
  37. data/lib/core/config.rb +8 -0
  38. data/lib/core/controlplane.rb +9 -7
  39. data/lib/core/controlplane_api_direct.rb +3 -3
  40. data/lib/core/github_flow_readiness/checks.rb +143 -0
  41. data/lib/core/github_flow_readiness_service.rb +453 -0
  42. data/lib/core/repo_introspection.rb +118 -0
  43. data/lib/core/terraform_config/dsl.rb +1 -1
  44. data/lib/core/terraform_config/local_variable.rb +1 -1
  45. data/lib/cpflow/version.rb +1 -1
  46. data/lib/cpflow.rb +65 -3
  47. data/lib/generator_templates/Dockerfile +59 -3
  48. data/lib/generator_templates/controlplane.yml +27 -39
  49. data/lib/generator_templates/entrypoint.sh +1 -1
  50. data/lib/generator_templates/release_script.sh +23 -0
  51. data/lib/generator_templates/templates/app.yml +5 -8
  52. data/lib/generator_templates/templates/rails.yml +2 -11
  53. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  54. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  55. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  56. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  57. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  58. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  59. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  60. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  61. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  62. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  63. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  64. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  65. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  66. data/lib/github_flow_templates/.github/cpflow-help.md +73 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  68. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  69. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  70. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  71. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +58 -0
  72. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  73. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +45 -0
  74. data/rakelib/create_release.rake +662 -37
  75. data/script/check_command_docs +4 -2
  76. data/script/check_cpln_links +25 -11
  77. data/script/precommit/check_command_docs +22 -0
  78. data/script/precommit/check_cpln_links +21 -0
  79. data/script/precommit/check_trailing_newlines +68 -0
  80. data/script/precommit/get_changed_files +49 -0
  81. data/script/precommit/ruby_autofix +52 -0
  82. data/script/precommit/ruby_lint +33 -0
  83. metadata +54 -14
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ require_relative "generator_helpers"
7
+
8
+ module Command
9
+ class GithubActionsGenerator < Thor::Group
10
+ include Thor::Actions
11
+ include GeneratorHelpers
12
+
13
+ argument :staging_branch, type: :string, required: false
14
+
15
+ def copy_files
16
+ relative_paths = generated_files
17
+ copy_template_files(relative_paths)
18
+ substitute_template_variables(relative_paths)
19
+ make_shell_scripts_executable(relative_paths)
20
+ end
21
+
22
+ def self.source_root
23
+ Cpflow.root_path.join("lib")
24
+ end
25
+
26
+ private
27
+
28
+ def copy_template_files(relative_paths)
29
+ relative_paths.each do |relative_path|
30
+ empty_directory(File.dirname(relative_path), verbose: false)
31
+ copy_file(
32
+ File.join("github_flow_templates", relative_path),
33
+ relative_path,
34
+ force: true,
35
+ verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
36
+ )
37
+ end
38
+ end
39
+
40
+ def template_variables
41
+ {
42
+ "__CPFLOW_VERSION__" => ::Cpflow::VERSION,
43
+ "__STAGING_BRANCH_FILTER__" => staging_branch_filter,
44
+ "__STAGING_APP_BRANCH_EXPRESSION__" => staging_app_branch_expression
45
+ }
46
+ end
47
+
48
+ def generated_files
49
+ # Keep file discovery centralized on the command class so existence checks and
50
+ # Thor's template copy list cannot drift.
51
+ GenerateGithubActions.generated_files
52
+ end
53
+
54
+ def staging_branch_filter
55
+ branches = staging_branch ? [staging_branch] : %w[main master]
56
+ # JSON string literals are valid YAML flow-sequence scalars, so this keeps
57
+ # the generated branch list readable while still escaping branch names.
58
+ branches.map(&:to_json).join(", ")
59
+ end
60
+
61
+ def staging_app_branch_expression
62
+ return "${{ vars.STAGING_APP_BRANCH }}" unless staging_branch
63
+
64
+ # `valid_staging_branch?` excludes quotes, so this single-quoted GitHub
65
+ # expression literal cannot be broken by the generated branch name.
66
+ "${{ vars.STAGING_APP_BRANCH || '#{staging_branch}' }}"
67
+ end
68
+ end
69
+
70
+ class GenerateGithubActions < Base
71
+ NAME = "generate-github-actions"
72
+ OPTIONS = [staging_branch_option].freeze
73
+ DESCRIPTION = "Creates GitHub Actions templates for review apps, staging deploys, and production promotion"
74
+ LONG_DESCRIPTION = <<~DESC
75
+ Creates GitHub Actions templates for a Heroku Flow style Control Plane pipeline:
76
+ - on-demand review apps for pull requests
77
+ - automatic staging deploys from your main branch
78
+ - manual promotion from staging to production
79
+ - nightly cleanup and PR help workflows
80
+
81
+ Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
82
+ other than `main` or `master`; the generator will bake that branch into the
83
+ GitHub Actions push trigger and use it as the default STAGING_APP_BRANCH.
84
+ DESC
85
+ EXAMPLES = <<~EX
86
+ ```sh
87
+ # Creates .github/actions and .github/workflows files for the Control Plane flow
88
+ cpflow generate-github-actions
89
+
90
+ # Creates the flow with staging deploys triggered from develop
91
+ cpflow generate-github-actions --staging-branch develop
92
+ ```
93
+ EX
94
+ WITH_INFO_HEADER = false
95
+ VALIDATIONS = [].freeze
96
+ REQUIRES_STARTUP_CHECKS = false
97
+
98
+ # Resolve template root from __dir__ rather than Cpflow.root_path because this file is
99
+ # loaded before `module Cpflow` finishes defining its class methods.
100
+ TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
101
+
102
+ def self.generated_files
103
+ ensure_template_root!
104
+
105
+ Dir.glob(TEMPLATE_ROOT.join("**", "*").to_s, File::FNM_DOTMATCH)
106
+ .select { |path| File.file?(path) }
107
+ .map { |path| Pathname.new(path).relative_path_from(TEMPLATE_ROOT).to_s }
108
+ .sort
109
+ .freeze
110
+ end
111
+
112
+ def self.ensure_template_root!
113
+ raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
114
+ end
115
+
116
+ def call
117
+ self.class.ensure_template_root!
118
+ branch = staging_branch
119
+
120
+ if (existing = existing_files).any?
121
+ files = existing.map { |path| "- #{path}" }.join("\n")
122
+ Shell.warn("The following files already exist:\n#{files}\n\n" \
123
+ "Remove or rename them before running `cpflow #{NAME}` again.")
124
+ return
125
+ end
126
+
127
+ GithubActionsGenerator.start([branch].compact)
128
+ end
129
+
130
+ private
131
+
132
+ def existing_files
133
+ @existing_files ||= self.class.generated_files.select { |path| File.exist?(path) }
134
+ end
135
+
136
+ def staging_branch
137
+ branch = config.options[:staging_branch].to_s.strip
138
+ return nil if branch.empty?
139
+
140
+ unless valid_staging_branch?(branch)
141
+ Shell.abort(
142
+ "Invalid --staging-branch value: #{branch.inspect}. " \
143
+ "Use a valid git branch name containing only alphanumerics, dots, slashes, underscores, hyphens, and @."
144
+ )
145
+ end
146
+
147
+ branch
148
+ end
149
+
150
+ def valid_staging_branch?(branch)
151
+ return false unless branch.match?(%r{\A[a-zA-Z0-9._/@-]+\z})
152
+
153
+ valid_git_branch_shape?(branch) && valid_git_branch_components?(branch)
154
+ end
155
+
156
+ def valid_git_branch_shape?(branch)
157
+ return false if branch.start_with?("-", "/", ".")
158
+ return false if branch.end_with?("/", ".")
159
+ return false if branch.include?("@{")
160
+
161
+ !branch.include?("..")
162
+ end
163
+
164
+ def valid_git_branch_components?(branch)
165
+ branch.split("/").none? do |component|
166
+ component.empty? || component.start_with?(".") || component.end_with?(".lock")
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ module GeneratorHelpers
5
+ private
6
+
7
+ def substitute_template_variables(file_paths, replacements = template_variables)
8
+ Array(file_paths).each do |path|
9
+ next unless File.file?(path)
10
+
11
+ contents = File.read(path)
12
+ updated_contents = replacements.reduce(contents) do |memo, (placeholder, value)|
13
+ # Block form avoids regex-style back-reference interpretation (\1, \&, \\) in `value`.
14
+ memo.gsub(placeholder) { value }
15
+ end
16
+
17
+ next if updated_contents == contents
18
+
19
+ File.write(path, updated_contents)
20
+ end
21
+ end
22
+
23
+ def make_shell_scripts_executable(file_paths)
24
+ Array(file_paths).each do |path|
25
+ next unless File.file?(path) && File.extname(path) == ".sh"
26
+
27
+ FileUtils.chmod(0o755, path)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ class GithubFlowReadiness < Base
5
+ NAME = "github-flow-readiness"
6
+ DESCRIPTION = "Checks whether the current repo is ready for the Control Plane GitHub flow rollout"
7
+ LONG_DESCRIPTION = <<~DESC
8
+ Checks the current repository for common rollout blockers before adding the Control Plane GitHub flow:
9
+ - Rails runtime scaffold present
10
+ - modern Ruby and Bundler toolchain
11
+ - installable exact-pinned direct gem and npm package versions
12
+ - production Dockerfile presence and SQLite production hints
13
+ DESC
14
+ EXAMPLES = <<~EX
15
+ ```sh
16
+ # Checks the current repo for common rollout blockers
17
+ cpflow github-flow-readiness
18
+ ```
19
+ EX
20
+ WITH_INFO_HEADER = false
21
+ VALIDATIONS = [].freeze
22
+ REQUIRES_STARTUP_CHECKS = false
23
+
24
+ def call
25
+ service = GithubFlowReadinessService.new
26
+
27
+ service.results.each do |result|
28
+ Shell.info("[#{result.status.to_s.upcase}] #{result.message}")
29
+ end
30
+
31
+ Shell.info("")
32
+ Shell.info(service.summary)
33
+
34
+ exit(ExitCode::ERROR_DEFAULT) if service.blockers?
35
+ end
36
+ end
37
+ end
@@ -5,7 +5,8 @@ module Command
5
5
  NAME = "promote-app-from-upstream"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
- upstream_token_option(required: true)
8
+ upstream_token_option(required: true),
9
+ use_digest_image_ref_option
9
10
  ].freeze
10
11
  DESCRIPTION = "Copies the latest image from upstream, runs a release script (optional), and deploys the image"
11
12
  LONG_DESCRIPTION = <<~DESC
@@ -15,6 +16,7 @@ module Command
15
16
  - Runs `cpflow deploy-image` to deploy the image
16
17
  - If `.controlplane/controlplane.yml` includes the `release_script`, `cpflow deploy-image` will use the `--run-release-phase` option
17
18
  - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
19
+ - If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
18
20
  DESC
19
21
 
20
22
  def call
@@ -31,8 +33,17 @@ module Command
31
33
 
32
34
  def deploy_image
33
35
  args = []
34
- args.push("--run-release-phase") if config.current[:release_script]
36
+ args.push("--run-release-phase") if config.current&.dig(:release_script)
37
+ digest_image_ref_option = deploy_image_digest_ref_option
38
+ args.push(digest_image_ref_option) if digest_image_ref_option
35
39
  run_cpflow_command("deploy-image", "-a", config.app, *args)
36
40
  end
41
+
42
+ def deploy_image_digest_ref_option
43
+ # Forward explicit false so a parent CLI override is not lost when the child command re-reads YAML.
44
+ return "--no-use-digest-image-ref" if config.options[:use_digest_image_ref] == false
45
+
46
+ "--use-digest-image-ref" if config.use_digest_image_ref?
47
+ end
37
48
  end
38
49
  end
data/lib/command/run.rb CHANGED
@@ -48,7 +48,7 @@ module Command
48
48
  - By default, the job is stopped if it takes longer than 6 hours to finish
49
49
  (can be configured though `runner_job_timeout` in `controlplane.yml`)
50
50
  DESC
51
- EXAMPLES = <<~EX
51
+ EXAMPLES = <<~EX.freeze
52
52
  ```sh
53
53
  # Opens shell (bash by default).
54
54
  cpflow run -a $APP_NAME
@@ -14,6 +14,7 @@ module Command
14
14
  - Generates terraform configuration files based on `controlplane.yml` and `templates/` config
15
15
  DESC
16
16
  WITH_INFO_HEADER = false
17
+ REQUIRES_STARTUP_CHECKS = false
17
18
 
18
19
  def call
19
20
  Array(config.app || config.apps.keys).each do |app|
@@ -10,6 +10,7 @@ module Command
10
10
  DESC
11
11
  WITH_INFO_HEADER = false
12
12
  VALIDATIONS = [].freeze
13
+ REQUIRES_STARTUP_CHECKS = false
13
14
 
14
15
  def call
15
16
  puts Cpflow::VERSION
@@ -2,6 +2,7 @@
2
2
 
3
3
  module ExitCode
4
4
  SUCCESS = 0
5
+ NOT_FOUND = 3
5
6
  ERROR_DEFAULT = 64
6
7
  INTERRUPT = 130
7
8
  end
data/lib/core/config.rb CHANGED
@@ -145,6 +145,14 @@ class Config # rubocop:disable Metrics/ClassLength
145
145
  end&.last
146
146
  end
147
147
 
148
+ def use_digest_image_ref?
149
+ # Three-state: --use-digest-image-ref → true, --no-use-digest-image-ref → false
150
+ # (both short-circuit YAML), absent → nil (fall through to YAML).
151
+ return options[:use_digest_image_ref] unless options[:use_digest_image_ref].nil?
152
+
153
+ current&.dig(:use_digest_image_ref) == true
154
+ end
155
+
148
156
  private
149
157
 
150
158
  def ensure_current_config!
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  class Controlplane # rubocop:disable Metrics/ClassLength
4
6
  attr_reader :config, :api, :gvc, :org
5
7
 
@@ -93,14 +95,14 @@ class Controlplane # rubocop:disable Metrics/ClassLength
93
95
  def image_build(image, dockerfile:, docker_context:, docker_args: [], build_args: [])
94
96
  # https://docs.controlplane.com/guides/push-image#step-2
95
97
  # Might need to use `docker buildx build` if compatiblitity issues arise
96
- cmd = "docker build --platform=linux/amd64 -t #{image} -f #{dockerfile}"
97
- cmd += " --progress=plain" if ControlplaneApiDirect.trace
98
+ cmd = ["docker", "build", "--platform=linux/amd64", "-t", image, "-f", dockerfile]
99
+ cmd << "--progress=plain" if ControlplaneApiDirect.trace
98
100
 
99
- cmd += " #{docker_args.join(' ')}" if docker_args.any?
100
- build_args.each { |build_arg| cmd += " --build-arg #{build_arg}" }
101
- cmd += " #{docker_context}"
101
+ cmd.concat(docker_args)
102
+ build_args.each { |build_arg| cmd.concat(["--build-arg", build_arg]) }
103
+ cmd << docker_context
102
104
 
103
- perform!(cmd)
105
+ perform!(Shellwords.join(cmd))
104
106
  end
105
107
 
106
108
  def fetch_image_details(image)
@@ -321,7 +323,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
321
323
  # domain
322
324
 
323
325
  def find_domain_route(data)
324
- port = data["spec"]["ports"].find { |current_port| current_port["number"] == 80 || current_port["number"] == 443 }
326
+ port = data["spec"]["ports"].find { |current_port| [80, 443].include?(current_port["number"]) }
325
327
  return nil if port.nil? || port["routes"].nil?
326
328
 
327
329
  route = port["routes"].find { |current_route| current_route["prefix"] == "/" }
@@ -2,7 +2,7 @@
2
2
 
3
3
  class RedactedDebugOutput
4
4
  SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
5
- HEADER_REGEX = /^([A-Za-z\-]+): (.+)$/.freeze
5
+ HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
6
6
 
7
7
  def <<(msg)
8
8
  $stdout << redact(msg)
@@ -37,7 +37,7 @@ class ControlplaneApiDirect
37
37
  # /^[\w\-._]{1134}$/ # 'cpln profile token' format
38
38
  # ).freeze
39
39
 
40
- API_TOKEN_REGEX = /^[\w\-._]+$/.freeze
40
+ API_TOKEN_REGEX = /^[\w\-._]+$/
41
41
  API_TOKEN_EXPIRY_SECONDS = 300
42
42
 
43
43
  class << self
@@ -111,7 +111,7 @@ class ControlplaneApiDirect
111
111
  def should_refresh_api_token?
112
112
  return false unless api_token[:comes_from_profile]
113
113
 
114
- payload, = JWT.decode(api_token[:token], nil, false)
114
+ payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
115
115
  difference_in_seconds = payload["exp"] - Time.now.to_i
116
116
 
117
117
  difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GithubFlowReadiness
4
+ Result = Struct.new(:status, :message, keyword_init: true)
5
+
6
+ # Each check class accepts the host service in its initializer (so it can reach the
7
+ # shared lockfile parser, HTTP version cache, etc.), exposes a single `call` method,
8
+ # and returns either a `Result`, an array of `Result`s, or `nil` (skipped). Adding
9
+ # a new check is "create a class with `call` and register it in
10
+ # `GithubFlowReadinessService::CHECKS`".
11
+ module Checks
12
+ class Base
13
+ def initialize(service)
14
+ @service = service
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :service
20
+
21
+ def root_path
22
+ service.root_path
23
+ end
24
+
25
+ def pass(message)
26
+ Result.new(status: :pass, message: message)
27
+ end
28
+
29
+ def fail_result(message)
30
+ Result.new(status: :fail, message: message)
31
+ end
32
+
33
+ def warn_result(message)
34
+ Result.new(status: :warn, message: message)
35
+ end
36
+
37
+ def info_result(message)
38
+ Result.new(status: :info, message: message)
39
+ end
40
+
41
+ def format_path_list(paths)
42
+ paths.map { |path| "`#{path}`" }.join(", ")
43
+ end
44
+
45
+ def first_existing_path(paths)
46
+ paths.find { |relative_path| root_path.join(relative_path).file? }
47
+ end
48
+
49
+ def missing_paths_for(paths)
50
+ paths.reject { |relative_path| root_path.join(relative_path).file? }
51
+ end
52
+ end
53
+
54
+ class RailsApp < Base
55
+ REQUIRED_PATHS = ["Gemfile", "bin/rails", "config/application.rb", "config.ru"].freeze
56
+
57
+ def call
58
+ missing = missing_paths_for(REQUIRED_PATHS)
59
+ return pass("Rails app scaffold found (#{format_path_list(REQUIRED_PATHS)}).") if missing.empty?
60
+
61
+ fail_result("Missing Rails runtime scaffold: #{format_path_list(missing)}.")
62
+ end
63
+ end
64
+
65
+ class RubyVersion < Base
66
+ # Oldest Ruby line still receiving security backports (ruby-lang.org/en/downloads/branches/).
67
+ # Bump this constant when the upstream list drops the 3.3 series.
68
+ THRESHOLD = Gem::Version.new("3.3.0")
69
+
70
+ def call
71
+ version = service.inferred_ruby_version
72
+ return warn_result("Could not determine the app Ruby version.") unless version
73
+ return pass("Ruby #{version} is modern enough for rollout.") if version >= THRESHOLD
74
+
75
+ fail_result("Ruby #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
76
+ end
77
+ end
78
+
79
+ class BundlerVersion < Base
80
+ THRESHOLD = Gem::Version.new("2.0.0")
81
+
82
+ def call
83
+ version = service.lockfile_bundler_version
84
+ return warn_result("Could not determine the Bundler version from `Gemfile.lock`.") unless version
85
+ return pass("Bundler #{version} is modern enough for rollout.") if version >= THRESHOLD
86
+
87
+ fail_result("Bundler #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
88
+ end
89
+ end
90
+
91
+ class Dockerfile < Base
92
+ PATHS = ["Dockerfile", ".controlplane/Dockerfile"].freeze
93
+
94
+ def call
95
+ path = first_existing_path(PATHS)
96
+ return pass("Found production Dockerfile at `#{path}`.") if path
97
+
98
+ fail_result(
99
+ "No production Dockerfile found at `Dockerfile` or `.controlplane/Dockerfile`. " \
100
+ "Add and validate one before generating the Control Plane GitHub flow."
101
+ )
102
+ end
103
+ end
104
+
105
+ class SqliteProduction < Base
106
+ def call
107
+ return unless service.sqlite_database_in_production?
108
+
109
+ info_result(
110
+ "Production database config uses SQLite. `cpflow generate` will scaffold " \
111
+ "persistent `db` and `storage` volumes."
112
+ )
113
+ end
114
+ end
115
+
116
+ class GemSources < Base
117
+ def call
118
+ non_public = service.gem_dependencies.reject { |dep| service.public_rubygems_dependency?(dep) }
119
+ return pass("All direct Ruby gems resolve from public RubyGems sources.") if non_public.empty?
120
+
121
+ names = non_public.map { |dep| dep[:name] }.sort
122
+ warn_result(
123
+ "Direct Ruby dependencies using git/path or non-public gem sources need manual review: " \
124
+ "#{names.map { |name| "`#{name}`" }.join(', ')}."
125
+ )
126
+ end
127
+ end
128
+
129
+ class GemExactPins < Base
130
+ def call
131
+ service.exact_pin_registry_result(service.rubygems_registry_check)
132
+ end
133
+ end
134
+
135
+ class NpmExactPins < Base
136
+ def call
137
+ return service.package_json_parse_error_result if service.package_json_parse_error
138
+
139
+ service.exact_pin_registry_result(service.npm_registry_check)
140
+ end
141
+ end
142
+ end
143
+ end