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.
- checksums.yaml +4 -4
- data/.claude/commands/update-changelog.md +367 -0
- data/.github/workflows/claude-code-review.yml +44 -0
- data/.github/workflows/claude.yml +55 -0
- data/.gitignore +2 -0
- data/.overcommit.yml +43 -3
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +39 -3
- data/CONTRIBUTING.md +6 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +93 -73
- data/README.md +53 -22
- data/cpflow.gemspec +5 -5
- data/docs/ai-github-flow-prompt.md +61 -0
- data/docs/ci-automation.md +335 -0
- data/docs/commands.md +70 -5
- 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/ps_wait.rb +5 -1
- data/lib/command/run.rb +4 -21
- 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/config.rb +1 -1
- data/lib/core/controlplane.rb +13 -10
- data/lib/core/controlplane_api_direct.rb +25 -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 +66 -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 +56 -15
- /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(
|
|
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/ps_wait.rb
CHANGED
|
@@ -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:
|
|
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, :
|
|
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
|
-
|
|
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
|
|
data/lib/command/version.rb
CHANGED
data/lib/constants/exit_code.rb
CHANGED
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
|
|
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
|