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
data/lib/command/base.rb CHANGED
@@ -40,6 +40,8 @@ module Command
40
40
  WITH_INFO_HEADER = true
41
41
  # Which validations to run before the command
42
42
  VALIDATIONS = %w[config].freeze
43
+ # Whether or not to run CLI startup checks such as cpln availability and update checks
44
+ REQUIRES_STARTUP_CHECKS = true
43
45
 
44
46
  def initialize(config)
45
47
  @config = config
@@ -316,6 +318,18 @@ module Command
316
318
  }
317
319
  end
318
320
 
321
+ def self.staging_branch_option(required: false)
322
+ {
323
+ name: :staging_branch,
324
+ params: {
325
+ banner: "BRANCH",
326
+ desc: "Branch that should auto-deploy staging; defaults to main/master",
327
+ type: :string,
328
+ required: required
329
+ }
330
+ }
331
+ end
332
+
319
333
  def self.logs_limit_option(required: false)
320
334
  {
321
335
  name: :limit,
@@ -26,7 +26,7 @@ module Command
26
26
 
27
27
  progress.puts("Images to delete:")
28
28
  images_to_delete.each do |image|
29
- created = Shell.color((image[:created]).to_s, :red)
29
+ created = Shell.color(image[:created].to_s, :red)
30
30
  reason = Shell.color(image[:reason], :red)
31
31
  progress.puts(" - #{image[:name]} (#{created} - #{reason})")
32
32
  end
@@ -22,7 +22,7 @@ module Command
22
22
 
23
23
  progress.puts("Stale apps:")
24
24
  stale_apps.each do |app|
25
- progress.puts(" - #{app[:name]} (#{Shell.color((app[:date]).to_s, :red)})")
25
+ progress.puts(" - #{app[:name]} (#{Shell.color(app[:date].to_s, :red)})")
26
26
  end
27
27
 
28
28
  return unless confirm_delete
@@ -5,14 +5,14 @@ module Command
5
5
  NAME = "copy-image-from-upstream"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
- upstream_token_option(required: true),
8
+ upstream_token_option,
9
9
  image_option
10
10
  ].freeze
11
11
  DESCRIPTION = "Copies an image (by default the latest) from a source org to the current org"
12
12
  LONG_DESCRIPTION = <<~DESC
13
13
  - Copies an image (by default the latest) from a source org to the current org
14
14
  - The source app must be specified either through the `CPLN_UPSTREAM` env var or `upstream` in the `.controlplane/controlplane.yml` file
15
- - Additionally, the token for the source org must be provided through `--upstream-token` or `-t`
15
+ - The token for the source org must be provided through `--upstream-token`/`-t` or the `CPLN_UPSTREAM_TOKEN` env var
16
16
  - A `cpln` profile will be temporarily created to pull the image from the source org
17
17
  DESC
18
18
  EXAMPLES = <<~EX
@@ -20,6 +20,9 @@ module Command
20
20
  # Copies the latest image from the source org to the current org.
21
21
  cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN
22
22
 
23
+ # Equivalent call using an env var (avoids exposing the token via the OS process table).
24
+ CPLN_UPSTREAM_TOKEN=$UPSTREAM_TOKEN cpflow copy-image-from-upstream -a $APP_NAME
25
+
23
26
  # Copies a specific image from the source org to the current org.
24
27
  cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN --image appimage:123
