cpflow 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -25
  3. data/.agents/bin/README.md +19 -9
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.agents/legacy-workflow-policy.yml +11 -0
  10. data/.agents/shaka.md +20 -0
  11. data/.agents/trusted-actions.yml +12 -0
  12. data/.coderabbit.yaml +14 -0
  13. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  14. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  15. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  16. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  17. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  18. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  19. data/.github/dependabot.yml +10 -0
  20. data/.github/workflows/check_cpln_links.yml +6 -1
  21. data/.github/workflows/claude-code-review.yml +5 -2
  22. data/.github/workflows/claude.yml +6 -4
  23. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  24. data/.github/workflows/command_docs.yml +7 -2
  25. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  26. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  27. data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
  28. data/.github/workflows/cpflow-deploy-staging.yml +28 -11
  29. data/.github/workflows/cpflow-help-command.yml +3 -3
  30. data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  31. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  32. data/.github/workflows/rspec-shared.yml +34 -26
  33. data/.github/workflows/rspec-specific.yml +10 -2
  34. data/.github/workflows/rspec.yml +95 -4
  35. data/.github/workflows/rubocop.yml +9 -2
  36. data/.github/workflows/trigger-docs-site.yml +2 -0
  37. data/.rubocop.yml +6 -1
  38. data/AGENTS.md +18 -3
  39. data/CHANGELOG.md +39 -1
  40. data/CONTRIBUTING.md +39 -2
  41. data/Gemfile.lock +1 -1
  42. data/README.md +10 -2
  43. data/cpflow.gemspec +3 -15
  44. data/docs/ai-github-flow-prompt.md +3 -3
  45. data/docs/ci-automation.md +284 -68
  46. data/docs/commands.md +38 -15
  47. data/docs/postgres-review-apps.md +90 -0
  48. data/docs/postgres.md +233 -375
  49. data/docs/rds-private-networking.md +12 -12
  50. data/docs/releasing.md +15 -4
  51. data/docs/secrets-and-env-values.md +42 -0
  52. data/docs/sidebars.ts +1 -0
  53. data/examples/controlplane.yml +5 -0
  54. data/lib/command/ai_github_flow_prompt.rb +2 -2
  55. data/lib/command/apply_template.rb +36 -4
  56. data/lib/command/base.rb +56 -3
  57. data/lib/command/cleanup_stale_apps.rb +28 -4
  58. data/lib/command/copy_image_from_upstream.rb +3 -0
  59. data/lib/command/delete.rb +174 -4
  60. data/lib/command/deploy_image.rb +44 -1
  61. data/lib/command/generate.rb +247 -3
  62. data/lib/command/generate_github_actions.rb +43 -13
  63. data/lib/command/run.rb +224 -34
  64. data/lib/command/setup_app.rb +92 -10
  65. data/lib/command/update_github_actions.rb +82 -43
  66. data/lib/core/config.rb +73 -0
  67. data/lib/core/controlplane.rb +167 -80
  68. data/lib/core/controlplane_api.rb +28 -2
  69. data/lib/core/controlplane_api_direct.rb +17 -0
  70. data/lib/core/disposable_postgres_helpers.rb +167 -0
  71. data/lib/core/doctor_service.rb +7 -0
  72. data/lib/core/github_flow_readiness/checks.rb +2 -1
  73. data/lib/core/github_flow_readiness_service.rb +4 -0
  74. data/lib/core/repo_introspection.rb +112 -2
  75. data/lib/core/shell.rb +35 -10
  76. data/lib/core/timed_command.rb +111 -0
  77. data/lib/cpflow/version.rb +1 -1
  78. data/lib/cpflow.rb +1 -1
  79. data/lib/generator_templates/.dockerignore +16 -0
  80. data/lib/generator_templates/Dockerfile +9 -5
  81. data/lib/generator_templates/entrypoint.sh +2 -0
  82. data/lib/generator_templates/templates/postgres.yml +9 -0
  83. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  84. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  85. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  86. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  87. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  88. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  89. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  90. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  91. data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
  92. data/lib/patches/hash.rb +2 -2
  93. data/rakelib/create_release.rake +14 -14
  94. data/script/check_shell_scripts +66 -0
  95. metadata +18 -17
@@ -5,32 +5,49 @@ require "yaml"
5
5
  require_relative "staging_branch_validation"
6
6
 
7
7
  module Command
8
- class UpdateGithubActions < Base
8
+ class UpdateGithubActions < Base # rubocop:disable Metrics/ClassLength
9
9
  include StagingBranchValidation
10
10
 
11
11
  NAME = "update-github-actions"
