cpflow 6.0.0.rc.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -38
  3. data/.agents/bin/README.md +7 -8
  4. data/.agents/legacy-workflow-policy.yml +11 -0
  5. data/.agents/shaka.md +20 -0
  6. data/.agents/trusted-actions.yml +12 -0
  7. data/.coderabbit.yaml +1 -0
  8. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  9. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  10. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  11. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  12. data/.github/workflows/cpflow-deploy-review-app.yml +13 -1
  13. data/.github/workflows/cpflow-deploy-staging.yml +12 -1
  14. data/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  15. data/.github/workflows/rspec.yml +24 -0
  16. data/AGENTS.md +18 -3
  17. data/CHANGELOG.md +18 -3
  18. data/CONTRIBUTING.md +1 -1
  19. data/Gemfile.lock +1 -1
  20. data/README.md +5 -2
  21. data/docs/ai-github-flow-prompt.md +2 -2
  22. data/docs/ci-automation.md +178 -14
  23. data/docs/commands.md +26 -13
  24. data/docs/postgres-review-apps.md +90 -0
  25. data/docs/postgres.md +233 -375
  26. data/docs/secrets-and-env-values.md +42 -0
  27. data/docs/sidebars.ts +1 -0
  28. data/lib/command/ai_github_flow_prompt.rb +2 -2
  29. data/lib/command/apply_template.rb +36 -4
  30. data/lib/command/base.rb +56 -3
  31. data/lib/command/delete.rb +174 -4
  32. data/lib/command/deploy_image.rb +6 -0
  33. data/lib/command/generate.rb +247 -3
  34. data/lib/command/generate_github_actions.rb +7 -1
  35. data/lib/command/setup_app.rb +92 -10
  36. data/lib/command/update_github_actions.rb +81 -43
  37. data/lib/core/config.rb +73 -0
  38. data/lib/core/controlplane.rb +22 -2
  39. data/lib/core/controlplane_api.rb +28 -2
  40. data/lib/core/controlplane_api_direct.rb +17 -0
  41. data/lib/core/disposable_postgres_helpers.rb +167 -0
  42. data/lib/core/doctor_service.rb +7 -0
  43. data/lib/core/github_flow_readiness/checks.rb +2 -1
  44. data/lib/core/github_flow_readiness_service.rb +4 -0
  45. data/lib/core/repo_introspection.rb +112 -2
  46. data/lib/cpflow/version.rb +1 -1
  47. data/lib/generator_templates/.dockerignore +16 -0
  48. data/lib/generator_templates/Dockerfile +9 -5
  49. data/lib/generator_templates/entrypoint.sh +2 -0
  50. data/lib/generator_templates/templates/postgres.yml +9 -0
  51. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  52. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  53. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  54. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  55. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  56. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  57. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  58. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  59. data/lib/github_flow_templates/bin/test-cpflow-github-flow +62 -43
  60. metadata +11 -3
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "pathname"
5
+ require "shellwords"
4
6
 
5
7
  require_relative "generator_helpers"
6
8
  require_relative "../core/repo_introspection"
@@ -34,10 +36,29 @@ module Command
34
36
  # `.tool-versions`, or the `Gemfile`. Keep this on a supported release line
35
37
  # (https://www.ruby-lang.org/en/downloads/branches/).
36
38
  DEFAULT_RUBY_VERSION = "3.3"
39
+ SQLITE_DATABASE_PREPARE_FUNCTION = <<~SH
40
+ prepare_sqlite_database() {
41
+ source_path="$1"
42
+ persistent_path="$2"
43
+ legacy_path="${3:-}"
44
+ if [ -n "${legacy_path}" ] && [ -e "${legacy_path}" ] && [ ! -e "${persistent_path}" ]; then
45
+ persistent_path="${legacy_path}"
46
+ fi
47
+ mkdir -p "$(dirname "${source_path}")" "$(dirname "${persistent_path}")"
48
+ if [ -e "${source_path}" ] && [ ! -L "${source_path}" ] && [ ! -e "${persistent_path}" ]; then
49
+ mv -n "${source_path}" "${persistent_path}"
50
+ fi
51
+ rm -f "${source_path}"
52
+ ln -s "${persistent_path}" "${source_path}"
53
+ }
54
+ SH
37
55
 
