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.
- checksums.yaml +4 -4
- data/.claude/commands/update-changelog.md +367 -0
- data/.github/workflows/claude.yml +5 -0
- data/.overcommit.yml +43 -3
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +28 -4
- data/CONTRIBUTING.md +6 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +92 -72
- data/README.md +43 -15
- data/cpflow.gemspec +5 -5
- data/docs/ai-github-flow-prompt.md +61 -0
- data/docs/ci-automation.md +335 -28
- data/docs/commands.md +65 -4
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +14 -0
- data/lib/command/cleanup_images.rb +1 -1
- data/lib/command/cleanup_stale_apps.rb +1 -1
- data/lib/command/copy_image_from_upstream.rb +14 -3
- data/lib/command/exists.rb +13 -2
- data/lib/command/generate.rb +153 -4
- data/lib/command/generate_github_actions.rb +170 -0
- data/lib/command/generator_helpers.rb +31 -0
- data/lib/command/github_flow_readiness.rb +37 -0
- data/lib/command/run.rb +1 -1
- data/lib/command/terraform/generate.rb +1 -0
- data/lib/command/version.rb +1 -0
- data/lib/constants/exit_code.rb +1 -0
- data/lib/core/controlplane.rb +9 -7
- data/lib/core/controlplane_api_direct.rb +3 -3
- data/lib/core/github_flow_readiness/checks.rb +143 -0
- data/lib/core/github_flow_readiness_service.rb +453 -0
- data/lib/core/repo_introspection.rb +118 -0
- data/lib/core/terraform_config/dsl.rb +1 -1
- data/lib/core/terraform_config/local_variable.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +65 -3
- data/lib/generator_templates/Dockerfile +59 -3
- data/lib/generator_templates/controlplane.yml +27 -39
- data/lib/generator_templates/entrypoint.sh +1 -1
- data/lib/generator_templates/release_script.sh +23 -0
- data/lib/generator_templates/templates/app.yml +5 -8
- data/lib/generator_templates/templates/rails.yml +2 -11
- data/lib/generator_templates_sqlite/controlplane.yml +46 -0
- data/lib/generator_templates_sqlite/release_script.sh +25 -0
- data/lib/generator_templates_sqlite/templates/app.yml +15 -0
- data/lib/generator_templates_sqlite/templates/db.yml +6 -0
- data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
- data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
- data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
- data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
- data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
- data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
- data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
- data/rakelib/create_release.rake +662 -37
- data/script/check_command_docs +4 -2
- data/script/check_cpln_links +25 -11
- data/script/precommit/check_command_docs +22 -0
- data/script/precommit/check_cpln_links +21 -0
- data/script/precommit/check_trailing_newlines +68 -0
- data/script/precommit/get_changed_files +49 -0
- data/script/precommit/ruby_autofix +52 -0
- data/script/precommit/ruby_lint +33 -0
- metadata +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(
|
|
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(
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
72
|
+
cp.profile_create(@upstream_profile, @upstream_token)
|
|
62
73
|
end
|
|
63
74
|
end
|
|
64
75
|
|
data/lib/command/exists.rb
CHANGED
|
@@ -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
|
-
|
|
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::
|
|
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
|
data/lib/command/generate.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/command/version.rb
CHANGED