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.
- checksums.yaml +4 -4
- data/.agents/workflows/address-review.md +216 -0
- data/.claude/commands/address-review.md +547 -0
- 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 +28 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +92 -72
- data/README.md +55 -20
- 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 +67 -4
- data/docs/migrating-heroku-to-control-plane.md +12 -0
- data/docs/postgres.md +5 -0
- data/docs/redis.md +6 -0
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +25 -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/deploy_image.rb +40 -9
- 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/promote_app_from_upstream.rb +13 -2
- 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/config.rb +8 -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 +73 -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 +58 -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 +45 -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 +54 -14
|
@@ -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
|
|
@@ -5,7 +5,8 @@ module Command
|
|
|
5
5
|
NAME = "promote-app-from-upstream"
|
|
6
6
|
OPTIONS = [
|
|
7
7
|
app_option(required: true),
|
|
8
|
-
upstream_token_option(required: true)
|
|
8
|
+
upstream_token_option(required: true),
|
|
9
|
+
use_digest_image_ref_option
|
|
9
10
|
].freeze
|
|
10
11
|
DESCRIPTION = "Copies the latest image from upstream, runs a release script (optional), and deploys the image"
|
|
11
12
|
LONG_DESCRIPTION = <<~DESC
|
|
@@ -15,6 +16,7 @@ module Command
|
|
|
15
16
|
- Runs `cpflow deploy-image` to deploy the image
|
|
16
17
|
- If `.controlplane/controlplane.yml` includes the `release_script`, `cpflow deploy-image` will use the `--run-release-phase` option
|
|
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
|
|
@@ -31,8 +33,17 @@ module Command
|
|
|
31
33
|
|
|
32
34
|
def deploy_image
|
|
33
35
|
args = []
|
|
34
|
-
args.push("--run-release-phase") if config.current
|
|
36
|
+
args.push("--run-release-phase") if config.current&.dig(:release_script)
|
|
37
|
+
digest_image_ref_option = deploy_image_digest_ref_option
|
|
38
|
+
args.push(digest_image_ref_option) if digest_image_ref_option
|
|
35
39
|
run_cpflow_command("deploy-image", "-a", config.app, *args)
|
|
36
40
|
end
|
|
41
|
+
|
|
42
|
+
def deploy_image_digest_ref_option
|
|
43
|
+
# Forward explicit false so a parent CLI override is not lost when the child command re-reads YAML.
|
|
44
|
+
return "--no-use-digest-image-ref" if config.options[:use_digest_image_ref] == false
|
|
45
|
+
|
|
46
|
+
"--use-digest-image-ref" if config.use_digest_image_ref?
|
|
47
|
+
end
|
|
37
48
|
end
|
|
38
49
|
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
data/lib/constants/exit_code.rb
CHANGED
data/lib/core/config.rb
CHANGED
|
@@ -145,6 +145,14 @@ class Config # rubocop:disable Metrics/ClassLength
|
|
|
145
145
|
end&.last
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
def use_digest_image_ref?
|
|
149
|
+
# Three-state: --use-digest-image-ref → true, --no-use-digest-image-ref → false
|
|
150
|
+
# (both short-circuit YAML), absent → nil (fall through to YAML).
|
|
151
|
+
return options[:use_digest_image_ref] unless options[:use_digest_image_ref].nil?
|
|
152
|
+
|
|
153
|
+
current&.dig(:use_digest_image_ref) == true
|
|
154
|
+
end
|
|
155
|
+
|
|
148
156
|
private
|
|
149
157
|
|
|
150
158
|
def ensure_current_config!
|
data/lib/core/controlplane.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
class Controlplane # rubocop:disable Metrics/ClassLength
|
|
4
6
|
attr_reader :config, :api, :gvc, :org
|
|
5
7
|
|
|
@@ -93,14 +95,14 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
93
95
|
def image_build(image, dockerfile:, docker_context:, docker_args: [], build_args: [])
|
|
94
96
|
# https://docs.controlplane.com/guides/push-image#step-2
|
|
95
97
|
# Might need to use `docker buildx build` if compatiblitity issues arise
|
|
96
|
-
cmd = "docker build --platform=linux/amd64 -t
|
|
97
|
-
cmd
|
|
98
|
+
cmd = ["docker", "build", "--platform=linux/amd64", "-t", image, "-f", dockerfile]
|
|
99
|
+
cmd << "--progress=plain" if ControlplaneApiDirect.trace
|
|
98
100
|
|
|
99
|
-
cmd
|
|
100
|
-
build_args.each { |build_arg| cmd
|
|
101
|
-
cmd
|
|
101
|
+
cmd.concat(docker_args)
|
|
102
|
+
build_args.each { |build_arg| cmd.concat(["--build-arg", build_arg]) }
|
|
103
|
+
cmd << docker_context
|
|
102
104
|
|
|
103
|
-
perform!(cmd)
|
|
105
|
+
perform!(Shellwords.join(cmd))
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def fetch_image_details(image)
|
|
@@ -321,7 +323,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
321
323
|
# domain
|
|
322
324
|
|
|
323
325
|
def find_domain_route(data)
|
|
324
|
-
port = data["spec"]["ports"].find { |current_port|
|
|
326
|
+
port = data["spec"]["ports"].find { |current_port| [80, 443].include?(current_port["number"]) }
|
|
325
327
|
return nil if port.nil? || port["routes"].nil?
|
|
326
328
|
|
|
327
329
|
route = port["routes"].find { |current_route| current_route["prefix"] == "/" }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class RedactedDebugOutput
|
|
4
4
|
SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
|
|
5
|
-
HEADER_REGEX = /^([A-Za-z
|
|
5
|
+
HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
|
|
6
6
|
|
|
7
7
|
def <<(msg)
|
|
8
8
|
$stdout << redact(msg)
|
|
@@ -37,7 +37,7 @@ class ControlplaneApiDirect
|
|
|
37
37
|
# /^[\w\-._]{1134}$/ # 'cpln profile token' format
|
|
38
38
|
# ).freeze
|
|
39
39
|
|
|
40
|
-
API_TOKEN_REGEX = /^[\w\-._]
|
|
40
|
+
API_TOKEN_REGEX = /^[\w\-._]+$/
|
|
41
41
|
API_TOKEN_EXPIRY_SECONDS = 300
|
|
42
42
|
|
|
43
43
|
class << self
|
|
@@ -111,7 +111,7 @@ class ControlplaneApiDirect
|
|
|
111
111
|
def should_refresh_api_token?
|
|
112
112
|
return false unless api_token[:comes_from_profile]
|
|
113
113
|
|
|
114
|
-
payload, = JWT.decode(api_token[:token], nil, false)
|
|
114
|
+
payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
|
|
115
115
|
difference_in_seconds = payload["exp"] - Time.now.to_i
|
|
116
116
|
|
|
117
117
|
difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GithubFlowReadiness
|
|
4
|
+
Result = Struct.new(:status, :message, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
# Each check class accepts the host service in its initializer (so it can reach the
|
|
7
|
+
# shared lockfile parser, HTTP version cache, etc.), exposes a single `call` method,
|
|
8
|
+
# and returns either a `Result`, an array of `Result`s, or `nil` (skipped). Adding
|
|
9
|
+
# a new check is "create a class with `call` and register it in
|
|
10
|
+
# `GithubFlowReadinessService::CHECKS`".
|
|
11
|
+
module Checks
|
|
12
|
+
class Base
|
|
13
|
+
def initialize(service)
|
|
14
|
+
@service = service
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :service
|
|
20
|
+
|
|
21
|
+
def root_path
|
|
22
|
+
service.root_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pass(message)
|
|
26
|
+
Result.new(status: :pass, message: message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fail_result(message)
|
|
30
|
+
Result.new(status: :fail, message: message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warn_result(message)
|
|
34
|
+
Result.new(status: :warn, message: message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def info_result(message)
|
|
38
|
+
Result.new(status: :info, message: message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_path_list(paths)
|
|
42
|
+
paths.map { |path| "`#{path}`" }.join(", ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def first_existing_path(paths)
|
|
46
|
+
paths.find { |relative_path| root_path.join(relative_path).file? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def missing_paths_for(paths)
|
|
50
|
+
paths.reject { |relative_path| root_path.join(relative_path).file? }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class RailsApp < Base
|
|
55
|
+
REQUIRED_PATHS = ["Gemfile", "bin/rails", "config/application.rb", "config.ru"].freeze
|
|
56
|
+
|
|
57
|
+
def call
|
|
58
|
+
missing = missing_paths_for(REQUIRED_PATHS)
|
|
59
|
+
return pass("Rails app scaffold found (#{format_path_list(REQUIRED_PATHS)}).") if missing.empty?
|
|
60
|
+
|
|
61
|
+
fail_result("Missing Rails runtime scaffold: #{format_path_list(missing)}.")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class RubyVersion < Base
|
|
66
|
+
# Oldest Ruby line still receiving security backports (ruby-lang.org/en/downloads/branches/).
|
|
67
|
+
# Bump this constant when the upstream list drops the 3.3 series.
|
|
68
|
+
THRESHOLD = Gem::Version.new("3.3.0")
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
version = service.inferred_ruby_version
|
|
72
|
+
return warn_result("Could not determine the app Ruby version.") unless version
|
|
73
|
+
return pass("Ruby #{version} is modern enough for rollout.") if version >= THRESHOLD
|
|
74
|
+
|
|
75
|
+
fail_result("Ruby #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class BundlerVersion < Base
|
|
80
|
+
THRESHOLD = Gem::Version.new("2.0.0")
|
|
81
|
+
|
|
82
|
+
def call
|
|
83
|
+
version = service.lockfile_bundler_version
|
|
84
|
+
return warn_result("Could not determine the Bundler version from `Gemfile.lock`.") unless version
|
|
85
|
+
return pass("Bundler #{version} is modern enough for rollout.") if version >= THRESHOLD
|
|
86
|
+
|
|
87
|
+
fail_result("Bundler #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Dockerfile < Base
|
|
92
|
+
PATHS = ["Dockerfile", ".controlplane/Dockerfile"].freeze
|
|
93
|
+
|
|
94
|
+
def call
|
|
95
|
+
path = first_existing_path(PATHS)
|
|
96
|
+
return pass("Found production Dockerfile at `#{path}`.") if path
|
|
97
|
+
|
|
98
|
+
fail_result(
|
|
99
|
+
"No production Dockerfile found at `Dockerfile` or `.controlplane/Dockerfile`. " \
|
|
100
|
+
"Add and validate one before generating the Control Plane GitHub flow."
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class SqliteProduction < Base
|
|
106
|
+
def call
|
|
107
|
+
return unless service.sqlite_database_in_production?
|
|
108
|
+
|
|
109
|
+
info_result(
|
|
110
|
+
"Production database config uses SQLite. `cpflow generate` will scaffold " \
|
|
111
|
+
"persistent `db` and `storage` volumes."
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class GemSources < Base
|
|
117
|
+
def call
|
|
118
|
+
non_public = service.gem_dependencies.reject { |dep| service.public_rubygems_dependency?(dep) }
|
|
119
|
+
return pass("All direct Ruby gems resolve from public RubyGems sources.") if non_public.empty?
|
|
120
|
+
|
|
121
|
+
names = non_public.map { |dep| dep[:name] }.sort
|
|
122
|
+
warn_result(
|
|
123
|
+
"Direct Ruby dependencies using git/path or non-public gem sources need manual review: " \
|
|
124
|
+
"#{names.map { |name| "`#{name}`" }.join(', ')}."
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class GemExactPins < Base
|
|
130
|
+
def call
|
|
131
|
+
service.exact_pin_registry_result(service.rubygems_registry_check)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class NpmExactPins < Base
|
|
136
|
+
def call
|
|
137
|
+
return service.package_json_parse_error_result if service.package_json_parse_error
|
|
138
|
+
|
|
139
|
+
service.exact_pin_registry_result(service.npm_registry_check)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|