12
- OPTIONS = [staging_branch_option].freeze
13
- DESCRIPTION = "Regenerates generated GitHub Actions wrappers for the installed cpflow version"
12
+ OPTIONS = [staging_branch_option, {
13
+ name: :workflows,
14
+ params: {
15
+ type: :array, banner: "FILE...",
16
+ desc: "Explicitly add or replace these generated workflow filenames (default: preserve all workflows)"
17
+ }
18
+ }].freeze
19
+ DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
14
20
  LONG_DESCRIPTION = <<~DESC.freeze
15
- Regenerates the generated cpflow GitHub Actions wrappers and helper files
16
- from the currently installed cpflow gem. Use this after updating the
17
- cpflow gem so checked-in workflow wrappers move to the matching upstream
18
- release tag, for example `v#{Cpflow::VERSION}`.
19
-
20
- If the existing generated staging workflow uses a custom single staging
21
- branch, the command preserves it. Pass `--staging-branch BRANCH` to set or
22
- replace the generated staging branch explicitly.
21
+ Refreshes local composite actions and helper files from the installed gem.
22
+ All top-level workflows are preserved by default, including their refs,
23
+ triggers, permissions, and deployment ownership. Use --workflows FILE...
24
+ to explicitly add or replace named generated workflows. Replacement resets
25
+ each selected workflow to its template and matching v#{Cpflow::VERSION} ref;
26
+ review and reapply any downstream customizations in the same PR.
27
+
28
+ Selecting cpflow-deploy-staging.yml preserves a single existing push branch
29
+ or the default main/master pair. Missing or ambiguous branches require
30
+ --staging-branch BRANCH. This option requires selecting the staging workflow.
31
+
32
+ A differing existing bin/test-cpflow-github-flow blocks all writes. Move
33
+ downstream checks to executable bin/test-cpflow-github-flow-custom and
34
+ remove or rename the old validator before updating. The generated validator
35
+ invokes that extension after its baseline checks; the extension is never
36
+ generated or overwritten. See docs/ci-automation.md for migration guidance.
23
37
  DESC
24
38
  EXAMPLES = <<~EX
25
39
  ```sh
26
- # After updating the cpflow gem, refresh generated GitHub Actions wrappers
40
+ # Refresh actions/helpers while preserving downstream workflows
27
41
  cpflow update-github-actions
28
42
 
29
43
  # When running cpflow through Bundler
30
44
  bundle exec cpflow update-github-actions
31
45
 
32
- # Preserve or set a custom staging branch
33
- cpflow update-github-actions --staging-branch develop
46
+ # Explicitly replace the review-app wrapper
47
+ cpflow update-github-actions --workflows cpflow-deploy-review-app.yml
48
+
49
+ # Explicitly add or replace staging deployment from develop
50
+ cpflow update-github-actions --workflows cpflow-deploy-staging.yml --staging-branch develop
34
51
  ```
35
52
  EX
36
53
  WITH_INFO_HEADER = false
@@ -43,66 +60,88 @@ module Command
43
60
  def call
44
61
  GenerateGithubActions.ensure_template_root!
45
62
  abort_if_no_generated_files!
46
-
47
- branch = staging_branch || inferred_staging_branch
48
- GithubActionsGenerator.start([branch].compact)
63
+ workflows = selected_workflows
64
+ branch = selected_staging_branch(workflows)
65
+ abort_if_custom_validator!
66
+ GithubActionsGenerator.new([branch].compact, workflows: workflows).invoke_all
49
67
 
50
68
  print_post_update_message
51
69
  end
52
70
 
53
71
  private
54
72
 
73
+ def selected_workflows
74
+ available = GenerateGithubActions.generated_files.grep(%r{\A\.github/workflows/}).map do |path|
75
+ File.basename(path)
76
+ end
77
+ selected = Array(config.options[:workflows])
78
+ unknown = selected - available
79
+ Shell.abort("Unknown --workflows: #{unknown.join(', ')}. Available: #{available.join(', ')}") if unknown.any?
80
+ selected
81
+ end
82
+
83
+ def selected_staging_branch(workflows)
84
+ branch = staging_branch
85
+ return branch || inferred_staging_branch if workflows.include?(STAGING_WORKFLOW_PATH.basename.to_s)
86
+
87
+ Shell.abort("--staging-branch requires --workflows cpflow-deploy-staging.yml") if branch
88
+ nil
89
+ end
90
+
91
+ def abort_if_custom_validator!
92
+ path = "bin/test-cpflow-github-flow"
93
+ return unless File.exist?(path)
94
+ return if File.binread(path) == File.binread(GenerateGithubActions.source_file(path))
95
+
96
+ Shell.abort(<<~MESSAGE)
97
+ #{path} differs from the current generated validator. No files were updated.
98
+ Move downstream checks to executable bin/test-cpflow-github-flow-custom, then remove or rename
99
+ #{path} and retry. See docs/ci-automation.md for the legacy validator migration.
100
+ MESSAGE
101
+ end
102
+
55
103
  def abort_if_no_generated_files!
