cpflow 4.2.0 → 5.0.0.rc.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude.yml +5 -0
  4. data/.overcommit.yml +43 -3
  5. data/.rubocop.yml +3 -3
  6. data/CHANGELOG.md +28 -4
  7. data/CONTRIBUTING.md +6 -0
  8. data/Gemfile +8 -7
  9. data/Gemfile.lock +92 -72
  10. data/README.md +43 -15
  11. data/cpflow.gemspec +5 -5
  12. data/docs/ai-github-flow-prompt.md +61 -0
  13. data/docs/ci-automation.md +335 -28
  14. data/docs/commands.md +65 -4
  15. data/docs/releasing.md +153 -0
  16. data/lib/command/ai_github_flow_prompt.rb +47 -0
  17. data/lib/command/base.rb +14 -0
  18. data/lib/command/cleanup_images.rb +1 -1
  19. data/lib/command/cleanup_stale_apps.rb +1 -1
  20. data/lib/command/copy_image_from_upstream.rb +14 -3
  21. data/lib/command/exists.rb +13 -2
  22. data/lib/command/generate.rb +153 -4
  23. data/lib/command/generate_github_actions.rb +170 -0
  24. data/lib/command/generator_helpers.rb +31 -0
  25. data/lib/command/github_flow_readiness.rb +37 -0
  26. data/lib/command/run.rb +1 -1
  27. data/lib/command/terraform/generate.rb +1 -0
  28. data/lib/command/version.rb +1 -0
  29. data/lib/constants/exit_code.rb +1 -0
  30. data/lib/core/controlplane.rb +9 -7
  31. data/lib/core/controlplane_api_direct.rb +3 -3
  32. data/lib/core/github_flow_readiness/checks.rb +143 -0
  33. data/lib/core/github_flow_readiness_service.rb +453 -0
  34. data/lib/core/repo_introspection.rb +118 -0
  35. data/lib/core/terraform_config/dsl.rb +1 -1
  36. data/lib/core/terraform_config/local_variable.rb +1 -1
  37. data/lib/cpflow/version.rb +1 -1
  38. data/lib/cpflow.rb +65 -3
  39. data/lib/generator_templates/Dockerfile +59 -3
  40. data/lib/generator_templates/controlplane.yml +27 -39
  41. data/lib/generator_templates/entrypoint.sh +1 -1
  42. data/lib/generator_templates/release_script.sh +23 -0
  43. data/lib/generator_templates/templates/app.yml +5 -8
  44. data/lib/generator_templates/templates/rails.yml +2 -11
  45. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  46. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  47. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  48. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  49. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  50. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  51. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  52. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  53. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  54. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  58. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  61. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  62. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  66. data/rakelib/create_release.rake +662 -37
  67. data/script/check_command_docs +4 -2
  68. data/script/check_cpln_links +25 -11
  69. data/script/precommit/check_command_docs +22 -0
  70. data/script/precommit/check_cpln_links +21 -0
  71. data/script/precommit/check_trailing_newlines +68 -0
  72. data/script/precommit/get_changed_files +49 -0
  73. data/script/precommit/ruby_autofix +52 -0
  74. data/script/precommit/ruby_lint +33 -0
  75. metadata +52 -14