25
28
  ```
@@ -30,7 +33,9 @@ module Command
30
33
 
31
34
  @upstream = ENV.fetch("CPLN_UPSTREAM", nil) || config[:upstream]
32
35
  @upstream_org = ENV.fetch("CPLN_ORG_UPSTREAM", nil) || config.find_app_config(@upstream)&.dig(:cpln_org)
36
+ @upstream_token = config.options[:upstream_token] || ENV.fetch("CPLN_UPSTREAM_TOKEN", nil)
33
37
  ensure_upstream_org!
38
+ ensure_upstream_token!
34
39
 
35
40
  create_upstream_profile
36
41
  fetch_upstream_image_url
@@ -51,6 +56,12 @@ module Command
51
56
  "and CPLN_ORG_UPSTREAM env var is not set."
52
57
  end
53
58
 
59
+ def ensure_upstream_token!
60
+ return if @upstream_token && !@upstream_token.strip.empty?
61
+
62
+ raise "Missing upstream token. Pass `--upstream-token`/`-t` or set the `CPLN_UPSTREAM_TOKEN` env var."
63
+ end
64
+
54
65
  def create_upstream_profile
55
66
  step("Creating upstream profile") do
56
67
  loop do
@@ -58,7 +69,7 @@ module Command
58
69
  break unless cp.profile_exists?(@upstream_profile)
59
70
  end
60
71
 
61
- cp.profile_create(@upstream_profile, config.options[:upstream_token])
72
+ cp.profile_create(@upstream_profile, @upstream_token)
62
73
  end
63
74
  end
64
75
 
@@ -9,15 +9,26 @@ module Command
9
9
  DESCRIPTION = "Shell-checks if an application (GVC) exists, useful in scripts"
10
10
  LONG_DESCRIPTION = <<~DESC
11
11
  - Shell-checks if an application (GVC) exists, useful in scripts, e.g.:
12
+ - Exits 0 when the app exists, 3 when it does not exist, and 64 for other errors.
12
13
  DESC
13
14
  EXAMPLES = <<~EX
14
15
  ```sh
