cpflow 4.2.0 → 5.0.0.rc.1

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/workflows/address-review.md +216 -0
  3. data/.claude/commands/address-review.md +547 -0
  4. data/.claude/commands/update-changelog.md +367 -0
  5. data/.github/workflows/claude.yml +5 -0
  6. data/.overcommit.yml +43 -3
  7. data/.rubocop.yml +3 -3
  8. data/CHANGELOG.md +28 -4
  9. data/CONTRIBUTING.md +28 -0
  10. data/Gemfile +8 -7
  11. data/Gemfile.lock +92 -72
  12. data/README.md +55 -20
  13. data/cpflow.gemspec +5 -5
  14. data/docs/ai-github-flow-prompt.md +61 -0
  15. data/docs/ci-automation.md +335 -28
  16. data/docs/commands.md +67 -4
  17. data/docs/migrating-heroku-to-control-plane.md +12 -0
  18. data/docs/postgres.md +5 -0
  19. data/docs/redis.md +6 -0
  20. data/docs/releasing.md +153 -0
  21. data/lib/command/ai_github_flow_prompt.rb +47 -0
  22. data/lib/command/base.rb +25 -0
  23. data/lib/command/cleanup_images.rb +1 -1
  24. data/lib/command/cleanup_stale_apps.rb +1 -1
  25. data/lib/command/copy_image_from_upstream.rb +14 -3
  26. data/lib/command/deploy_image.rb +40 -9
  27. data/lib/command/exists.rb +13 -2
  28. data/lib/command/generate.rb +153 -4
  29. data/lib/command/generate_github_actions.rb +170 -0
  30. data/lib/command/generator_helpers.rb +31 -0
  31. data/lib/command/github_flow_readiness.rb +37 -0
  32. data/lib/command/promote_app_from_upstream.rb +13 -2
  33. data/lib/command/run.rb +1 -1
  34. data/lib/command/terraform/generate.rb +1 -0
  35. data/lib/command/version.rb +1 -0
  36. data/lib/constants/exit_code.rb +1 -0
  37. data/lib/core/config.rb +8 -0
  38. data/lib/core/controlplane.rb +9 -7
  39. data/lib/core/controlplane_api_direct.rb +3 -3
  40. data/lib/core/github_flow_readiness/checks.rb +143 -0
  41. data/lib/core/github_flow_readiness_service.rb +453 -0
  42. data/lib/core/repo_introspection.rb +118 -0
  43. data/lib/core/terraform_config/dsl.rb +1 -1
  44. data/lib/core/terraform_config/local_variable.rb +1 -1
  45. data/lib/cpflow/version.rb +1 -1
  46. data/lib/cpflow.rb +65 -3
  47. data/lib/generator_templates/Dockerfile +59 -3
  48. data/lib/generator_templates/controlplane.yml +27 -39
  49. data/lib/generator_templates/entrypoint.sh +1 -1
  50. data/lib/generator_templates/release_script.sh +23 -0
  51. data/lib/generator_templates/templates/app.yml +5 -8
  52. data/lib/generator_templates/templates/rails.yml +2 -11
  53. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  54. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  55. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  56. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  57. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  58. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  59. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  60. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  61. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  62. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  63. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  64. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  65. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  66. data/lib/github_flow_templates/.github/cpflow-help.md +73 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  68. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  69. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  70. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  71. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +58 -0
  72. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  73. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +45 -0
  74. data/rakelib/create_release.rake +662 -37
  75. data/script/check_command_docs +4 -2
  76. data/script/check_cpln_links +25 -11
  77. data/script/precommit/check_command_docs +22 -0
  78. data/script/precommit/check_cpln_links +21 -0
  79. data/script/precommit/check_trailing_newlines +68 -0
  80. data/script/precommit/get_changed_files +49 -0
  81. data/script/precommit/ruby_autofix +52 -0
  82. data/script/precommit/ruby_lint +33 -0
  83. metadata +54 -14