@@ -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
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "cgi"
5
+ require "net/http"
6
+ require "yaml"
7
+
8
+ require_relative "repo_introspection"
9
+ require_relative "github_flow_readiness/checks"
10
+
11
+ # Drives the readiness checks that gate `cpflow generate-github-actions`. The actual
12
+ # checks live in `GithubFlowReadiness::Checks`; this class is the host that owns the
13
+ # shared lockfile parser, package.json parser, HTTP version cache, and registry-check
14
+ # helpers used across multiple checks. Add a new check by creating a class with `call`
15
+ # under `GithubFlowReadiness::Checks` and registering it in `CHECKS`.
16
+ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
17
+ Result = GithubFlowReadiness::Result
18
+ RegistryCheck = Struct.new(
19
+ :dependencies,
20
+ :empty_message,
21
+ :missing_prefix,
22
+ :unknown_prefix,
23
+ :success_noun,
24
+ :availability_proc,
25
+ :registry_name,
26
+ keyword_init: true
27
+ )
28
+
29
+ CHECKS = [
30
+ GithubFlowReadiness::Checks::RailsApp,
31
+ GithubFlowReadiness::Checks::RubyVersion,
32
+ GithubFlowReadiness::Checks::BundlerVersion,
33
+ GithubFlowReadiness::Checks::Dockerfile,
34
+ GithubFlowReadiness::Checks::SqliteProduction,
35
+ GithubFlowReadiness::Checks::GemSources,
36
+ GithubFlowReadiness::Checks::GemExactPins,
37
+ GithubFlowReadiness::Checks::NpmExactPins
38
+ ].freeze
39
+
40
+ PUBLIC_RUBYGEMS_REMOTE = "https://rubygems.org"
41
+ REGISTRY_FETCH_THREADS = 8
42
+ REGISTRY_FETCH_TIMEOUT_SECONDS = 60
43
+
44
+ attr_reader :root_path
45
+
46
+ def initialize(root_path: Dir.pwd)
47
+ @root_path = Pathname.new(root_path)
48
+ @package_json_parse_error = false
49
+ @rubygems_versions_cache = build_registry_cache
50
+ @npm_versions_cache = build_registry_cache
51
+ end
52
+
53
+ def results
54
+ @results ||= CHECKS.flat_map { |klass| wrap_check_result(klass.new(self).call) }
55
+ end
56
+
57
+ def blockers?
58
+ results.any? { |result| result.status == :fail }
59
+ end
60
+
61
+ def summary
62
+ if blockers?
63
+ "Blockers found. Fix them before generating the Control Plane GitHub flow."
64
+ else
65
+ "No blocking readiness issues detected. Validate the real production build path before merging."
66
+ end
67
+ end
68
+
69
+ # ------------------------------------------------------------------
70
+ # Helpers exposed to check classes (and stubbed by specs).
71
+ # ------------------------------------------------------------------
72
+
73
+ def gem_dependencies
74
+ @gem_dependencies ||= load_gem_dependencies
75
+ end
76
+
77
+ def public_rubygems_dependency?(dependency)
78
+ return false unless dependency[:source_type] == :rubygems
79
+
80
+ remotes = dependency[:source_remotes]
81
+ remotes.empty? || remotes.all? { |remote| remote == PUBLIC_RUBYGEMS_REMOTE }
82
+ end
83
+
84
+ def inferred_ruby_version
85
+ version_string = RepoIntrospection.inferred_ruby_version_string(root_path.to_s)
86
+ Gem::Version.new(version_string) if version_string
87
+ end
88
+
89
+ def lockfile_bundler_version
90
+ file_path = root_path.join("Gemfile.lock")
91
+ return unless file_path.file?
92
+
93
+ lines = file_path.readlines(chomp: true)
94
+ bundler_index = lines.index("BUNDLED WITH")
95
+ return unless bundler_index
96
+
97
+ version = lines[(bundler_index + 1)..]&.find { |line| !line.strip.empty? }&.strip
98
+ return unless version
99
+
100
+ Gem::Version.new(version)
101
+ end
102
+
103
+ def sqlite_database_in_production?
104
+ RepoIntrospection.sqlite_database_in_production?(root_path.to_s)
105
+ end
106
+
107
+ def parsed_package_json
108
+ return @parsed_package_json if instance_variable_defined?(:@parsed_package_json)
109
+
110
+ package_json_path = root_path.join("package.json")
111
+ @package_json_parse_error = false
112
+ return @parsed_package_json = nil unless package_json_path.file?
113
+
114
+ @parsed_package_json = JSON.parse(package_json_path.read)
115
+ rescue JSON::ParserError
116
+ @package_json_parse_error = true
117
+ @parsed_package_json = nil
118
+ end
119
+
120
+ def package_json_parse_error
121
+ # Calling `parsed_package_json` here is the explicit "make sure parsing has run"
122
+ # trigger, so a reader of `package_json_parse_error` does not have to know that the
123
+ # flag is populated lazily. Guarded so memoized state isn't re-fetched.
124
+ parsed_package_json unless instance_variable_defined?(:@parsed_package_json)
125
+ @package_json_parse_error
126
+ end
127
+
128
+ def package_json_parse_error_result
129
+ Result.new(
130
+ status: :warn,
131
+ message: "Could not parse `package.json`; exact-pinned direct npm package readiness could not be fully verified."
132
+ )
133
+ end
134
+
135
+ def rubygems_registry_check
136
+ RegistryCheck.new(
137
+ dependencies: exact_rubygems_dependencies,
138
+ empty_message: "No exact-pinned direct Ruby gems to verify.",
139
+ missing_prefix: "Direct Ruby gem versions not available on RubyGems",
140
+ unknown_prefix: "Could not verify some exact-pinned Ruby gems against RubyGems",
141
+ success_noun: "direct Ruby gem",
142
+ availability_proc: method(:rubygems_requirement_available?),
143
+ registry_name: "RubyGems"
144
+ )
145
+ end
146
+
147
+ def npm_registry_check
148
+ RegistryCheck.new(
149
+ dependencies: exact_npm_dependencies,
150
+ empty_message: "No exact-pinned direct npm packages to verify.",
151
+ missing_prefix: "Direct npm package versions not available on npm",
152
+ unknown_prefix: "Could not verify some exact-pinned npm packages against npm",
153
+ success_noun: "direct npm package",
154
+ availability_proc: method(:npm_dependency_available?),
155
+ registry_name: "npm"
156
+ )
157
+ end
158
+
159
+ def exact_pin_registry_result(check)
160
+ return Result.new(status: :info, message: check.empty_message) if check.dependencies.empty?
161
+
162
+ grouped = partition_dependencies(check.dependencies, check.availability_proc)
163
+ results = []
164
+ results << registry_unavailable_result(check, grouped[:unavailable]) if grouped[:unavailable].any?
165
+ results << registry_unknown_result(check, grouped[:unknown]) if grouped[:unknown].any?
166
+ return results if results.any?
167
+
168
+ Result.new(status: :pass, message: registry_success_message(check))
169
+ end
170
+
171
+ # Stubbed in specs; keep public.
172
+ def fetch_rubygems_versions(name)
173
+ fetch_with_cache(rubygems_versions_cache, name) { fetch_versions_from_rubygems(name) }
174
+ end
175
+
176
+ # Stubbed in specs; keep public.
177
+ def fetch_npm_versions(name)
178
+ fetch_with_cache(npm_versions_cache, name) { fetch_versions_from_npm(name) }
179
+ end
180
+
181
+ private
182
+
183
+ attr_reader :rubygems_versions_cache, :npm_versions_cache
184
+
185
+ # Wrap a check's return value into an array. Avoid Kernel#Array on a Result Struct,
186
+ # which would unpack it into [status, message] instead of wrapping it.
187
+ def wrap_check_result(value)
188
+ return [] if value.nil?
189
+ return value if value.is_a?(Array)
190
+
191
+ [value]
192
+ end
193
+
194
+ def build_registry_cache
195
+ { store: {}, mutex: Mutex.new }
196
+ end
197
+
198
+ def exact_rubygems_dependencies
199
+ gem_dependencies.select do |dependency|
200
+ public_rubygems_dependency?(dependency) && dependency[:exact_version]
201
+ end
202
+ end
203
+
204
+ def exact_npm_dependencies
205
+ package_json = parsed_package_json
206
+ return [] unless package_json
207
+
208
+ collect_exact_dependencies(
209
+ package_json.fetch("dependencies", {}),
210
+ package_json.fetch("devDependencies", {})
211
+ )
212
+ end
213
+
214
+ def collect_exact_dependencies(*dependency_sets)
215
+ dependency_sets.flat_map { |dependencies| exact_dependency_entries(dependencies) }
216
+ end
217
+
218
+ def exact_dependency_entries(dependencies)
219
+ dependencies.filter_map do |name, version|
220
+ { name: name, exact_version: version } if exact_version_string?(version)
221
+ end
222
+ end
223
+
224
+ def exact_version_string?(version)
225
+ version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/)
226
+ end
227
+
228
+ def rubygems_requirement_available?(dependency)
229
+ versions = fetch_rubygems_versions(dependency[:name])
230
+ return nil unless versions
231
+
232
+ requirement = dependency[:requirement]
233
+ versions.any? { |version| requirement.satisfied_by?(Gem::Version.new(version)) }
234
+ end
235
+
236
+ def npm_dependency_available?(dependency)
237
+ versions = fetch_npm_versions(dependency[:name])
238
+ return nil unless versions
239
+
240
+ versions.include?(dependency[:exact_version])
241
+ end
242
+
243
+ # Fan out registry lookups across a small thread pool. Each HTTP call has a 5s timeout
244
+ # (see `http_get`), and the join deadline below bounds cases such as DNS resolution
245
+ # hangs that Net::HTTP does not cover. Results are memoized per dependency name;
246
+ # serially this scaled linearly with dependency count, which made readiness slow for
247
+ # repos with many exact pins.
248
+ def partition_dependencies(dependencies, availability_proc)
249
+ results = fetch_availability_in_parallel(dependencies, availability_proc)
250
+ results.each_with_object(unavailable: [], unknown: []) do |(dependency, status), grouped|
251
+ case status
252
+ when false
253
+ grouped[:unavailable] << dependency
254
+ when nil
255
+ grouped[:unknown] << dependency
256
+ end
257
+ end
258
+ end
259
+
260
+ def fetch_availability_in_parallel(dependencies, availability_proc)
261
+ queue = Queue.new
262
+ indexed = dependencies.each_with_index.to_a
263
+ indexed.each { |entry| queue << entry }
264
+ results = Array.new(dependencies.length)
265
+ result_state = { mutex: Mutex.new, timed_out: false }
266
+
267
+ workers = build_availability_workers(queue, availability_proc, results, dependencies.length, result_state)
268
+ wait_for_availability_workers(workers, result_state)
269
+ fill_missing_availability_results(indexed, results)
270
+ results
271
+ end
272
+
273
+ def build_availability_workers(queue, availability_proc, results, dependency_count, result_state)
274
+ Array.new([REGISTRY_FETCH_THREADS, dependency_count].min) do
275
+ Thread.new { drain_availability_queue(queue, availability_proc, results, result_state) }
276
+ end
277
+ end
278
+
279
+ def wait_for_availability_workers(workers, result_state)
280
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REGISTRY_FETCH_TIMEOUT_SECONDS
281
+ workers.each do |worker|
282
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
283
+ next if worker.join(remaining.positive? ? remaining : 0)
284
+
285
+ # Timed-out workers are not killed; their in-flight HTTP calls have their own
286
+ # short timeouts and then exit before writing any more results.
287
+ mark_availability_timeout(result_state)
288
+ end
289
+ end
290
+
291
+ def fill_missing_availability_results(indexed, results)
292
+ indexed.each { |dependency, index| results[index] ||= [dependency, nil] }
293
+ end
294
+
295
+ def drain_availability_queue(queue, availability_proc, results, result_state)
296
+ loop do
297
+ break if availability_timed_out?(result_state)
298
+
299
+ dependency, index = queue.pop(true)
300
+ write_availability_result(results, index, [dependency, availability_proc.call(dependency)], result_state)
301
+ rescue ThreadError
302
+ break
303
+ end
304
+ end
305
+
306
+ def availability_timed_out?(result_state)
307
+ result_state[:mutex].synchronize { result_state[:timed_out] }
308
+ end
309
+
310
+ def mark_availability_timeout(result_state)
311
+ result_state[:mutex].synchronize { result_state[:timed_out] = true }
312
+ end
313
+
314
+ def write_availability_result(results, index, value, result_state)
315
+ result_state[:mutex].synchronize do
316
+ results[index] = value unless result_state[:timed_out]
317
+ end
318
+ end
319
+
320
+ # Worker threads in `fetch_availability_in_parallel` may share the cache, so guard
321
+ # both the duplicate-fetch check and the assignment with a mutex. The mutex is released
322
+ # before `yield` so a slow HTTP request does not block other workers from reading cached
323
+ # entries; the trade-off is that N threads racing on a cold cache for the same name can
324
+ # all fire HTTP requests in parallel. The duplicate-fetch window is bounded:
325
+ # the queue assigns each (dependency, index) entry to exactly one worker, so a name
326
+ # only races when two workers happen to look up the same `name` from different
327
+ # dependencies. At most REGISTRY_FETCH_THREADS duplicate requests can fire per cold
328
+ # name on the first parallel sweep — acceptable because the fetches are idempotent and
329
+ # the public registries (rubygems.org, registry.npmjs.org) are designed to absorb that.
330
+ # On the write side this means the second `cache[:store][name] = value` overwrites the
331
+ # first; that is safe (no `||=` guard needed) precisely because both racers fetched the
332
+ # same registry data and so write the same value.
333
+ def fetch_with_cache(cache, name)
334
+ cache[:mutex].synchronize do
335
+ return cache[:store][name] if cache[:store].key?(name)
336
+ end
337
+
338
+ value = yield
339
+ cache[:mutex].synchronize { cache[:store][name] = value }
340
+ end
341
+
342
+ def fetch_versions_from_rubygems(name)
343
+ uri = URI("https://rubygems.org/api/v1/versions/#{CGI.escape(name)}.json")
344
+ response = http_get(uri)
345
+ return nil unless response.is_a?(Net::HTTPSuccess)
346
+
347
+ JSON.parse(response.body).map { |entry| entry["number"] }
348
+ rescue JSON::ParserError
349
+ nil
350
+ end
351
+
352
+ def fetch_versions_from_npm(name)
353
+ uri = URI("https://registry.npmjs.org/#{npm_package_path_segment(name)}")
354
+ response = http_get(uri)
355
+ return nil unless response.is_a?(Net::HTTPSuccess)
356
+
357
+ JSON.parse(response.body).fetch("versions", {}).keys
358
+ rescue JSON::ParserError, URI::InvalidURIError
359
+ nil
360
+ end
361
+
362
+ # Encodes the `/` in scoped package names so the registry path is valid.
363
+ # Other characters that are rare-but-legal in npm names (e.g. `%`) are not
364
+ # encoded here; URI construction errors are rescued in `fetch_versions_from_npm`
365
+ # and treated as unknown availability.
366
+ def npm_package_path_segment(name)
367
+ name.gsub("/", "%2F")
368
+ end
369
+
370
+ def http_get(uri)
371
+ http = Net::HTTP.new(uri.host, uri.port)
372
+ http.use_ssl = true
373
+ http.open_timeout = 5
374
+ http.read_timeout = 5
375
+ http.get(uri.request_uri)
376
+ rescue StandardError => e
377
+ warn "github_flow_readiness: HTTP GET #{uri} failed: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
378
+ nil
379
+ end
380
+
381
+ def load_gem_dependencies
382
+ lockfile_path = root_path.join("Gemfile.lock")
383
+ return [] unless lockfile_path.file?
384
+
385
+ parse_gem_dependencies(lockfile_path)
386
+ rescue StandardError => e
387
+ warn "cpflow: failed to parse Gemfile.lock: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
388
+ []
389
+ end
390
+
391
+ # Parse Gemfile.lock via Bundler::LockfileParser rather than Bundler::Dsl#eval_gemfile.
392
+ # `eval_gemfile` instance_evals the user's Gemfile, which executes arbitrary Ruby. Readiness
393
+ # checks run against untrusted project trees, so we keep the trust boundary at "parse the
394
+ # lockfile only" — no Ruby from the user's repo is ever executed here.
395
+ def parse_gem_dependencies(lockfile_path)
396
+ parser = Bundler::LockfileParser.new(lockfile_path.read)
397
+ parser.dependencies.values.map do |dependency|
398
+ spec = parser.specs.find { |locked_spec| locked_spec.name == dependency.name }
399
+ build_gem_dependency(dependency, source: spec&.source)
400
+ end
401
+ end
402
+
403
+ def build_gem_dependency(dependency, source:)
404
+ {
405
+ name: dependency.name,
406
+ exact_version: exact_gem_version(dependency),
407
+ requirement: dependency.requirement,
408
+ source_type: gem_source_type(source),
409
+ source_remotes: gem_source_remotes(source)
410
+ }
411
+ end
412
+
413
+ def exact_gem_version(dependency)
414
+ dependency.requirement.requirements.first.last.to_s if dependency.requirement.exact?
415
+ end
416
+
417
+ def gem_source_type(source)
418
+ return :rubygems if source.nil? || source.is_a?(Bundler::Source::Rubygems)
419
+ return :path if source.is_a?(Bundler::Source::Path)
420
+ return :git if source.is_a?(Bundler::Source::Git)
421
+
422
+ :other
423
+ end
424
+
425
+ def gem_source_remotes(source)
426
+ return [] unless source.respond_to?(:remotes)
427
+
428
+ Array(source.remotes).map { |remote| normalize_remote(remote) }
429
+ end
430
+
431
+ def normalize_remote(remote)
432
+ remote.to_s.sub(%r{/+\z}, "")
433
+ end
434
+
435
+ def format_dependencies(dependencies)
436
+ dependencies.map { |dependency| "`#{dependency[:name]}@#{dependency[:exact_version]}`" }.join(", ")
437
+ end
438
+
439
+ def registry_success_message(check)
440
+ dependency_count = check.dependencies.length
441
+ noun = "#{check.success_noun}#{'s' if dependency_count != 1}"
442
+
443
+ "Checked #{dependency_count} exact-pinned #{noun}; all appear available on #{check.registry_name}."
444
+ end
445
+
446
+ def registry_unavailable_result(check, dependencies)
447
+ Result.new(status: :fail, message: "#{check.missing_prefix}: #{format_dependencies(dependencies)}.")
448
+ end
449
+
450
+ def registry_unknown_result(check, dependencies)
451
+ Result.new(status: :warn, message: "#{check.unknown_prefix}: #{format_dependencies(dependencies)}.")
452
+ end
453
+ end