38
56
  def copy_files
57
+ validate_sqlite_database_paths!
39
58
  generated_paths = copy_template_files("generator_templates", base_template_files)
40
59
  generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project?
60
+ copy_dockerignore unless File.exist?(".dockerignore")
61
+ append_dockerignore_entries
41
62
  substitute_template_variables(generated_paths)
42
63
  make_shell_scripts_executable(generated_paths)
43
64
  end
@@ -64,6 +85,78 @@ module Command
64
85
  destination_path
65
86
  end
66
87
 
88
+ def copy_dockerignore
89
+ copy_file(
90
+ File.join("generator_templates", ".dockerignore"),
91
+ ".dockerignore",
92
+ verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
93
+ )
94
+ end
95
+
96
+ def append_dockerignore_entries
97
+ contents = File.read(".dockerignore")
98
+ existing_lines = contents.lines(chomp: true)
99
+ additions = dockerignore_additions(existing_lines)
100
+ return if additions.empty?
101
+
102
+ File.open(".dockerignore", "a") do |file|
103
+ file.write("\n") unless contents.empty? || contents.end_with?("\n")
104
+ file.puts(additions)
105
+ end
106
+ end
107
+
108
+ def dockerignore_additions(existing_lines)
109
+ entries = File.readlines(File.join(self.class.source_root, "generator_templates", ".dockerignore"), chomp: true)
110
+ sqlite_entries = sqlite_project? ? sqlite_database_ignore_entries : []
111
+ entries += sqlite_entries
112
+ env_entries = [".env*", "!.env.example"]
113
+ mandatory_exclusions = ["config/master.key", "config/credentials/*.key", *sqlite_entries]
114
+ ordinary_entries = entries - env_entries - mandatory_exclusions
115
+ additions = ordinary_entries - existing_lines
116
+ additions += mandatory_exclusions.reject { |entry| dockerignore_exclusion_effective?(existing_lines, entry) }
117
+ additions += env_entries unless dockerignore_env_exception_preserved?(existing_lines)
118
+ additions
119
+ end
120
+
121
+ def dockerignore_exclusion_effective?(lines, exclusion)
122
+ exclusion_index = lines.rindex(exclusion)
123
+ exclusion_index && lines[(exclusion_index + 1)..].none? { |line| line.start_with?("!") }
124
+ end
125
+
126
+ def dockerignore_env_exception_preserved?(lines)
127
+ exclusion_index = lines.rindex(".env*")
128
+ exception_index = lines.rindex("!.env.example")
129
+ return false unless exclusion_index && exception_index && exception_index > exclusion_index
130
+
131
+ lines[(exception_index + 1)..].none? { |line| line.start_with?("!") }
132
+ end
133
+
134
+ def sqlite_database_ignore_entries
135
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).flat_map do |database_path|
136
+ relative = docker_context_database_path(database_path)
137
+ next [] unless relative
138
+
139
+ base = "/#{escape_dockerignore_path(relative)}"
140
+ [base, "#{base}-wal", "#{base}-shm", "#{base}-journal"]
141
+ end.uniq
142
+ end
143
+
144
+ def escape_dockerignore_path(path)
145
+ path.gsub(/[\\*?\[\]]/) { |character| "\\#{character}" }
146
+ end
147
+
148
+ def docker_context_database_path(database_path)
149
+ path = Pathname.new(database_path).cleanpath
150
+ if path.absolute?
151
+ return unless path.to_s.start_with?("/app/")
152
+
153
+ return path.to_s.delete_prefix("/app/")
154
+ end
155
+
156
+ normalized = path.to_s
157
+ normalized unless normalized == "." || normalized.start_with?("../")
158
+ end
159
+
67
160
  def base_template_files
68
161
  COMMON_TEMPLATE_FILES + (sqlite_project? ? [] : POSTGRES_TEMPLATE_FILES)
69
162
  end
