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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../core/repo_introspection"
4
+
5
+ module Command
6
+ class AiGithubFlowPrompt < Base
7
+ NAME = "ai-github-flow-prompt"
8
+ DESCRIPTION = "Prints the recommended AI prompt for adding the Control Plane GitHub Flow to a repo"
9
+ LONG_DESCRIPTION = <<~DESC
10
+ Prints a copy-paste prompt for an AI agent to roll out the reusable Control Plane GitHub Flow:
11
+ - verifies the repo is deployable from a clean clone before generating files
12
+ - scaffolds `.controlplane/` and `cpflow-*` GitHub Actions files when the repo qualifies
13
+ - stops on external blockers or product decisions instead of forcing a broken rollout
14
+ DESC
15
+ EXAMPLES = <<~EX
16
+ ```sh
17
+ # Prints the recommended AI rollout prompt for the current repo
18
+ cpflow ai-github-flow-prompt
19
+ ```
20
+ EX
21
+ WITH_INFO_HEADER = false
22
+ VALIDATIONS = [].freeze
23
+ REQUIRES_STARTUP_CHECKS = false
24
+
25
+ def call
26
+ puts prompt
27
+ end
28
+
29
+ private
30
+
31
+ def prompt
32
+ <<~PROMPT
33
+ Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout.
34
+
35
+ If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `/deploy-review-app`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured.
36
+
37
+ Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold uses persistent `db` and `storage` volumes plus a release script that runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`.
38
+
39
+ Run the real local validations you can: Docker build if feasible, repo tests or smoke checks, YAML validation, and any CI-equivalent build steps. Push the branch and check the GitHub Actions results. Only stop early for a real external blocker or a product decision that changes scope.
40
+ PROMPT
41
+ end
42
+
43
+ def inferred_app_prefix
44
+ RepoIntrospection.inferred_app_prefix(Dir.pwd)
45
+ end
46
+ end
47
+ end
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
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
@@ -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