56
104
  return if GenerateGithubActions.generated_files.any? { |path| File.exist?(path) }
57
105
 
58
- Shell.abort(
59
- "No generated cpflow GitHub Actions files found in this repository. " \
60
- "Run `cpflow generate-github-actions` first to create the wrappers, " \
61
- "then use `cpflow update-github-actions` after future gem upgrades."
62
- )
106
+ Shell.abort(<<~MESSAGE)
107
+ No generated cpflow GitHub Actions files found in this repository.
108
+ Run `cpflow generate-github-actions` first to create the wrappers,
109
+ then use `cpflow update-github-actions` after future gem upgrades.
110
+ MESSAGE
63
111
  end
64
112
 
65
113
  def print_post_update_message
66
114
  Shell.info("")
67
- Shell.info("Updated cpflow GitHub Actions wrappers for cpflow #{Cpflow::VERSION}.")
115
+ Shell.info("Updated cpflow GitHub Actions files for cpflow #{Cpflow::VERSION}.")
116
+ Shell.info("Preserved unselected workflows; review their refs and compatibility before committing.")
68
117
  Shell.info("Next: review the diff and run `bin/test-cpflow-github-flow`.")
69
118
  Shell.info("If you run cpflow through Bundler, use `bin/test-cpflow-github-flow bundle exec cpflow`.")
70
119
  end
71
120
 
72
121
  def inferred_staging_branch
73
122
  branches = existing_staging_branches
74
- return if branches.empty? || branches.sort == DEFAULT_STAGING_BRANCHES.sort
75
- return branches.first if branches.length == 1
76
-
77
- Shell.warn(
78
- "Existing staging workflow has multiple custom push branches: #{branches.join(', ')}. " \
79
- "Run with --staging-branch BRANCH if only one branch should auto-deploy staging."
80
- )
123
+ return if branches.sort == DEFAULT_STAGING_BRANCHES.sort
124
+ return branches.first if branches.length == 1 && valid_staging_branch?(branches.first)
81
125
 
82
- nil
126
+ Shell.abort("Cannot infer staging deployment branch. Pass --staging-branch BRANCH to explicitly set it.")
83
127
  end
84
128
 
85
129
  def existing_staging_branches
86
- Array(parsed_staging_workflow_on.dig("push", "branches")).map(&:to_s)
130
+ push = parsed_staging_workflow_on["push"]
131
+ branches = push.is_a?(Hash) ? push["branches"] : nil
132
+ branches.is_a?(Array) && branches.all?(String) ? branches : []
87
133
  end
88
134
 
89
135
  def parsed_staging_workflow_on
90
136
  return {} unless STAGING_WORKFLOW_PATH.file?
91
137
 
92
138
  workflow = YAML.load_file(STAGING_WORKFLOW_PATH, aliases: true)
93
- return {} unless workflow.is_a?(Hash)
94
-
95
- workflow_on = workflow["on"] || workflow[true]
139
+ workflow_on = workflow.is_a?(Hash) ? workflow["on"] || workflow[true] : nil
96
140
  workflow_on.is_a?(Hash) ? workflow_on : {}
97
141
  rescue Psych::SyntaxError => e
