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
data/lib/core/controlplane.rb
CHANGED
|
@@ -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
|
|
97
|
-
cmd
|
|
98
|
+
cmd = ["docker", "build", "--platform=linux/amd64", "-t", image, "-f", dockerfile]
|
|
99
|
+
cmd << "--progress=plain" if ControlplaneApiDirect.trace
|
|
98
100
|
|
|
99
|
-
cmd
|
|
100
|
-
build_args.each { |build_arg| cmd
|
|
101
|
-
cmd
|
|
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|
|
|
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"] == "/" }
|
|
@@ -404,11 +406,12 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
404
406
|
end
|
|
405
407
|
|
|
406
408
|
# apply
|
|
407
|
-
def apply_template(data) # rubocop:disable Metrics/MethodLength
|
|
409
|
+
def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
408
410
|
Tempfile.create do |f|
|
|
409
411
|
f.write(data)
|
|
410
412
|
f.rewind
|
|
411
413
|
cmd = "cpln apply #{gvc_org} --file #{f.path}"
|
|
414
|
+
cmd += " --ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
|
|
412
415
|
if Shell.tmp_stderr
|
|
413
416
|
cmd += " 2> #{Shell.tmp_stderr.path}" if Shell.should_hide_output?
|
|
414
417
|
|
|
@@ -429,8 +432,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
429
432
|
end
|
|
430
433
|
end
|
|
431
434
|
|
|
432
|
-
def apply_hash(data)
|
|
433
|
-
apply_template(data.to_yaml)
|
|
435
|
+
def apply_hash(data, wait: false)
|
|
436
|
+
apply_template(data.to_yaml, wait: wait)
|
|
434
437
|
end
|
|
435
438
|
|
|
436
439
|
def parse_apply_result(result) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
@@ -1,5 +1,27 @@
|
|
|
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-]+): (.+)$/
|
|
6
|
+
|
|
7
|
+
def <<(msg)
|
|
8
|
+
$stdout << redact(msg)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def redact(msg)
|
|
14
|
+
msg.lines.map { |line| redact_line(line) }.join
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def redact_line(line)
|
|
18
|
+
match = line.match(HEADER_REGEX)
|
|
19
|
+
return line.gsub(/[\w\-._]{50,}/, "[REDACTED]") unless match
|
|
20
|
+
|
|
21
|
+
SAFE_HEADERS.any? { |h| h.casecmp(match[1]).zero? } ? line : "#{match[1]}: [REDACTED]\n"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
3
25
|
class ControlplaneApiDirect
|
|
4
26
|
API_METHODS = {
|
|
5
27
|
get: Net::HTTP::Get,
|
|
@@ -15,7 +37,7 @@ class ControlplaneApiDirect
|
|
|
15
37
|
# /^[\w\-._]{1134}$/ # 'cpln profile token' format
|
|
16
38
|
# ).freeze
|
|
17
39
|
|
|
18
|
-
API_TOKEN_REGEX = /^[\w\-._]
|
|
40
|
+
API_TOKEN_REGEX = /^[\w\-._]+$/
|
|
19
41
|
API_TOKEN_EXPIRY_SECONDS = 300
|
|
20
42
|
|
|
21
43
|
class << self
|
|
@@ -37,7 +59,7 @@ class ControlplaneApiDirect
|
|
|
37
59
|
|
|
38
60
|
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
39
61
|
http.use_ssl = uri.scheme == "https"
|
|
40
|
-
http.set_debug_output(
|
|
62
|
+
http.set_debug_output(RedactedDebugOutput.new) if trace
|
|
41
63
|
|
|
42
64
|
response = http.start { |ht| ht.request(request) }
|
|
43
65
|
|
|
@@ -89,7 +111,7 @@ class ControlplaneApiDirect
|
|
|
89
111
|
def should_refresh_api_token?
|
|
90
112
|
return false unless api_token[:comes_from_profile]
|
|
91
113
|
|
|
92
|
-
payload, = JWT.decode(api_token[:token], nil, false)
|
|
114
|
+
payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
|
|
93
115
|
difference_in_seconds = payload["exp"] - Time.now.to_i
|
|
94
116
|
|
|
95
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
|