data/docs/releasing.md ADDED
@@ -0,0 +1,153 @@
1
+ # Releasing the Gem
2
+
3
+ This project follows a changelog-first Ruby gem release process, modeled after
4
+ the React on Rails release flow but without any npm publishing steps.
5
+
6
+ ## Release Process
7
+
8
+ ### 1. Update the Changelog
9
+
10
+ Always update `CHANGELOG.md` before running the release task.
11
+
12
+ 1. Ensure all desired changes are merged to `main`.
13
+ 2. Run `/update-changelog release` to find merged PRs, add entries under the
14
+ right headings, compute the next version, stamp the version header, update
15
+ compare links, and open a changelog PR. Use `/update-changelog rc`,
16
+ `/update-changelog beta`, or an explicit version like
17
+ `/update-changelog 4.2.0.rc.1` when preparing a prerelease or fixed target
18
+ version.
19
+ 3. Review the generated changelog PR and verify the version number matches the
20
+ intended release level:
21
+ - Breaking changes: major
22
+ - Added features or enhancements: minor
23
+ - Fixes, improvements, deprecations, removals, or security updates: patch
24
+ 4. Merge the changelog PR before releasing.
25
+
26
+ If updating the changelog manually, move the relevant `Unreleased` entries into
27
+ a versioned header:
28
+
29
+ ```markdown
30
+ ## [4.2.0] - 2026-05-05
31
+ ```
32
+
33
+ Then update the compare links at the bottom of `CHANGELOG.md`, including the
34
+ `Unreleased` link and the new version link, and merge those changelog changes
35
+ before releasing.
36
+
37
+ The release task reads the latest versioned `CHANGELOG.md` header and can create
38
+ the GitHub release from that section automatically.
39
+
40
+ ### 2. Run the Release Task
41
+
42
+ The recommended command has no arguments:
43
+
44
+ ```bash
45
+ bundle exec rake release
46
+ ```
47
+
48
+ With no arguments, `rake release`:
49
+
50
+ 1. Reads the first versioned `CHANGELOG.md` header, such as `## [4.2.0]`.
51
+ 2. Uses that version when it is newer than the current gem version.
52
+ 3. Uses the current version if the changelog version matches the gem version
53
+ but has not been tagged yet.
54
+ 4. Falls back to a patch bump if no new changelog version is found.
55
+
56
+ Other supported forms:
57
+
58
+ ```bash
59
+ bundle exec rake "release[patch]"
60
+ bundle exec rake "release[minor]"
61
+ bundle exec rake "release[major]"
62
+ bundle exec rake "release[4.2.0]"
63
+ bundle exec rake "release[4.2.0.rc.1]"
64
+ bundle exec rake "release[4.2.0,true]"
65
+ ```
66
+
67
+ Use RubyGems version format for prereleases: `4.2.0.rc.1`, not
68
+ `4.2.0-rc.1`.
69
+
70
+ Full argument list:
71
+
72
+ ```bash
73
+ bundle exec rake "release[version,dry_run,override_version_policy]"
74
+ ```
75
+
76
+ Environment variables:
77
+
78
+ ```bash
79
+ VERBOSE=1
80
+ RUBYGEMS_OTP=<code>
81
+ RELEASE_VERSION_POLICY_OVERRIDE=true
82
+ GEM_RELEASE_MAX_RETRIES=<n>
83
+ ```
84
+
85
+ ### 3. What the Task Does
86
+
87
+ `bundle exec rake release` performs the gem-only release:
88
+
89
+ 1. Requires a clean working tree.
90
+ 2. Verifies `gem-release` is available through Bundler.
91
+ 3. For real releases, verifies GitHub CLI authentication and write access.
92
+ 4. Pulls the latest changes with `git pull --rebase`.
93
+ 5. Resolves the target version from the changelog or explicit argument.
94
+ 6. Requires stable releases to run from `main`; prereleases may run from another
95
+ branch.
96
+ 7. Validates the target version is newer than the latest tag and is consistent
97
+ with the changelog section when the section indicates a bump level.
98
+ 8. Bumps `lib/cpflow/version.rb` and updates `Gemfile.lock`.
99
+ 9. Commits the version bump, tags `vX.Y.Z`, and pushes the commit and tags.
100
+ 10. Publishes the `cpflow` gem to RubyGems.org.
101
+ 11. Creates or updates the GitHub release from the matching changelog section.
102
+
103
+ The older `bundle exec rake "create_release[4.2.0,false]"` task name remains as
104
+ a compatibility alias, but new releases should use `bundle exec rake release`.
105
+
106
+ ### 4. Sync GitHub Release Notes Manually
107
+
108
+ If the GitHub release was not created automatically, update and commit the
109
+ matching changelog section, then run:
110
+
111
+ ```bash
112
+ bundle exec rake "sync_github_release[4.2.0]"
113
+ bundle exec rake "sync_github_release[4.2.0,true]"
114
+ ```
115
+
116
+ `sync_github_release` reads notes from `CHANGELOG.md` and creates or updates the
117
+ GitHub release for the corresponding `vX.Y.Z` tag.
118
+
119
+ ## Pre-Release Checklist
120
+
121
+ Before running the release:
122
+
123
+ 1. `git checkout main`
124
+ 2. `git pull --rebase`
125
+ 3. `bundle install`
126
+ 4. `gh auth status`
127
+ 5. Confirm RubyGems credentials can publish `cpflow`.
128
+ 6. Confirm `CHANGELOG.md` has a committed section for the target version.
129
+ 7. Run a dry run:
130
+
131
+ ```bash
132
+ bundle exec rake "release[4.2.0,true]"
133
+ ```
134
+
135
+ ## If a Release Fails
136
+
137
+ Check what was published before retrying:
138
+
139
+ ```bash
140
+ gem list cpflow -r -a
141
+ gh release view v4.2.0
142
+ git tag -l v4.2.0
143
+ ```
144
+
145
+ If the gem was published but GitHub release creation failed, fix GitHub CLI
146
+ authentication or permissions and run:
147
+
148
+ ```bash
149
+ bundle exec rake "sync_github_release[4.2.0]"
150
+ ```
151
+
152
+ If the tag was pushed but the gem was not published, delete or correct the tag
153
+ and version commit intentionally before trying again.
@@ -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 `+review-app-deploy`, 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,
@@ -477,6 +491,17 @@ module Command
477
491
  }
