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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude-code-review.yml +44 -0
  4. data/.github/workflows/claude.yml +55 -0
  5. data/.gitignore +2 -0
  6. data/.overcommit.yml +43 -3
  7. data/.rubocop.yml +3 -3
  8. data/CHANGELOG.md +39 -3
  9. data/CONTRIBUTING.md +6 -0
  10. data/Gemfile +8 -7
  11. data/Gemfile.lock +93 -73
  12. data/README.md +53 -22
  13. data/cpflow.gemspec +5 -5
  14. data/docs/ai-github-flow-prompt.md +61 -0
  15. data/docs/ci-automation.md +335 -0
  16. data/docs/commands.md +70 -5
  17. data/docs/releasing.md +153 -0
  18. data/lib/command/ai_github_flow_prompt.rb +47 -0
  19. data/lib/command/base.rb +14 -0
  20. data/lib/command/cleanup_images.rb +1 -1
  21. data/lib/command/cleanup_stale_apps.rb +1 -1
  22. data/lib/command/copy_image_from_upstream.rb +14 -3
  23. data/lib/command/exists.rb +13 -2
  24. data/lib/command/generate.rb +153 -4
  25. data/lib/command/generate_github_actions.rb +170 -0
  26. data/lib/command/generator_helpers.rb +31 -0
  27. data/lib/command/github_flow_readiness.rb +37 -0
  28. data/lib/command/ps_wait.rb +5 -1
  29. data/lib/command/run.rb +4 -21
  30. data/lib/command/terraform/generate.rb +1 -0
  31. data/lib/command/version.rb +1 -0
  32. data/lib/constants/exit_code.rb +1 -0
  33. data/lib/core/config.rb +1 -1
  34. data/lib/core/controlplane.rb +13 -10
  35. data/lib/core/controlplane_api_direct.rb +25 -3
  36. data/lib/core/github_flow_readiness/checks.rb +143 -0
  37. data/lib/core/github_flow_readiness_service.rb +453 -0
  38. data/lib/core/repo_introspection.rb +118 -0
  39. data/lib/core/terraform_config/dsl.rb +1 -1
  40. data/lib/core/terraform_config/local_variable.rb +1 -1
  41. data/lib/cpflow/version.rb +1 -1
  42. data/lib/cpflow.rb +66 -3
  43. data/lib/generator_templates/Dockerfile +59 -3
  44. data/lib/generator_templates/controlplane.yml +27 -39
  45. data/lib/generator_templates/entrypoint.sh +1 -1
  46. data/lib/generator_templates/release_script.sh +23 -0
  47. data/lib/generator_templates/templates/app.yml +5 -8
  48. data/lib/generator_templates/templates/rails.yml +2 -11
  49. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  50. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  51. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  52. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  53. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  54. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  58. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  59. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  60. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  61. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  62. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  66. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  68. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  69. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  70. data/rakelib/create_release.rake +662 -37
  71. data/script/check_command_docs +4 -2
  72. data/script/check_cpln_links +25 -11
  73. data/script/precommit/check_command_docs +22 -0
  74. data/script/precommit/check_cpln_links +21 -0
  75. data/script/precommit/check_trailing_newlines +68 -0
  76. data/script/precommit/get_changed_files +49 -0
  77. data/script/precommit/ruby_autofix +52 -0
  78. data/script/precommit/ruby_lint +33 -0
  79. metadata +56 -15
  80. /data/docs/{migrating.md → migrating-heroku-to-control-plane.md} +0 -0
@@ -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"] == "/" }
@@ -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\-._]+$/.freeze
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($stdout) if trace
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