cpflow 4.1.1 → 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.
- checksums.yaml +4 -4
- data/.claude/commands/update-changelog.md +367 -0
- data/.github/workflows/claude-code-review.yml +44 -0
- data/.github/workflows/claude.yml +55 -0
- data/.gitignore +2 -0
- data/.overcommit.yml +43 -3
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +39 -3
- data/CONTRIBUTING.md +6 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +93 -73
- data/README.md +53 -22
- data/cpflow.gemspec +5 -5
- data/docs/ai-github-flow-prompt.md +61 -0
- data/docs/ci-automation.md +335 -0
- data/docs/commands.md +70 -5
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +14 -0
- data/lib/command/cleanup_images.rb +1 -1
- data/lib/command/cleanup_stale_apps.rb +1 -1
- data/lib/command/copy_image_from_upstream.rb +14 -3
- data/lib/command/exists.rb +13 -2
- data/lib/command/generate.rb +153 -4
- data/lib/command/generate_github_actions.rb +170 -0
- data/lib/command/generator_helpers.rb +31 -0
- data/lib/command/github_flow_readiness.rb +37 -0
- data/lib/command/ps_wait.rb +5 -1
- data/lib/command/run.rb +4 -21
- data/lib/command/terraform/generate.rb +1 -0
- data/lib/command/version.rb +1 -0
- data/lib/constants/exit_code.rb +1 -0
- data/lib/core/config.rb +1 -1
- data/lib/core/controlplane.rb +13 -10
- data/lib/core/controlplane_api_direct.rb +25 -3
- data/lib/core/github_flow_readiness/checks.rb +143 -0
- data/lib/core/github_flow_readiness_service.rb +453 -0
- data/lib/core/repo_introspection.rb +118 -0
- data/lib/core/terraform_config/dsl.rb +1 -1
- data/lib/core/terraform_config/local_variable.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +66 -3
- data/lib/generator_templates/Dockerfile +59 -3
- data/lib/generator_templates/controlplane.yml +27 -39
- data/lib/generator_templates/entrypoint.sh +1 -1
- data/lib/generator_templates/release_script.sh +23 -0
- data/lib/generator_templates/templates/app.yml +5 -8
- data/lib/generator_templates/templates/rails.yml +2 -11
- data/lib/generator_templates_sqlite/controlplane.yml +46 -0
- data/lib/generator_templates_sqlite/release_script.sh +25 -0
- data/lib/generator_templates_sqlite/templates/app.yml +15 -0
- data/lib/generator_templates_sqlite/templates/db.yml +6 -0
- data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
- data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
- data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
- data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
- data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
- data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
- data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
- data/rakelib/create_release.rake +662 -37
- data/script/check_command_docs +4 -2
- data/script/check_cpln_links +25 -11
- data/script/precommit/check_command_docs +22 -0
- data/script/precommit/check_cpln_links +21 -0
- data/script/precommit/check_trailing_newlines +68 -0
- data/script/precommit/get_changed_files +49 -0
- data/script/precommit/ruby_autofix +52 -0
- data/script/precommit/ruby_lint +33 -0
- metadata +56 -15
- /data/docs/{migrating.md → migrating-heroku-to-control-plane.md} +0 -0
|
@@ -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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RepoIntrospection
|
|
6
|
+
DEFAULT_APP_PREFIX = "my-app"
|
|
7
|
+
RUBY_VERSION_DIRECTIVE_PATTERN = /^\s*ruby\s+['"\d]/
|
|
8
|
+
RUBY_VERSION_DIRECTIVE_PREFIX = /^\s*ruby\s+/
|
|
9
|
+
|
|
10
|
+
# Pure string → version-string extractor. Strips a leading `ruby-` prefix and returns
|
|
11
|
+
# the first `MAJOR.MINOR[.PATCH]` found in the source, or nil.
|
|
12
|
+
def self.parse_ruby_version_string(source)
|
|
13
|
+
normalized = source.strip.sub(/\Aruby-/, "")
|
|
14
|
+
normalized[/\d+\.\d+(?:\.\d+)?/]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the first Ruby version string the repo declares, checked in the order Bundler
|
|
18
|
+
# itself uses: `.ruby-version`, then `.tool-versions`, then `Gemfile`. Returns nil when
|
|
19
|
+
# no source declares a version. Both `Command::Generator` and `GithubFlowReadinessService`
|
|
20
|
+
# call into this so a future format change (e.g. `.tool-versions`) only updates here.
|
|
21
|
+
def self.inferred_ruby_version_string(root)
|
|
22
|
+
ruby_version_from_ruby_version_file(root) ||
|
|
23
|
+
ruby_version_from_tool_versions(root) ||
|
|
24
|
+
ruby_version_from_gemfile(root)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.ruby_version_from_ruby_version_file(root)
|
|
28
|
+
path = File.join(root, ".ruby-version")
|
|
29
|
+
return unless File.file?(path)
|
|
30
|
+
|
|
31
|
+
parse_ruby_version_string(File.read(path))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.ruby_version_from_tool_versions(root)
|
|
35
|
+
path = File.join(root, ".tool-versions")
|
|
36
|
+
return unless File.file?(path)
|
|
37
|
+
|
|
38
|
+
ruby_line = File.readlines(path, chomp: true).find { |line| line.match?(RUBY_VERSION_DIRECTIVE_PREFIX) }
|
|
39
|
+
return unless ruby_line
|
|
40
|
+
|
|
41
|
+
parse_ruby_version_string(ruby_line.sub(RUBY_VERSION_DIRECTIVE_PREFIX, ""))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.ruby_version_from_gemfile(root)
|
|
45
|
+
path = File.join(root, "Gemfile")
|
|
46
|
+
return unless File.file?(path)
|
|
47
|
+
|
|
48
|
+
ruby_lines = File.readlines(path, chomp: true).select { |line| line.match?(RUBY_VERSION_DIRECTIVE_PREFIX) }
|
|
49
|
+
ruby_line = ruby_lines.find { |line| line.match?(RUBY_VERSION_DIRECTIVE_PATTERN) }
|
|
50
|
+
warn_dynamic_ruby_directive if ruby_lines.any? && ruby_line.nil?
|
|
51
|
+
return unless ruby_line
|
|
52
|
+
|
|
53
|
+
parse_ruby_version_string(ruby_line.sub(RUBY_VERSION_DIRECTIVE_PREFIX, ""))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.warn_dynamic_ruby_directive
|
|
57
|
+
return unless ENV["CPFLOW_DEBUG"]
|
|
58
|
+
|
|
59
|
+
warn "cpflow: Gemfile has a dynamic `ruby` directive; falling back to the default Ruby version"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns a Control Plane-safe app prefix derived from the basename of `root`:
|
|
63
|
+
# lower-cased, with non-alphanumeric runs collapsed to dashes and stripped from
|
|
64
|
+
# the ends. Falls back to DEFAULT_APP_PREFIX when the result is empty.
|
|
65
|
+
def self.inferred_app_prefix(root)
|
|
66
|
+
sanitized = File.basename(root)
|
|
67
|
+
.downcase
|
|
68
|
+
.gsub(/[^a-z0-9]+/, "-")
|
|
69
|
+
.gsub(/\A-+|-+\z/, "")
|
|
70
|
+
|
|
71
|
+
sanitized.empty? ? DEFAULT_APP_PREFIX : sanitized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns true if `config/database.yml` under `root` configures SQLite for production.
|
|
75
|
+
# YAML merge keys such as `<<: *default` are resolved by safe_load, so only the
|
|
76
|
+
# final production hash should be inspected.
|
|
77
|
+
def self.sqlite_database_in_production?(root)
|
|
78
|
+
path = File.join(root, "config/database.yml")
|
|
79
|
+
return false unless File.file?(path)
|
|
80
|
+
|
|
81
|
+
parsed = safe_load_database_yml(File.read(path))
|
|
82
|
+
return false unless parsed.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
production = parsed["production"]
|
|
85
|
+
return false unless production.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
url = production["url"]
|
|
88
|
+
return sqlite_database_url?(url) if url.is_a?(String) && !url.strip.empty?
|
|
89
|
+
|
|
90
|
+
sqlite_adapter_in_hash?(production)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.safe_load_database_yml(raw_contents)
|
|
94
|
+
# ERB conditionals can change YAML structure, so avoid guessing. Output-only
|
|
95
|
+
# ERB is stubbed as a scalar so common Rails defaults like `pool: <%= ... %>`
|
|
96
|
+
# still parse, but control-flow ERB returns unknown. `<%- ... %>` is a
|
|
97
|
+
# whitespace-trimming code tag, not an output tag, so treat it as unknown too.
|
|
98
|
+
# Callers treat unknown as non-SQLite and
|
|
99
|
+
# emit the default Postgres scaffold rather than guessing wrong.
|
|
100
|
+
return nil if raw_contents.match?(/<%(?![=#])/m)
|
|
101
|
+
|
|
102
|
+
stubbed = raw_contents.gsub(/<%=.*?%>/m, "__erb__").gsub(/<%#.*?%>/m, "")
|
|
103
|
+
YAML.safe_load(stubbed, aliases: true, permitted_classes: [Symbol])
|
|
104
|
+
rescue Psych::SyntaxError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.sqlite_adapter_in_hash?(config)
|
|
109
|
+
return false unless config.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
adapter = config["adapter"]
|
|
112
|
+
adapter.is_a?(String) && adapter.strip.start_with?("sqlite3")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.sqlite_database_url?(url)
|
|
116
|
+
url.strip.downcase.start_with?("sqlite:", "sqlite3:")
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/cpflow/version.rb
CHANGED
data/lib/cpflow.rb
CHANGED
|
@@ -51,10 +51,15 @@ module Cpflow
|
|
|
51
51
|
|
|
52
52
|
def self.start(*args)
|
|
53
53
|
ENV["CPLN_SKIP_UPDATE_CHECK"] = "true"
|
|
54
|
+
ENV["NODE_NO_WARNINGS"] = "1"
|
|
54
55
|
|
|
55
|
-
check_cpln_version
|
|
56
|
-
check_cpflow_version
|
|
57
56
|
fix_help_option
|
|
57
|
+
# Thor's `start(args = ARGV.dup, ...)` accepts an explicit argv as the first
|
|
58
|
+
# positional argument. Use that when present so the startup-check decision matches
|
|
59
|
+
# the command Thor is about to dispatch (and so test invocations don't pick up
|
|
60
|
+
# rspec's ARGV by accident).
|
|
61
|
+
argv = args.first.is_a?(Array) ? args.first : ARGV
|
|
62
|
+
run_startup_checks if requires_startup_checks?(argv)
|
|
58
63
|
|
|
59
64
|
super
|
|
60
65
|
end
|
|
@@ -119,13 +124,71 @@ module Cpflow
|
|
|
119
124
|
end
|
|
120
125
|
private_class_method :subcommand?
|
|
121
126
|
|
|
127
|
+
def self.run_startup_checks
|
|
128
|
+
check_cpln_version
|
|
129
|
+
check_cpflow_version
|
|
130
|
+
end
|
|
131
|
+
private_class_method :run_startup_checks
|
|
132
|
+
|
|
133
|
+
def self.requires_startup_checks?(argv = ARGV)
|
|
134
|
+
return false if argv.empty?
|
|
135
|
+
return false if help_request?(argv)
|
|
136
|
+
return false if version_flag?(argv)
|
|
137
|
+
|
|
138
|
+
command_class = command_class_for_argv(argv)
|
|
139
|
+
# Default to true when the command name is unrecognized so a typo still gets the
|
|
140
|
+
# version check (Thor's "unknown command" error then surfaces). Pre-PR behavior
|
|
141
|
+
# was always-on; only known commands explicitly opt out.
|
|
142
|
+
command_class ? command_class::REQUIRES_STARTUP_CHECKS : true
|
|
143
|
+
end
|
|
144
|
+
private_class_method :requires_startup_checks?
|
|
145
|
+
|
|
146
|
+
def self.help_request?(argv)
|
|
147
|
+
help_mappings = Thor::HELP_MAPPINGS + ["help"]
|
|
148
|
+
help_mappings.include?(argv.first)
|
|
149
|
+
end
|
|
150
|
+
private_class_method :help_request?
|
|
151
|
+
|
|
152
|
+
def self.version_flag?(argv)
|
|
153
|
+
%w[--version -v].include?(argv.first)
|
|
154
|
+
end
|
|
155
|
+
private_class_method :version_flag?
|
|
156
|
+
|
|
157
|
+
def self.command_class_for_argv(argv)
|
|
158
|
+
first_arg = argv[0]
|
|
159
|
+
return if first_arg.nil?
|
|
160
|
+
|
|
161
|
+
return subcommand_class_for_argv(first_arg, argv[1]) if subcommand_names.include?(first_arg)
|
|
162
|
+
|
|
163
|
+
top_level_command_class_for(first_arg)
|
|
164
|
+
end
|
|
165
|
+
private_class_method :command_class_for_argv
|
|
166
|
+
|
|
167
|
+
def self.subcommand_class_for_argv(subcommand_name, command_name)
|
|
168
|
+
return if command_name.nil?
|
|
169
|
+
|
|
170
|
+
all_base_commands[:"#{subcommand_name}_#{command_name.tr('-', '_')}"] ||
|
|
171
|
+
all_base_commands.values.find do |command_class|
|
|
172
|
+
subcommand_name == command_class::SUBCOMMAND_NAME && command_name == command_class::NAME
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
private_class_method :subcommand_class_for_argv
|
|
176
|
+
|
|
177
|
+
def self.top_level_command_class_for(command_name)
|
|
178
|
+
all_base_commands[command_name.tr("-", "_").to_sym] ||
|
|
179
|
+
all_base_commands.values.find do |command_class|
|
|
180
|
+
command_class::SUBCOMMAND_NAME.nil? && command_name == command_class::NAME
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
private_class_method :top_level_command_class_for
|
|
184
|
+
|
|
122
185
|
# Needed to silence deprecation warning
|
|
123
186
|
def self.exit_on_failure?
|
|
124
187
|
true
|
|
125
188
|
end
|
|
126
189
|
|
|
127
190
|
# Needed to be able to use "run" as a command
|
|
128
|
-
def self.is_thor_reserved_word?(word, type) # rubocop:disable Naming/
|
|
191
|
+
def self.is_thor_reserved_word?(word, type) # rubocop:disable Naming/PredicatePrefix
|
|
129
192
|
return false if word == "run"
|
|
130
193
|
|
|
131
194
|
super
|