15
- if [ cpflow exists -a $APP_NAME ]; ...
16
+ cpflow exists -a "$APP_NAME"
17
+ status=$?
18
+ if [ "$status" -eq 0 ]; then
19
+ echo "exists"
20
+ elif [ "$status" -eq 3 ]; then
21
+ echo "not found"
22
+ else
23
+ echo "error: cpflow exists exited $status"
24
+ fi
16
25
  ```
17
26
  EX
18
27
 
19
28
  def call
20
- exit(cp.fetch_gvc.nil? ? ExitCode::ERROR_DEFAULT : ExitCode::SUCCESS)
29
+ exit(cp.fetch_gvc.nil? ? ExitCode::NOT_FOUND : ExitCode::SUCCESS)
30
+ rescue StandardError => e
31
+ Shell.abort(e.message)
21
32
  end
22
33
  end
23
34
  end
@@ -1,32 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
5
+ require_relative "generator_helpers"
6
+ require_relative "../core/repo_introspection"
7
+
3
8
  module Command
4
- class Generator < Thor::Group
9
+ class Generator < Thor::Group # rubocop:disable Metrics/ClassLength
5
10
  include Thor::Actions
11
+ include GeneratorHelpers
12
+
13
+ COMMON_TEMPLATE_FILES = %w[
14
+ Dockerfile
15
+ entrypoint.sh
16
+ ].freeze
17
+ POSTGRES_TEMPLATE_FILES = %w[
18
+ controlplane.yml
19
+ templates/app.yml
20
+ templates/postgres.yml
21
+ templates/rails.yml
22
+ release_script.sh
23
+ ].freeze
24
+ SQLITE_TEMPLATE_FILES = %w[
25
+ controlplane.yml
26
+ release_script.sh
27
+ templates/app.yml
28
+ templates/db.yml
29
+ templates/rails.yml
30
+ templates/storage.yml
31
+ ].freeze
32
+
33
+ # Fallback Ruby version when the repo doesn't pin one via `.ruby-version`,
34
+ # `.tool-versions`, or the `Gemfile`. Keep this on a supported release line
35
+ # (https://www.ruby-lang.org/en/downloads/branches/).
36
+ DEFAULT_RUBY_VERSION = "3.3"
6
37
 
7
38
  def copy_files
8
- directory("generator_templates", ".controlplane", verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true")
39
+ generated_paths = copy_template_files("generator_templates", base_template_files)
40
+ generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project?
41
+ substitute_template_variables(generated_paths)
42
+ make_shell_scripts_executable(generated_paths)
9
43
  end
10
44
 
11
45
  def self.source_root
12
46
  Cpflow.root_path.join("lib")
13
47
  end
48
+
49
+ private
50
+
51
+ def copy_template_files(root_dir, relative_paths)
52
+ relative_paths.map { |relative_path| copy_template_file(root_dir, relative_path) }
53
+ end
54
+
55
+ def copy_template_file(root_dir, relative_path)
56
+ destination_path = File.join(".controlplane", relative_path)
57
+ empty_directory(File.dirname(destination_path), verbose: false)
58
+ copy_file(
59
+ File.join(root_dir, relative_path),
60
+ destination_path,
61
+ force: true,
62
+ verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
63
+ )
64
+ destination_path
65
+ end
66
+
67
+ def base_template_files
68
+ COMMON_TEMPLATE_FILES + (sqlite_project? ? [] : POSTGRES_TEMPLATE_FILES)
69
+ end
70
+
71
+ def template_variables
72
+ {
73
+ "__APP_PREFIX__" => inferred_app_prefix,
74
+ "__RUBY_VERSION__" => inferred_ruby_version,
75
+ "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run
76
+ }
77
+ end
78
+
79
+ def inferred_app_prefix
80
+ RepoIntrospection.inferred_app_prefix(Dir.pwd)
81
+ end
82
+
83
+ def inferred_ruby_version
84
+ RepoIntrospection.inferred_ruby_version_string(Dir.pwd) || DEFAULT_RUBY_VERSION
85
+ end
86
+
87
+ def sqlite_project?
88
+ return @sqlite_project if instance_variable_defined?(:@sqlite_project)
89
+
90
+ @sqlite_project = sqlite_database_in_production?
91
+ end
92
+
93
+ def asset_precompile_hook_run
94
+ command = normalized_asset_precompile_hook_command
95
+ return "" unless command
96
+ return "" unless single_line_asset_precompile_hook?(command)
97
+
98
+ "RUN #{command}\n\n"
99
+ end
100
+
101
+ def single_line_asset_precompile_hook?(command)
102
+ return true unless command.match?(/[\r\n]/)
103
+
104
+ Shell.warn("Skipping asset precompile hook: value must be a single line: #{command.inspect}")
105
+ false
106
+ end
107
+
108
+ def sqlite_database_in_production?
109
+ RepoIntrospection.sqlite_database_in_production?(Dir.pwd)
110
+ end
111
+
112
+ def normalized_asset_precompile_hook_command
113
+ command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook
114
+ return unless command
115
+
116
+ command.start_with?("rake ") ? "bundle exec #{command}" : command
117
+ end
118
+
119
+ def shakapacker_precompile_hook
120
+ return unless File.file?("config/shakapacker.yml")
121
+
122
+ # Parse rather than regex-match: Shakapacker emits an environment-keyed YAML file
123
+ # (the hook usually lives under `default:` or `production:`), and folded or quoted
124
+ # multi-line values would also defeat a single-line regex.
125
+ config = YAML.safe_load(File.read("config/shakapacker.yml"), aliases: true)
126
+ hook = extract_shakapacker_precompile_hook(config)
127
+ hook unless hook.nil? || hook.empty?
128
+ rescue Psych::SyntaxError
129
+ nil
130
+ end
131
+
132
+ SHAKAPACKER_HOOK_SCOPES = %w[production default].freeze
133
+ private_constant :SHAKAPACKER_HOOK_SCOPES
134
+
135
+ def extract_shakapacker_precompile_hook(config)
136
+ return nil unless config.is_a?(Hash)
137
+
138
+ scoped = SHAKAPACKER_HOOK_SCOPES.filter_map do |key|
139
+ section = config[key]
140
+ section["precompile_hook"] if section.is_a?(Hash) && section["precompile_hook"].is_a?(String)
141
+ end.first
142
+ scoped || (config["precompile_hook"] if config["precompile_hook"].is_a?(String))
143
+ end
144
+
145
+ def react_on_rails_auto_bundle_hook
146
+ return unless react_on_rails_auto_load_bundle?
147
+
148
+ "bundle exec rake react_on_rails:generate_packs"
149
+ end
150
+
151
+ def react_on_rails_auto_load_bundle?
152
+ return false unless File.file?("config/initializers/react_on_rails.rb")
153
+
154
+ File.readlines("config/initializers/react_on_rails.rb")
155
+ .reject { |line| line.lstrip.start_with?("#") }
156
+ .any? { |line| line.match?(/config\.auto_load_bundle\s*=\s*true\b/) }
157
+ end
14
158
  end
15
159
 
16
160
  class Generate < Base
17
161
  NAME = "generate"
18
162
  DESCRIPTION = "Creates base Control Plane config and template files"
19
163
  LONG_DESCRIPTION = <<~DESC
20
- Creates base Control Plane config and template files
164
+ Creates base Control Plane config and template files for a Rails project:
165
+ - infers the app prefix from the current directory and wires staging, review, and production entries
166
+ - infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`
167
+ - preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation
168
+ - detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload
21
169
  DESC