@@ -72,7 +165,8 @@ module Command
72
165
  {
73
166
  "__APP_PREFIX__" => inferred_app_prefix,
74
167
  "__RUBY_VERSION__" => inferred_ruby_version,
75
- "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run
168
+ "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run,
169
+ "__SQLITE_DATABASE_SETUP__" => sqlite_database_setup
76
170
  }
77
171
  end
78
172
 
@@ -99,7 +193,7 @@ module Command
99
193
  return "" if stripped.empty?
100
194
  return "" unless single_line_asset_precompile_hook?(stripped)
101
195
 
102
- "RUN #{stripped}\n\n"
196
+ "RUN export SECRET_KEY_BASE=NOT_USED_NON_BLANK && #{stripped}\n\n"
103
197
  end
104
198
 
105
199
  def single_line_asset_precompile_hook?(command)
@@ -113,6 +207,156 @@ module Command
113
207
  RepoIntrospection.sqlite_database_in_production?(Dir.pwd)
114
208
  end
115
209
 
210
+ def validate_sqlite_database_paths!
211
+ return unless sqlite_project?
212
+
213
+ validate_sqlite_database_locations!
214
+ validate_sqlite_persistence_targets!
215
+ return unless RepoIntrospection.unresolved_sqlite_database_paths_in_production?(Dir.pwd)
216
+
217
+ raise Cpflow::Error,
218
+ "Production SQLite database paths must be literal file paths in config/database.yml; " \
219
+ "runtime ERB paths cannot be persisted safely by the generated scaffold."
220
+ end
221
+
222
+ def validate_sqlite_database_locations!
223
+ database_paths = RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd)
224
+ validate_sqlite_paths_without_control_characters!(database_paths)
225
+ validate_sqlite_paths_under_app!(database_paths)
226
+ validate_sqlite_paths_below_mount_roots!(database_paths)
227
+ end
228
+
229
+ def validate_sqlite_paths_without_control_characters!(database_paths)
230
+ unsafe_path = database_paths.find { |database_path| database_path.match?(/[[:cntrl:]]/) }
231
+ return unless unsafe_path
232
+
233
+ raise Cpflow::Error,
234
+ "Production SQLite database paths cannot contain control characters; update config/database.yml " \
235
+ "before generating the scaffold."
236
+ end
237
+
238
+ def validate_sqlite_paths_under_app!(database_paths)
239
+ unsupported_path = database_paths.find do |database_path|
240
+ path = absolute_app_database_path(database_path)
241
+ path != "/app" && !path.start_with?("/app/")
242
+ end
243
+ return unless unsupported_path
244
+
245
+ raise Cpflow::Error,
246
+ "Production SQLite database path #{unsupported_path.inspect} must resolve under /app so the generated " \
247
+ "scaffold can persist it safely."
248
+ end
249
+
250
+ def validate_sqlite_paths_below_mount_roots!(database_paths)
251
+ mount_root_path = database_paths.find do |database_path|
252
+ ["/app/data", "/app/storage"].include?(absolute_app_database_path(database_path))
253
+ end
254
+ return unless mount_root_path
255
+
256
+ raise Cpflow::Error,
257
+ "Production SQLite database path #{mount_root_path.inspect} resolves to a volume mount directory; " \
258
+ "use a file path beneath data/ or storage/."
259
+ end
260
+
261
+ def sqlite_database_setup
262
+ redirects = sqlite_database_redirects
263
+ return "" if redirects.empty?
264
+
265
+ setup_calls = redirects.map do |source, target, legacy|
266
+ arguments = [source, target, legacy].compact.map { |path| Shellwords.shellescape(path) }
267
+ "prepare_sqlite_database #{arguments.join(' ')}"
268
+ end
269
+ "#{SQLITE_DATABASE_PREPARE_FUNCTION}\n#{setup_calls.join("\n")}\n"
270
+ end
271
+
272
+ def sqlite_database_redirects
273
+ return [] unless sqlite_project?
274
+
275
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).filter_map do |database_path|
276
+ source = absolute_app_database_path(database_path)
277
+ next if persistent_sqlite_path?(source)
278
+
279
+ target = persistent_sqlite_target(source)
280
+ [source, target, legacy_sqlite_database_path(source)]
281
+ end
282
+ end
283
+
284
+ def validate_sqlite_persistence_targets!
285
+ paths_by_target = sqlite_database_paths_by_persistence_target
286
+ validate_exact_sqlite_target_collisions!(paths_by_target)
287
+ validate_ancestor_sqlite_target_collisions!(paths_by_target.keys)
288
+ end
289
+
290
+ def validate_exact_sqlite_target_collisions!(paths_by_target)
291
+ collision = paths_by_target.find { |_target, paths| paths.uniq.size > 1 }
292
+ return unless collision
293
+
294
+ target = collision.first
295
+ paths = collision.last.uniq
296
+ raise Cpflow::Error,
297
+ "Production SQLite database paths #{paths.map(&:inspect).join(' and ')} resolve to the same persistent " \
298
+ "target #{target.inspect}; use distinct paths before generating the scaffold."
299
+ end
300
+
301
+ def validate_ancestor_sqlite_target_collisions!(targets)
302
+ collision = targets.combination(2).find do |first, second|
303
+ sqlite_path_ancestor?(first, second) || sqlite_path_ancestor?(second, first)
304
+ end
305
+ return unless collision
306
+
307
+ first, second = collision
308
+ raise Cpflow::Error,
309
+ "Production SQLite persistence targets #{first.inspect} and #{second.inspect} overlap; " \
310
+ "a database file cannot contain another database path."
311
+ end
312
+
313
+ def sqlite_path_ancestor?(ancestor, descendant)
314
+ descendant.start_with?("#{ancestor}/")
315
+ end
316
+
317
+ def sqlite_database_paths_by_persistence_target
318
+ paths_by_target = Hash.new { |hash, key| hash[key] = [] }
319
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).each do |database_path|
320
+ source = absolute_app_database_path(database_path)
321
+ target = persistent_sqlite_path?(source) ? source : persistent_sqlite_target(source)
322
+ [target, legacy_sqlite_database_path(source)].compact.each do |claimed_path|
323
+ claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
324
+ end
325
+ end
326
+ paths_by_target
327
+ end
328
+
329
+ def claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
330
+ sqlite_database_owned_paths(claimed_path).each do |owned_path|
331
+ paths_by_target[owned_path] << database_path
332
+ end
333
+ end
334
+
335
+ def sqlite_database_owned_paths(database_path)
336
+ [database_path, "#{database_path}-wal", "#{database_path}-shm", "#{database_path}-journal"]
337
+ end
338
+
339
+ def persistent_sqlite_target(source)
340
+ relative = source.delete_prefix("/")
341
+ File.join("/app/data", relative.delete_prefix("app/"))
342
+ end
343
+
344
+ def legacy_sqlite_database_path(source)
345
+ return unless source.start_with?("/app/db/")
346
+
347
+ File.join("/app/data", source.delete_prefix("/app/db/"))
348
+ end
349
+
350
+ def absolute_app_database_path(database_path)
351
+ path = Pathname.new(database_path)
352
+ (path.absolute? ? path : Pathname.new("/app").join(path)).cleanpath.to_s
353
+ end
354
+
355
+ def persistent_sqlite_path?(path)
356
+ path == "/app/data" || path.start_with?("/app/data/") ||
357
+ path == "/app/storage" || path.start_with?("/app/storage/")
358
+ end
359
+
116
360
  def normalized_asset_precompile_hook_command