478
492
  }
479
493
  end
494
+
495
+ def self.use_digest_image_ref_option(required: false)
496
+ {
497
+ name: :use_digest_image_ref,
498
+ params: {
499
+ desc: "Uses the image's digest (SHA256 value) for referencing the Docker image",
500
+ type: :boolean,
501
+ required: required
502
+ }
503
+ }
504
+ end
480
505
  # rubocop:enable Metrics/MethodLength
481
506
 
482
507
  def self.all_options
@@ -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
 
@@ -7,7 +7,8 @@ module Command
7
7
  NAME = "deploy-image"
8
8
  OPTIONS = [
9
9
  app_option(required: true),
10
- run_release_phase_option
10
+ run_release_phase_option,
11
+ use_digest_image_ref_option
11
12
  ].freeze
12
13
  DESCRIPTION = "Deploys the latest image to app workloads, and runs a release script (optional)"
13
14
  LONG_DESCRIPTION = <<~DESC
@@ -15,24 +16,19 @@ module Command
15
16
  - Runs a release script before deploying if `release_script` is specified in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided
16
17
  - The release script is run in the context of `cpflow run` with the latest image
17
18
  - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
19
+ - If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
18
20
  DESC
19
21
 
20
22
  def call # rubocop:disable Metrics/MethodLength
21
23
  run_release_script if config.options[:run_release_phase]
22
24
 
23
25
  deployed_endpoints = {}
24
-
25
- image = cp.latest_image
26
- if cp.fetch_image_details(image).nil?
27
- raise "Image '#{image}' does not exist in the Docker repository on Control Plane " \
28
- "(see https://console.cpln.io/console/org/#{config.org}/repository/#{config.app}). " \
29
- "Use `cpflow build-image` first."
30
- end
26
+ image = resolve_image_to_deploy
31
27
 
32
28
  config[:app_workloads].each do |workload|
33
29
  workload_data = cp.fetch_workload!(workload)
34
30
  workload_data.dig("spec", "containers").each do |container|
35
- next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}:})
31
+ next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
36
32
 
37
33
  container_name = container["name"]
38
34
  step("Deploying image '#{image}' for workload '#{container_name}'") do
@@ -50,6 +46,41 @@ module Command
50
46
 
51
47
  private
52
48
 
49
+ def resolve_image_to_deploy
50
+ image = cp.latest_image
51
+ # Preserve the pre-existing fail-fast check so missing images are reported
52
+ # before workloads are touched.
53
+ image_details = fetch_image_details!(image)
54
+
55
+ return image unless config.use_digest_image_ref?
56
+
57
+ # Control Plane accepts the tagged digest form returned here; latest_image currently returns app:N.
58
+ "#{image}@#{image_digest!(image, image_details)}"
59
+ end
60
+
61
+ def fetch_image_details!(image)
62
+ image_details = cp.fetch_image_details(image)
63
+ raise image_not_found_message(image) if image_details.nil?
64
+
65
+ image_details
66
+ end
67
+
68
+ def image_digest!(image, image_details)
69
+ digest = image_details["digest"]
70
+ raise "Image '#{image}' does not have a digest available." if digest.nil? || digest.empty?
71
+ # SHA-256 only; expand the regex if Control Plane ever returns sha512 or other digest algorithms.
72
+ # OCI digests are always lowercase hex per the OCI image spec.
73
+ raise "Unexpected digest format for image '#{image}'." unless digest.match?(/\Asha256:[a-f0-9]{64}\z/)
74
+
75
+ digest
76
+ end
77
+
78
+ def image_not_found_message(image)
79
+ "Image '#{image}' does not exist in the Docker repository on Control Plane " \
80
+ "(see https://console.cpln.io/console/org/#{config.org}/repository/#{config.app}). " \
81
+ "Use `cpflow build-image` first."
82
+ end
83
+
53
84
  def endpoint_for_workload(workload_data)
54
85
  endpoint = workload_data.dig("status", "endpoint")
55
86
  Resolv.getaddress(endpoint.split("/").last)
@@ -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?