22
170
  EXAMPLES = <<~EX
23
171
  ```sh
24
- # Creates .controlplane directory with Control Plane config and other templates
172
+ # Creates .controlplane directory with Control Plane config and starter templates
25
173
  cpflow generate
26
174
  ```
27
175
  EX
28
176
  WITH_INFO_HEADER = false
29
177
  VALIDATIONS = [].freeze
178
+ REQUIRES_STARTUP_CHECKS = false
30
179
 
31
180
  def call
32
181
  if controlplane_directory_exists?
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ require_relative "generator_helpers"
7
+
8
+ module Command
9
+ class GithubActionsGenerator < Thor::Group
10
+ include Thor::Actions
11
+ include GeneratorHelpers
12
+
13
+ argument :staging_branch, type: :string, required: false
14
+
15
+ def copy_files
16
+ relative_paths = generated_files
17
+ copy_template_files(relative_paths)
18
+ substitute_template_variables(relative_paths)
19
+ make_shell_scripts_executable(relative_paths)
20
+ end
21
+
22
+ def self.source_root
23
+ Cpflow.root_path.join("lib")
24
+ end
25
+
26
+ private
27
+
28
+ def copy_template_files(relative_paths)
29
+ relative_paths.each do |relative_path|
30
+ empty_directory(File.dirname(relative_path), verbose: false)
31
+ copy_file(
32
+ File.join("github_flow_templates", relative_path),
33
+ relative_path,
34
+ force: true,
35
+ verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
36
+ )
37
+ end
38
+ end
39
+
40
+ def template_variables
41
+ {
42
+ "__CPFLOW_VERSION__" => ::Cpflow::VERSION,
43
+ "__STAGING_BRANCH_FILTER__" => staging_branch_filter,
44
+ "__STAGING_APP_BRANCH_EXPRESSION__" => staging_app_branch_expression
45
+ }
46
+ end
47
+
48
+ def generated_files
49
+ # Keep file discovery centralized on the command class so existence checks and
50
+ # Thor's template copy list cannot drift.
51
+ GenerateGithubActions.generated_files
52
+ end
53
+
54
+ def staging_branch_filter
55
+ branches = staging_branch ? [staging_branch] : %w[main master]
56
+ # JSON string literals are valid YAML flow-sequence scalars, so this keeps
57
+ # the generated branch list readable while still escaping branch names.
58
+ branches.map(&:to_json).join(", ")
59
+ end
60
+
61
+ def staging_app_branch_expression
62
+ return "${{ vars.STAGING_APP_BRANCH }}" unless staging_branch
63
+
64
+ # `valid_staging_branch?` excludes quotes, so this single-quoted GitHub
65
+ # expression literal cannot be broken by the generated branch name.
66
+ "${{ vars.STAGING_APP_BRANCH || '#{staging_branch}' }}"
67
+ end
68
+ end
69
+
70
+ class GenerateGithubActions < Base
71
+ NAME = "generate-github-actions"
72
+ OPTIONS = [staging_branch_option].freeze
73
+ DESCRIPTION = "Creates GitHub Actions templates for review apps, staging deploys, and production promotion"
74
+ LONG_DESCRIPTION = <<~DESC
75
+ Creates GitHub Actions templates for a Heroku Flow style Control Plane pipeline:
76
+ - on-demand review apps for pull requests
77
+ - automatic staging deploys from your main branch
78
+ - manual promotion from staging to production
79
+ - nightly cleanup and PR help workflows
80
+
81
+ Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
82
+ other than `main` or `master`; the generator will bake that branch into the
83
+ GitHub Actions push trigger and use it as the default STAGING_APP_BRANCH.
84
+ DESC
85
+ EXAMPLES = <<~EX
86
+ ```sh
87
+ # Creates .github/actions and .github/workflows files for the Control Plane flow
88
+ cpflow generate-github-actions
89
+
90
+ # Creates the flow with staging deploys triggered from develop
91
+ cpflow generate-github-actions --staging-branch develop
92
+ ```
93
+ EX
94
+ WITH_INFO_HEADER = false
95
+ VALIDATIONS = [].freeze
96
+ REQUIRES_STARTUP_CHECKS = false
97
+
98
+ # Resolve template root from __dir__ rather than Cpflow.root_path because this file is
99
+ # loaded before `module Cpflow` finishes defining its class methods.
100
+ TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
101
+
102
+ def self.generated_files
103
+ ensure_template_root!
104
+
105
+ Dir.glob(TEMPLATE_ROOT.join("**", "*").to_s, File::FNM_DOTMATCH)
106
+ .select { |path| File.file?(path) }
107
+ .map { |path| Pathname.new(path).relative_path_from(TEMPLATE_ROOT).to_s }
108
+ .sort
109
+ .freeze
110
+ end
111
+
112
+ def self.ensure_template_root!
113
+ raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
114
+ end
115
+
116
+ def call
117
+ self.class.ensure_template_root!
118
+ branch = staging_branch
119
+
120
+ if (existing = existing_files).any?
121
+ files = existing.map { |path| "- #{path}" }.join("\n")
122
+ Shell.warn("The following files already exist:\n#{files}\n\n" \
123
+ "Remove or rename them before running `cpflow #{NAME}` again.")
124
+ return
125
+ end
126
+
127
+ GithubActionsGenerator.start([branch].compact)
128
+ end
129
+
130
+ private
131
+
132
+ def existing_files
133
+ @existing_files ||= self.class.generated_files.select { |path| File.exist?(path) }
134
+ end
135
+
136
+ def staging_branch
137
+ branch = config.options[:staging_branch].to_s.strip
138
+ return nil if branch.empty?
139
+
140
+ unless valid_staging_branch?(branch)
141
+ Shell.abort(
142
+ "Invalid --staging-branch value: #{branch.inspect}. " \
143
+ "Use a valid git branch name containing only alphanumerics, dots, slashes, underscores, hyphens, and @."
144
+ )
145
+ end
146
+
147
+ branch
148
+ end
149
+
150
+ def valid_staging_branch?(branch)
151
+ return false unless branch.match?(%r{\A[a-zA-Z0-9._/@-]+\z})
152
+
153
+ valid_git_branch_shape?(branch) && valid_git_branch_components?(branch)
154
+ end
155
+
156
+ def valid_git_branch_shape?(branch)
157
+ return false if branch.start_with?("-", "/", ".")
158
+ return false if branch.end_with?("/", ".")
159
+ return false if branch.include?("@{")
160
+
161
+ !branch.include?("..")
162
+ end
163
+
164
+ def valid_git_branch_components?(branch)
165
+ branch.split("/").none? do |component|
166
+ component.empty? || component.start_with?(".") || component.end_with?(".lock")
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ module GeneratorHelpers
5
+ private
6
+
7
+ def substitute_template_variables(file_paths, replacements = template_variables)
8
+ Array(file_paths).each do |path|
9
+ next unless File.file?(path)
10
+
11
+ contents = File.read(path)
12
+ updated_contents = replacements.reduce(contents) do |memo, (placeholder, value)|
13
+ # Block form avoids regex-style back-reference interpretation (\1, \&, \\) in `value`.
14
+ memo.gsub(placeholder) { value }
15
+ end
16
+
17
+ next if updated_contents == contents
18
+
19
+ File.write(path, updated_contents)
20
+ end
21
+ end
22
+
23
+ def make_shell_scripts_executable(file_paths)
24
+ Array(file_paths).each do |path|
25
+ next unless File.file?(path) && File.extname(path) == ".sh"
26
+
27
+ FileUtils.chmod(0o755, path)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ class GithubFlowReadiness < Base
5
+ NAME = "github-flow-readiness"
6
+ DESCRIPTION = "Checks whether the current repo is ready for the Control Plane GitHub flow rollout"
7
+ LONG_DESCRIPTION = <<~DESC
8
+ Checks the current repository for common rollout blockers before adding the Control Plane GitHub flow:
9
+ - Rails runtime scaffold present
10
+ - modern Ruby and Bundler toolchain
11
+ - installable exact-pinned direct gem and npm package versions
12
+ - production Dockerfile presence and SQLite production hints
13
+ DESC
14
+ EXAMPLES = <<~EX
15
+ ```sh
16
+ # Checks the current repo for common rollout blockers
17
+ cpflow github-flow-readiness
18
+ ```
19
+ EX
20
+ WITH_INFO_HEADER = false
21
+ VALIDATIONS = [].freeze
22
+ REQUIRES_STARTUP_CHECKS = false
23
+
24
+ def call
25
+ service = GithubFlowReadinessService.new
26
+
27
+ service.results.each do |result|
28
+ Shell.info("[#{result.status.to_s.upcase}] #{result.message}")
29
+ end
30
+
31
+ Shell.info("")
32
+ Shell.info(service.summary)
33
+
34
+ exit(ExitCode::ERROR_DEFAULT) if service.blockers?
35
+ end
36
+ end
37
+ end
@@ -11,6 +11,7 @@ module Command
11
11
  DESCRIPTION = "Waits for workloads in app to be ready after re-deployment"