117
361
  command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook
118
362
  return unless command
@@ -169,7 +413,7 @@ module Command
169
413
  - infers the app prefix from the current directory and wires staging, review, and production entries
170
414
  - infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`
171
415
  - preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation
172
- - detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload
416
+ - detects SQLite in `config/database.yml` and generates persistent `/app/data` and `/app/storage` volume templates without hiding image migrations under `/app/db`
173
417
  DESC
174
418
  EXAMPLES = <<~EX
175
419
  ```sh
@@ -12,6 +12,7 @@ module Command
12
12
  include GeneratorHelpers
13
13
 
14
14
  argument :staging_branch, type: :string, required: false
15
+ class_option :workflows, type: :array
15
16
 
16
17
  def copy_files
17
18
  relative_paths = generated_files
@@ -51,7 +52,12 @@ module Command
51
52
  def generated_files
52
53
  # Keep file discovery centralized on the command class so existence checks and
53
54
  # Thor's template copy list cannot drift.
54
- GenerateGithubActions.generated_files
55
+ paths = GenerateGithubActions.generated_files
56
+ return paths unless options.key?("workflows")
57
+
58
+ paths.reject do |path|
59
+ path.start_with?(".github/workflows/") && !options["workflows"].include?(File.basename(path))
60
+ end
55
61
  end