98
- warn_unparseable_staging_workflow(e)
99
- {}
100
- end
101
-
102
- def warn_unparseable_staging_workflow(error)
103
- Shell.warn(
104
- "Could not parse #{STAGING_WORKFLOW_PATH}: #{error.message}. " \
105
- "Run with --staging-branch BRANCH if staging should use a custom branch."
142
+ Shell.abort(
143
+ "Could not parse #{STAGING_WORKFLOW_PATH}: #{e.message}. " \
144
+ "Pass --staging-branch BRANCH to explicitly replace its staging deployment branch."
106
145
  )
107
146
  end
108
147
  end
data/lib/core/config.rb CHANGED
@@ -10,6 +10,7 @@ class Config # rubocop:disable Metrics/ClassLength
10
10
  include Helpers
11
11
 
12
12
  CONFIG_FILE_LOCATION = ".controlplane/controlplane.yml"
13
+ GENERATED_REVIEW_APP_TAG = "cpflow-generated-review-app"
13
14
  REQUIRED_SHARED_SECRET_GRANT_KEYS = %i[name secret_name policy_name].freeze
14
15
  SHARED_SECRET_RESOURCE_NAME_KEYS = %i[secret_name policy_name].freeze
15
16
  CONTROL_PLANE_RESOURCE_NAME_REGEX = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
@@ -52,6 +53,8 @@ class Config # rubocop:disable Metrics/ClassLength
52
53
  end
53
54
 
54
55
  def secrets
56
+ return "#{app}-secrets" if generated_review_secret_keys.any?
57
+
55
58
  current&.dig(:secrets_name) || "#{app_prefix}-secrets"
56
59
  end
57
60
 
@@ -59,6 +62,54 @@ class Config # rubocop:disable Metrics/ClassLength
59
62
  current&.dig(:secrets_policy_name) || "#{secrets}-policy"
60
63
  end
61
64
 
65
+ def generated_review_secret_keys
66
+ @generated_review_secret_keys ||= begin
67
+ keys = current&.dig(:generated_review_secret_keys)
68
+ if keys.nil?
69
+ []
70
+ else
71
+ validate_generated_review_secret_scope!
72
+ validate_generated_review_secret_keys!(keys)
73
+ keys
74
+ end
75
+ end
76
+ end
77
+
78
+ # Cleanup must still find marked per-app resources after the opt-in is removed
79
+ # from the current configuration.
80
+ def disposable_review_secret_resource_names
81
+ return nil unless current&.dig(:match_if_app_name_starts_with) && app.to_s != app_prefix.to_s
82
+ return nil unless app.match?(CONTROL_PLANE_RESOURCE_NAME_REGEX)
83
+
84
+ secret = "#{app}-secrets"
85
+ policy = "#{secret}-policy"
86
+ return nil unless policy.length <= 64
87
+
88
+ [secret, policy]
89
+ end
90
+
91
+ def validate_generated_review_secret_scope!
92
+ dynamic_review_app = current[:match_if_app_name_starts_with] && app.to_s != app_prefix.to_s
93
+ raise "generated_review_secret_keys is only allowed for a dynamically named review app." unless dynamic_review_app
94
+
95
+ unless "#{app}-secrets-policy".length <= 64 && app.match?(CONTROL_PLANE_RESOURCE_NAME_REGEX)
96
+ raise "Review app name is too long or invalid for a per-app secret policy."
97
+ end
98
+ return unless current[:secrets_name] || current[:secrets_policy_name]
99
+
100
+ raise "generated_review_secret_keys cannot be combined with secrets_name or secrets_policy_name."
101
+ end
102
+
103
+ def validate_generated_review_secret_keys!(keys)
104
+ valid_size = keys.is_a?(Array) && keys.size.between?(1, 8)
105
+ valid_names = valid_size && keys.all? { |key| key.is_a?(String) && key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
106
+ return if valid_names && keys.uniq == keys
107
+
108
+ raise "generated_review_secret_keys must contain 1-8 unique uppercase environment names."
109
+ end
110
+
111
+ private :validate_generated_review_secret_scope!, :validate_generated_review_secret_keys!
112
+
62
113
  def shared_secret_grants
63
114
  @shared_secret_grants ||= normalize_shared_secret_grants(current&.dig(:shared_secret_grants))
64
115
  end
@@ -188,6 +239,28 @@ class Config # rubocop:disable Metrics/ClassLength
188
239
  end
189
240
  end
190
241
 
242
+ def validate_generated_review_secret_settings!
243
+ apps.each do |app_name, app_options|
244
+ keys = app_options[:generated_review_secret_keys]
245
+ next if keys.nil?
246
+
247
+ validate_generated_review_secret_entry_scope!(app_name, app_options)
248
+ validate_generated_review_secret_keys!(keys)
249
+ end
250
+ end
251
+
252
+ def validate_generated_review_secret_entry_scope!(app_name, app_options)
253
+ unless app_options[:match_if_app_name_starts_with]
254
+ raise "generated_review_secret_keys for '#{app_name}' requires a dynamically matched review app."
255
+ end
256
+ return unless app_options[:secrets_name] || app_options[:secrets_policy_name]
257
+
258
+ raise "generated_review_secret_keys for '#{app_name}' cannot be combined with " \
259
+ "secrets_name or secrets_policy_name."
260
+ end
261
+
262
+ private :validate_generated_review_secret_entry_scope!
263
+
191
264
  private
192
265
 
193
266
  def ensure_current_config!