12
12
  LONG_DESCRIPTION = <<~DESC
13
13
  - Waits for workloads in app to be ready after re-deployment
14
+ - Use Unix timeout command to set a maximum wait time (e.g., `timeout 300 cpflow ps:wait ...`)
14
15
  DESC
15
16
  EXAMPLES = <<~EX
16
17
  ```sh
@@ -18,7 +19,10 @@ module Command
18
19
  cpflow ps:wait -a $APP_NAME
19
20
 
20
21
  # Waits for a specific workload in app.
21
- cpflow ps:swait -a $APP_NAME -w $WORKLOAD_NAME
22
+ cpflow ps:wait -a $APP_NAME -w $WORKLOAD_NAME
23
+
24
+ # Waits for all workloads with a 5-minute timeout.
25
+ timeout 300 cpflow ps:wait -a $APP_NAME
22
26
  ```
23
27
  EX
24
28
 
data/lib/command/run.rb CHANGED
@@ -48,7 +48,7 @@ module Command
48
48
  - By default, the job is stopped if it takes longer than 6 hours to finish
49
49
  (can be configured though `runner_job_timeout` in `controlplane.yml`)
50
50
  DESC
51
- EXAMPLES = <<~EX
51
+ EXAMPLES = <<~EX.freeze
52
52
  ```sh
53
53
  # Opens shell (bash by default).
54
54
  cpflow run -a $APP_NAME
@@ -97,7 +97,7 @@ module Command
97
97
 
98
98
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
99
99
  :default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
100
- :container, :expected_deployed_version, :job, :replica, :command
100
+ :container, :job, :replica, :command
101
101
 
102
102
  def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
103
  @interactive = config.options[:interactive] || interactive_command?
@@ -126,10 +126,7 @@ module Command
126
126
  end
127
127
 
128
128
  create_runner_workload if cp.fetch_workload(runner_workload).nil?
129
- wait_for_runner_workload_deploy
130
129
  update_runner_workload
131
- wait_for_runner_workload_update if expected_deployed_version
132
-
133
130
  start_job
134
131
  wait_for_replica_for_job
135
132
 
@@ -191,7 +188,7 @@ module Command
191
188
  }
192
189
 
193
190
  # Create runner workload
194
- cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
191
+ cp.apply_hash({ "kind" => "workload", "name" => runner_workload, "spec" => spec }, wait: true)
195
192
  end
196
193
  end
197
194
 
@@ -242,21 +239,7 @@ module Command
242
239
  return unless should_update
243
240
 
244
241
  step("Updating runner workload '#{runner_workload}'") do
245
- # Update runner workload
246
- @expected_deployed_version = (cp.cron_workload_deployed_version(runner_workload) || 0) + 1
247
- cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
248
- end
249
- end
250
-
251
- def wait_for_runner_workload_deploy
252
- step("Waiting for runner workload '#{runner_workload}' to be deployed", retry_on_failure: true) do
253
- !cp.cron_workload_deployed_version(runner_workload).nil?
254
- end
255
- end
256
-
257
- def wait_for_runner_workload_update
258
- step("Waiting for runner workload '#{runner_workload}' to be updated", retry_on_failure: true) do
259
- (cp.cron_workload_deployed_version(runner_workload) || 0) >= expected_deployed_version
242
+ cp.apply_hash({ "kind" => "workload", "name" => runner_workload, "spec" => spec }, wait: true)
260
243
  end
261
244
  end
262
245
 
@@ -14,6 +14,7 @@ module Command
14
14
  - Generates terraform configuration files based on `controlplane.yml` and `templates/` config
15
15
  DESC
16
16
  WITH_INFO_HEADER = false
17
+ REQUIRES_STARTUP_CHECKS = false
17
18
 
18
19
  def call
19
20
  Array(config.app || config.apps.keys).each do |app|
@@ -10,6 +10,7 @@ module Command
10
10
  DESC
11
11
  WITH_INFO_HEADER = false
12
12
  VALIDATIONS = [].freeze
13
+ REQUIRES_STARTUP_CHECKS = false
13
14
 
14
15
  def call
15
16
  puts Cpflow::VERSION
@@ -2,6 +2,7 @@
2
2
 
3
3
  module ExitCode
4
4
  SUCCESS = 0
5
+ NOT_FOUND = 3
5
6
  ERROR_DEFAULT = 64
6
7
  INTERRUPT = 130
7
8
  end
data/lib/core/config.rb CHANGED
@@ -25,7 +25,7 @@ class Config # rubocop:disable Metrics/ClassLength
25
25
  return unless trace_mode
26
26
 
27
27
  ControlplaneApiDirect.trace = trace_mode
28
- Shell.warn("Trace mode is enabled, this will print sensitive information to the console.")
28
+ Shell.warn("Trace mode is enabled. Sensitive data is redacted, but please review output before sharing.")
29
29
  end
30
30
 
31
31
  def org