56
62
 
57
63
  def staging_branch_filter
@@ -18,13 +18,14 @@ module Command
18
18
  - Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
19
19
  using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
20
20
  - Creates identity for secrets if it does not exist
21
+ - For dynamically named review apps with `generated_review_secret_keys`, checks an existing policy before writing credentials, creates a per-app dictionary, skips its secret template during initial setup, and fills missing disposable keys without printing or rotating values
21
22
  - Binds the app identity to any configured `shared_secret_grants` policies as part of the secrets setup flow; skipped when `--skip-secrets-setup` or `--skip-secret-access-binding` is provided, or `skip_secrets_setup` is set
22
23
  - Use `--skip-secrets-setup` to prevent the automatic setup of secrets,
23
24
  or set it through `skip_secrets_setup` in the `.controlplane/controlplane.yml` file
24
25
  - Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
25
26
  - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
26
27
  - Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
27
- - Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret resources entirely, repairing secrets access bindings, and skipping the post-creation hook
28
+ - Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret templates (but filling missing opt-in generated review keys), repairing secrets access bindings, and skipping the post-creation hook
28
29
  DESC
29
30
  VALIDATIONS = %w[config templates].freeze
30
31
 
@@ -49,8 +50,13 @@ module Command
49
50
 
50
51
  args = []
51
52
  args.push("--add-app-identity") unless skip_secrets_setup
52
- args.push("--yes", "--preserve-existing-runtime") if refresh_templates
53
- run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
53
+ args.push("--yes") if refresh_templates
54
+ args.push("--preserve-existing-runtime") if refresh_templates
55
+ if !skip_secrets_setup && config.generated_review_secret_keys.any?
56
+ args.push("--exclude-policy-template", config.secrets_policy)
57
+ args.push("--exclude-secret-template", config.secrets) unless refresh_templates
58
+ end
59
+ run_cpflow_command("apply-template", "-a", config.app, *args, *templates)
54
60
 
55
61
  bind_identity_to_policy unless skip_secrets_setup
56
62
  bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
@@ -65,24 +71,81 @@ module Command
65
71
  end
66
72
 
67
73
  def create_secret_and_policy_if_not_exist
74
+ validate_existing_generated_review_policy_before_secret!
68
75
  create_secret_if_not_exists
69
76
  create_policy_if_not_exists
70
77
 
71
78
  progress.puts
72
79
  end
73
80
 
81
+ def validate_existing_generated_review_policy_before_secret!
82
+ return if config.generated_review_secret_keys.empty?
83
+
84
+ policy = cp.fetch_policy(config.secrets_policy)
85
+ verify_generated_review_policy!(policy) if policy
86
+ end
87
+
74
88
  def create_secret_if_not_exists
75
- if cp.fetch_secret(config.secrets)
76
- progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
89
+ secret = cp.fetch_secret(config.secrets)
90
+ return existing_secret_if_any(secret) if secret
91
+
92
+ step("Creating secret '#{config.secrets}'") { create_new_secret }
93
+ end
94
+
95
+ def existing_secret_if_any(secret)
96
+ if config.generated_review_secret_keys.any? && !generated_review_secret_owned_by_app?(secret)
97
+ raise "Existing review app secret dictionary is not owned by this app."
98
+ end
99
+
100
+ progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
101
+ fill_missing_generated_review_secrets
102
+ end
103
+
104
+ def create_new_secret
105
+ if config.generated_review_secret_keys.any?
106
+ cp.create_sensitive_secret(config.secrets, generated_review_secret_data)
77
107
  else
78
- step("Creating secret '#{config.secrets}'") do
79
- cp.apply_hash(build_secret_hash)
80
- end
108
+ cp.apply_hash(build_secret_hash)
109
+ end
110
+ end
111
+
112
+ def fill_missing_generated_review_secrets
113
+ keys = config.generated_review_secret_keys
114
+ return if keys.empty?
115
+
116
+ data = revealed_review_secret_data
117
+
118
+ missing_keys = keys.reject { |key| data[key].is_a?(String) && !data[key].empty? }
119
+ return if missing_keys.empty?
120
+
121
+ step("Adding missing generated review app secret fields") do
122
+ cp.patch_sensitive_secret_data(config.secrets, generated_review_secret_data(missing_keys))
123
+ end
124
+ end
125
+
126
+ def revealed_review_secret_data
127
+ revealed = begin
128
+ cp.reveal_secret(config.secrets, required: true)
129
+ rescue StandardError
130
+ raise "Cannot safely inspect existing review app secret dictionary.", cause: nil
81
131
  end
132
+ raise "Cannot safely inspect existing review app secret dictionary." unless revealed.is_a?(Hash)
133
+ raise "Existing review app secret is not a dictionary." unless revealed["type"] == "dictionary"
134
+
135
+ data = revealed["data"]
136
+ raise "Cannot safely inspect existing review app secret dictionary." unless data.is_a?(Hash)
137
+
138
+ data
139
+ end
140
+
141
+ def generated_review_secret_data(keys = config.generated_review_secret_keys)
142
+ keys.to_h { |key| [key, SecureRandom.hex(32)] }
82
143
  end
83
144
 
84
145
  def create_policy_if_not_exists
85
- if cp.fetch_policy(config.secrets_policy)
146
+ policy = cp.fetch_policy(config.secrets_policy)
147
+ if policy
148
+ verify_generated_review_policy!(policy) if config.generated_review_secret_keys.any?
86
149
  progress.puts("Policy '#{config.secrets_policy}' already exists. Skipping creation...")
87
150
  else
88
151
  step("Creating policy '#{config.secrets_policy}'") do
@@ -91,6 +154,16 @@ module Command
91
154
  end
92
155
  end
93
156
 
157
+ def verify_generated_review_policy!(policy)
158
+ expected_target = policy_targets_secret?(policy, config.secrets)
159
+ owned_policy = generated_review_app_tag(policy) == config.app
160
+ no_extra_selectors = %w[target targetQuery gvc].all? { |key| policy[key].nil? }
161
+ own_bindings = generated_review_policy_binding_state(policy) != :unsafe
162
+ return if expected_target && owned_policy && no_extra_selectors && own_bindings
163
+
164
+ raise "Existing review app secret policy has an unexpected target or binding."
165
+ end
166
+
94
167
  def build_secret_hash
95
168
  {
96
169
  "kind" => "secret",
@@ -101,17 +174,26 @@ module Command
101
174
  end
102
175
 
103
176
  def build_policy_hash
104
- {
177
+ policy = {
105
178
  "kind" => "policy",
106
179
  "name" => config.secrets_policy,
107
180
  "targetKind" => "secret",
108
181
  "targetLinks" => ["//secret/#{config.secrets}"]
109
182
  }
183
+ policy["tags"] = { ::Config::GENERATED_REVIEW_APP_TAG => config.app } if config.generated_review_secret_keys.any?
184
+ policy
110
185
  end
111
186
 
112
187
  def bind_identity_to_policy
113
188
  progress.puts
114
189
 
190
+ if config.generated_review_secret_keys.any?
191
+ policy = cp.fetch_policy(config.secrets_policy)
192
+ raise "Cannot safely inspect review app secret policy before binding." unless policy.is_a?(Hash)
193
+
194
+ verify_generated_review_policy!(policy)
195
+ end
196
+
115
197
  step("Binding identity '#{config.identity}' to policy '#{config.secrets_policy}'") do
116
198
  cp.bind_identity_to_policy(config.identity_link, config.secrets_policy)
117
199
  end
@@ -5,33 +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
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
13
19
  DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
14
20
  LONG_DESCRIPTION = <<~DESC.freeze
15
- Regenerates the cpflow workflow wrappers, local composite actions, and
16
- helper files from the currently installed cpflow gem. Use this after
17
- updating the cpflow gem so checked-in workflow wrappers move to the
18
- matching upstream release tag, for example `v#{Cpflow::VERSION}`, and the
19
- generated action implementations move with them.
20
-
21
- If the existing generated staging workflow uses a custom single staging
22
- branch, the command preserves it. Pass `--staging-branch BRANCH` to set or
23
- 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.
24
37
  DESC
25
38
  EXAMPLES = <<~EX
26
39
  ```sh
27
- # After updating the cpflow gem, refresh every generated GitHub Actions file
40
+ # Refresh actions/helpers while preserving downstream workflows
28
41
  cpflow update-github-actions
29
42
 
30
43
  # When running cpflow through Bundler
31
44
  bundle exec cpflow update-github-actions
32
45
 
33
- # Preserve or set a custom staging branch
34
- 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
35
51
  ```
36
52
  EX
37
53
  WITH_INFO_HEADER = false
@@ -44,66 +60,88 @@ module Command
44
60
  def call
45
61
  GenerateGithubActions.ensure_template_root!
46
62
  abort_if_no_generated_files!
47
-
48
- branch = staging_branch || inferred_staging_branch
49
- 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
50
67
 
51
68
  print_post_update_message
52
69
  end
53
70
 
54
71
  private
55
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
+
56
103
  def abort_if_no_generated_files!
57
104
  return if GenerateGithubActions.generated_files.any? { |path| File.exist?(path) }
58
105
 
59
- Shell.abort(
60
- "No generated cpflow GitHub Actions files found in this repository. " \
61
- "Run `cpflow generate-github-actions` first to create the wrappers, " \
62
- "then use `cpflow update-github-actions` after future gem upgrades."
63
- )
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
64
111
  end
65
112
 
66
113
  def print_post_update_message
67
114
  Shell.info("")
68
- 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.")
69
117
  Shell.info("Next: review the diff and run `bin/test-cpflow-github-flow`.")
70
118
  Shell.info("If you run cpflow through Bundler, use `bin/test-cpflow-github-flow bundle exec cpflow`.")
71
119
  end
72
120
 
73
121
  def inferred_staging_branch
74
122
  branches = existing_staging_branches
75
- return if branches.empty? || branches.sort == DEFAULT_STAGING_BRANCHES.sort
76
- return branches.first if branches.length == 1
77
-
78
- Shell.warn(
79
- "Existing staging workflow has multiple custom push branches: #{branches.join(', ')}. " \
80
- "Run with --staging-branch BRANCH if only one branch should auto-deploy staging."
81
- )
123
+ return if branches.sort == DEFAULT_STAGING_BRANCHES.sort
124
+ return branches.first if branches.length == 1 && valid_staging_branch?(branches.first)
82
125
 
83
- nil
126
+ Shell.abort("Cannot infer staging deployment branch. Pass --staging-branch BRANCH to explicitly set it.")
84
127
  end
85
128
 
86
129
  def existing_staging_branches
87
- 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 : []
88
133
  end
89
134
 
90
135
  def parsed_staging_workflow_on
91
136
  return {} unless STAGING_WORKFLOW_PATH.file?
92
137
 
93
138
  workflow = YAML.load_file(STAGING_WORKFLOW_PATH, aliases: true)
94
- return {} unless workflow.is_a?(Hash)
95
-
96
- workflow_on = workflow["on"] || workflow[true]
139
+ workflow_on = workflow.is_a?(Hash) ? workflow["on"] || workflow[true] : nil
97
140
  workflow_on.is_a?(Hash) ? workflow_on : {}
98
141
  rescue Psych::SyntaxError => e
99
- warn_unparseable_staging_workflow(e)
100
- {}
101
- end
102
-
103
- def warn_unparseable_staging_workflow(error)
104
- Shell.warn(
105
- "Could not parse #{STAGING_WORKFLOW_PATH}: #{error.message}. " \
106
- "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."
107
145
  )
108
146
  end
109
147
  end