cpflow 5.1.1 → 5.3.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -0
  3. data/.agents/bin/README.md +20 -0
  4. data/.agents/bin/docs +5 -0
  5. data/.agents/bin/lint +5 -0
  6. data/.agents/bin/setup +5 -0
  7. data/.agents/bin/test +5 -0
  8. data/.agents/bin/validate +5 -0
  9. data/.agents/trusted-github-actors.yml +32 -0
  10. data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
  11. data/.github/actions/cpflow-setup-environment/action.yml +1 -1
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/pull_request_template.md +18 -0
  14. data/.github/workflows/claude-code-review.yml +2 -0
  15. data/.github/workflows/claude.yml +94 -1
  16. data/.github/workflows/cpflow-delete-review-app.yml +621 -33
  17. data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
  18. data/.github/workflows/cpflow-review-app-help.yml +5 -13
  19. data/.github/workflows/rspec-shared.yml +10 -3
  20. data/.github/workflows/rspec-specific.yml +1 -0
  21. data/.github/workflows/rspec.yml +58 -1
  22. data/AGENTS.md +14 -0
  23. data/CHANGELOG.md +54 -1
  24. data/CLAUDE.md +3 -0
  25. data/CONTRIBUTING.md +15 -3
  26. data/Gemfile.lock +1 -1
  27. data/README.md +21 -7
  28. data/docs/ai-github-flow-prompt.md +18 -16
  29. data/docs/ci-automation.md +239 -27
  30. data/docs/commands.md +30 -2
  31. data/docs/grafana-opentelemetry.md +699 -0
  32. data/docs/secrets-and-env-values.md +37 -2
  33. data/docs/sidebars.ts +70 -0
  34. data/docs/telemetry/application-instrumentation.md +161 -0
  35. data/docs/telemetry/collector.md +297 -0
  36. data/docs/telemetry/index.md +152 -0
  37. data/docs/telemetry/pipelines.md +98 -0
  38. data/docs/telemetry/review-apps.md +55 -0
  39. data/docs/telemetry/troubleshooting.md +92 -0
  40. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  41. data/docs/terraform/overview.md +11 -0
  42. data/docs/tips.md +475 -28
  43. data/examples/controlplane.yml +2 -0
  44. data/lib/command/ai_github_flow_prompt.rb +2 -2
  45. data/lib/command/apply_template.rb +104 -2
  46. data/lib/command/base.rb +69 -5
  47. data/lib/command/deploy_image.rb +93 -7
  48. data/lib/command/promote_app_from_upstream.rb +1 -0
  49. data/lib/command/ps_wait.rb +2 -10
  50. data/lib/command/run.rb +133 -10
  51. data/lib/command/setup_app.rb +10 -5
  52. data/lib/core/config.rb +94 -0
  53. data/lib/core/controlplane.rb +38 -5
  54. data/lib/core/controlplane_api.rb +8 -0
  55. data/lib/core/controlplane_api_direct.rb +257 -63
  56. data/lib/core/doctor_service.rb +44 -3
  57. data/lib/core/shell.rb +9 -2
  58. data/lib/core/template_parser.rb +43 -9
  59. data/lib/cpflow/version.rb +1 -1
  60. data/lib/generator_templates/controlplane.yml +1 -2
  61. data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
  62. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  64. metadata +22 -2
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class DoctorService
3
+ class DoctorService # rubocop:disable Metrics/ClassLength
4
4
  class ValidationError < StandardError; end
5
5
 
6
6
  extend Forwardable
@@ -36,12 +36,12 @@ class DoctorService
36
36
 
37
37
  def validate_config
38
38
  check_for_app_names_contained_in_others
39
+ check_deploy_orders
39
40
  end
40
41
 
41
42
  def validate_templates
42
43
  @template_parser = TemplateParser.new(@command)
43
- filenames = Dir.glob("#{@template_parser.template_dir}/*.yml")
44
- templates = @template_parser.parse(filenames)
44
+ templates = @template_parser.parse(template_filenames)
45
45
 
46
46
  check_for_duplicate_templates(templates)
47
47
  warn_deprecated_template_variables
@@ -60,6 +60,12 @@ class DoctorService
60
60
  raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
61
61
  end
62
62
 
63
+ def check_deploy_orders
64
+ config.validate_deploy_orders!
65
+ rescue RuntimeError => e
66
+ raise ValidationError, Shell.color("ERROR: #{e.message}", :red)
67
+ end
68
+
63
69
  def find_app_names_contained_in_others # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
64
70
  app_names = config.apps.keys.map(&:to_s).sort
65
71
  app_prefixes = config.apps
@@ -88,6 +94,41 @@ class DoctorService
88
94
  raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
89
95
  end
90
96
 
97
+ def template_filenames
98
+ return existing_arg_template_filenames if config.args.any?
99
+
100
+ message = "ERROR: Can't find current config, please specify an app."
101
+ raise ValidationError, Shell.color(message, :red) if config.current.nil?
102
+
103
+ template_names = config.current[:setup_app_templates]
104
+ # Fall back to every template in the directory when setup_app_templates is unconfigured.
105
+ return Dir.glob("#{@template_parser.template_dir}/*.yml") if template_names.nil? || template_names.empty?
106
+
107
+ # When setup_app_templates is configured, validate only that selected subset,
108
+ # including the deprecation scan.
109
+ resolve_template_filenames(template_names)
110
+ end
111
+
112
+ def existing_arg_template_filenames = resolve_template_filenames(config.args)
113
+
114
+ def resolve_template_filenames(template_names)
115
+ unique_template_names = template_names.uniq
116
+ filenames = unique_template_names.map { |name| @template_parser.template_filename(name) }
117
+ ensure_templates_exist!(unique_template_names, filenames)
118
+ filenames
119
+ end
120
+
121
+ def ensure_templates_exist!(template_names, filenames)
122
+ missing_templates = template_names.zip(filenames).reject { |_, filename| File.exist?(filename) }
123
+ return if missing_templates.empty?
124
+
125
+ missing_templates_str = missing_templates.map do |name, filename|
126
+ " - #{name} (#{filename})"
127
+ end.join("\n")
128
+ message = "#{Shell.color('Missing templates:', :red)}\n#{missing_templates_str}"
129
+ raise ValidationError, message
130
+ end
131
+
91
132
  def warn_deprecated_template_variables
92
133
  deprecated_variables = @template_parser.deprecated_variables
93
134
  return if deprecated_variables.empty?
data/lib/core/shell.rb CHANGED
@@ -65,15 +65,22 @@ class Shell
65
65
  tmp_stderr && !verbose
66
66
  end
67
67
 
68
- def self.cmd(*cmd_to_run, capture_stderr: false)
69
- output, status = capture_stderr ? Open3.capture2e(*cmd_to_run) : Open3.capture2(*cmd_to_run)
68
+ def self.cmd(*cmd_to_run, capture_stderr: false, separate_stderr: false)
69
+ return cmd_with_separate_stderr(*cmd_to_run) if separate_stderr
70
70
 
71
+ output, status = capture_stderr ? Open3.capture2e(*cmd_to_run) : Open3.capture2(*cmd_to_run)
71
72
  {
72
73
  output: output,
73
74
  success: status.success?
74
75
  }
75
76
  end
76
77
 
78
+ def self.cmd_with_separate_stderr(*cmd_to_run)
79
+ output, error_output, status = Open3.capture3(*cmd_to_run)
80
+ { output: output, error_output: error_output, success: status.success? }
81
+ end
82
+ private_class_method :cmd_with_separate_stderr
83
+
77
84
  #
78
85
  # Hide sensitive data based on the passed pattern
79
86
  #
@@ -37,38 +37,72 @@ class TemplateParser
37
37
  private
38
38
 
39
39
  def replace_variables(yaml_file) # rubocop:disable Metrics/MethodLength
40
+ original_yaml_file = yaml_file
41
+ yaml_file = replace_legacy_variables(yaml_file)
42
+
40
43
  yaml_file = yaml_file
41
44
  .gsub("{{APP_ORG}}", config.org)
42
45
  .gsub("{{APP_NAME}}", config.app)
43
46
  .gsub("{{APP_LOCATION}}", config.location)
44
47
  .gsub("{{APP_LOCATION_LINK}}", config.location_link)
45
- .gsub("{{APP_IMAGE}}", cp.latest_image)
46
- .gsub("{{APP_IMAGE_LINK}}", config.image_link(cp.latest_image))
47
48
  .gsub("{{APP_IDENTITY}}", config.identity)
48
49
  .gsub("{{APP_IDENTITY_LINK}}", config.identity_link)
49
50
  .gsub("{{APP_SECRETS}}", config.secrets)
50
51
  .gsub("{{APP_SECRETS_POLICY}}", config.secrets_policy)
52
+ yaml_file = replace_image_variables(yaml_file)
51
53
 
52
54
  config.shared_secret_placeholders.each do |placeholder, secret_name|
53
55
  yaml_file = yaml_file.gsub(placeholder, secret_name)
54
56
  end
55
57
 
56
- find_deprecated_variables(yaml_file)
58
+ find_deprecated_variables(original_yaml_file)
59
+ yaml_file
60
+ end
61
+
62
+ def replace_image_variables(yaml_file)
63
+ has_image = yaml_file.include?("{{APP_IMAGE}}")
64
+ has_image_link = yaml_file.include?("{{APP_IMAGE_LINK}}")
65
+ return yaml_file unless has_image || has_image_link
57
66
 
58
- # Kept for backwards compatibility
67
+ yaml_file = yaml_file.gsub("{{APP_IMAGE}}", latest_image) if has_image
68
+ yaml_file = yaml_file.gsub("{{APP_IMAGE_LINK}}", config.image_link(latest_image)) if has_image_link
59
69
  yaml_file
60
- .gsub("APP_ORG", config.org)
61
- .gsub("APP_GVC", config.app)
62
- .gsub("APP_LOCATION", config.location)
63
- .gsub("APP_IMAGE", cp.latest_image)
70
+ end
71
+
72
+ # Kept for backwards compatibility.
73
+ def replace_legacy_variables(yaml_file)
74
+ yaml_file
75
+ .gsub(deprecated_variable_pattern("APP_ORG"), config.org)
76
+ .gsub(deprecated_variable_pattern("APP_GVC"), config.app)
77
+ .gsub(deprecated_variable_pattern("APP_LOCATION"), config.location)
78
+ .then { |updated_yaml| replace_legacy_image_variable(updated_yaml) }
79
+ end
80
+
81
+ def replace_legacy_image_variable(yaml_file)
82
+ return yaml_file unless deprecated_variable_used?(yaml_file, "APP_IMAGE")
83
+
84
+ yaml_file.gsub(deprecated_variable_pattern("APP_IMAGE"), latest_image)
85
+ end
86
+
87
+ def latest_image
88
+ # Share one image value across modern and legacy image replacements in this parser instance.
89
+ @latest_image ||= cp.latest_image
64
90
  end
65
91
 
66
92
  def find_deprecated_variables(yaml_file)
67
93
  new_variables.each do |old_key, new_key|
68
- @deprecated_variables[old_key] = new_key if yaml_file.include?(old_key)
94
+ @deprecated_variables[old_key] = new_key if deprecated_variable_used?(yaml_file, old_key)
69
95
  end
70
96
  end
71
97
 
98
+ def deprecated_variable_used?(yaml_file, old_key)
99
+ yaml_file.match?(deprecated_variable_pattern(old_key))
100
+ end
101
+
102
+ def deprecated_variable_pattern(old_key)
103
+ /(?<!\{\{)\b#{Regexp.escape(old_key)}\b(?!\}\})/
104
+ end
105
+
72
106
  def new_variables
73
107
  {
74
108
  "APP_ORG" => "{{APP_ORG}}",
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.1.1"
4
+ VERSION = "5.3.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -48,10 +48,9 @@ apps:
48
48
  # - name: database
49
49
  # secret_name: __APP_PREFIX__-review-database-secrets
50
50
  # policy_name: __APP_PREFIX__-review-database-secrets-policy
51
- # Uncomment to automatically initialize and tear down review-app databases:
51
+ # Uncomment to automatically initialize review-app databases:
52
52
  # hooks:
53
53
  # post_creation: bundle exec rails db:prepare
54
- # pre_deletion: bundle exec rails db:drop
55
54
 
56
55
  __APP_PREFIX__-production:
57
56
  <<: *production
@@ -1,10 +1,9 @@
1
- # Review App Commands
1
+ # Review App Help
2
2
 
3
+ You asked for review app help.
3
4
  These commands are generated by [cpflow](https://github.com/shakacode/control-plane-flow).
4
- For full setup, version-pinning, and troubleshooting details, see the upstream
5
- [CI automation guide](https://github.com/shakacode/control-plane-flow/blob/__CPFLOW_GITHUB_ACTIONS_REF__/docs/ci-automation.md).
6
5
 
7
- ## Pull Request Commands
6
+ ## Review App Commands
8
7
 
9
8
  Comment with exactly one command, with no surrounding text or trailing spaces.
10
9
  A single trailing newline from GitHub's comment editor is accepted.
@@ -15,13 +14,19 @@ A single trailing newline from GitHub's comment editor is accepted.
15
14
  | `+review-app-delete` | Deletes the review app. This also runs automatically when the PR closes. |
16
15
  | `+review-app-help` | Posts this help message on the PR. |
17
16
 
18
- ## Standard Setup
17
+ For complete setup, version-pinning, and troubleshooting guidance, see the upstream
18
+ [CI automation guide](https://github.com/shakacode/control-plane-flow/blob/__CPFLOW_GITHUB_ACTIONS_REF__/docs/ci-automation.md).
19
+
20
+ <details>
21
+ <summary>GitHub Actions setup options</summary>
19
22
 
20
- For the normal generated review-app path, GitHub needs one repository secret:
23
+ ## GitHub Actions Secrets
24
+
25
+ For the normal generated review-app path, GitHub Actions needs one secret:
21
26
 
22
27
  | Name | Where | Notes |
23
28
  | --- | --- | --- |
24
- | `CPLN_TOKEN_STAGING` | Repository secret | Control Plane service-account token for the staging/review org. |
29
+ | `CPLN_TOKEN_STAGING` | GitHub Actions secret | Service-account token scoped to the staging Control Plane org on controlplane.com. |
25
30
 
26
31
  For public repositories, use a staging/review token that cannot access
27
32
  production Control Plane resources. Generated review-app deploys skip fork PR
@@ -29,7 +34,9 @@ heads because Docker builds use repository secrets. If a forked change needs a
29
34
  review app, first move the reviewed change to a trusted branch in this
30
35
  repository.
31
36
 
32
- No repository variables are required for the standard review-app path when
37
+ ## GitHub Actions Variables
38
+
39
+ No GitHub Actions variables are required for the standard review-app path when
33
40
  `.controlplane/controlplane.yml` has exactly one review app entry with
34
41
  `match_if_app_name_starts_with: true`. cpflow infers the review-app prefix and
35
42
  staging org from that config.
@@ -40,14 +47,26 @@ review-app secret dictionaries limited to disposable databases, review-only
40
47
  renderer credentials, and license values that are acceptable for review-app
41
48
  exposure.
42
49
 
50
+ For public demos, starter staging apps, and long-lived review apps, keep the app
51
+ workload `type: standard` with one warm replica, set its autoscaling metric to
52
+ `disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and
53
+ memory allocation at that fixed replica count. Shared Postgres and other
54
+ stateful workloads are the usual exceptions and should stay manually sized;
55
+ Capacity AI is for supported stateless app/service workloads. If true idle
56
+ scale-to-zero is explicitly required, create a separate `serverless` workload
57
+ before the first deploy or plan a delete/recreate migration because Control
58
+ Plane will not change an existing `standard` workload to `serverless` in place.
59
+
43
60
  Optional overrides exist for forks, clones, and unusual apps:
44
61
 
45
62
  | Name | Notes |
46
63
  | --- | --- |
47
- | `CPLN_ORG_STAGING` | Override the staging/review Control Plane org inferred from `controlplane.yml`. |
64
+ | `CPLN_ORG_STAGING` | Control Plane org on controlplane.com for staging and review apps. Overrides the org inferred from `controlplane.yml`. |
48
65
  | `REVIEW_APP_PREFIX` | Override the review-app prefix inferred from `controlplane.yml`. |
49
66
  | `PRIMARY_WORKLOAD` | Public workload used for review URLs and health checks; defaults to `rails`. |
50
67
 
68
+ </details>
69
+
51
70
  ## Staging And Production
52
71
 
53
72
  Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`.
@@ -108,6 +127,9 @@ gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true
108
127
  Before the first promotion, bootstrap the production app the same way in the
109
128
  production org, using production-only secrets and values.
110
129
 
130
+ <details>
131
+ <summary>Version locking and advanced options</summary>
132
+
111
133
  ## Version Locking
112
134
 
113
135
  Generated wrappers pin Control Plane Flow with a release tag, for example
@@ -147,7 +169,7 @@ bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha>
147
169
  bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow
148
170
  ```
149
171
 
150
- ## Advanced Variables
172
+ ## Advanced GitHub Actions Variables
151
173
 
152
174
  Most apps do not need these:
153
175
 
@@ -166,3 +188,5 @@ that copy the workflow before configuring Control Plane can remove
166
188
  `.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the
167
189
  wrapper-level `if:` guard shown in that file, for example
168
190
  `vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''`.
191
+
192
+ </details>
@@ -1,5 +1,7 @@
1
1
  name: Delete Review App
2
2
 
3
+ run-name: "Delete Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}"
4
+
3
5
  on:
4
6
  pull_request_target:
5
7
  types: [closed]
@@ -11,9 +13,15 @@ on:
11
13
  description: Pull request number targeted for deletion
12
14
  required: true
13
15
  type: number
16
+ reconcile_intent_run_id:
17
+ description: Authenticated internal handoff; manual values are rejected
18
+ required: false
19
+ type: string
14
20
 
15
21
  permissions:
22
+ actions: write
16
23
  contents: read
24
+ deployments: write
17
25
  issues: write
18
26
  pull-requests: write
19
27
 
@@ -22,6 +30,8 @@ jobs:
22
30
  # pull_request_target is intentional: fork PR-close events need access to
23
31
  # staging secrets to delete review apps and update PR comments. The upstream
24
32
  # reusable workflow checks out trusted base-branch action code, not fork code.
33
+ # author_association is a cheap caller-side cost filter. The reusable workflow
34
+ # still checks the commenter's current repository permission before privileged work.
25
35
  if: |
26
36
  (github.event_name == 'issue_comment' &&
27
37
  github.event.issue.pull_request &&
@@ -13,15 +13,24 @@ on:
13
13
  description: Pull request number to deploy
14
14
  required: true
15
15
  type: number
16
+ reconcile_intent_run_id:
17
+ description: Authenticated internal handoff; manual values are rejected
18
+ required: false
19
+ type: string
16
20
 
17
21
  permissions:
22
+ actions: write
18
23
  contents: read
19
24
  deployments: write
20
25
  issues: write
21
26
  pull-requests: write
22
27
 
23
28
  jobs:
29
+ # The reusable job exposes `image_built`; downstream jobs can read
30
+ # `needs.deploy.outputs.image_built`. A value of `false` means this check did not validate the Docker image.
24
31
  deploy:
32
+ # author_association is a cheap caller-side cost filter. The reusable workflow
33
+ # still checks the commenter's current repository permission before privileged work.
25
34
  if: |
26
35
  (github.event_name == 'pull_request' &&
27
36
  github.event.pull_request.head.repo.full_name == github.repository) ||
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cpflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.1
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -75,7 +75,16 @@ executables:
75
75
  extensions: []
76
76
  extra_rdoc_files: []
77
77
  files:
78
+ - ".agents/agent-workflow.yml"
79
+ - ".agents/bin/README.md"
80
+ - ".agents/bin/docs"
81
+ - ".agents/bin/lint"
82
+ - ".agents/bin/setup"
83
+ - ".agents/bin/test"
84
+ - ".agents/bin/validate"
85
+ - ".agents/trusted-github-actors.yml"
78
86
  - ".agents/workflows/address-review.md"
87
+ - ".agents/workflows/ai-rollout-e2e-test.md"
79
88
  - ".claude/commands/address-review.md"
80
89
  - ".claude/commands/update-changelog.md"
81
90
  - ".github/actions/cpflow-build-docker-image/action.yml"
@@ -86,6 +95,7 @@ files:
86
95
  - ".github/actions/cpflow-setup-environment/action.yml"
87
96
  - ".github/actions/cpflow-validate-config/action.yml"
88
97
  - ".github/actions/cpflow-wait-for-health/action.yml"
98
+ - ".github/pull_request_template.md"
89
99
  - ".github/workflows/check_cpln_links.yml"
90
100
  - ".github/workflows/claude-code-review.yml"
91
101
  - ".github/workflows/claude.yml"
@@ -106,7 +116,9 @@ files:
106
116
  - ".overcommit.yml"
107
117
  - ".rubocop.yml"
108
118
  - ".simplecov_spawn.rb"
119
+ - AGENTS.md
109
120
  - CHANGELOG.md
121
+ - CLAUDE.md
110
122
  - COMM-LICENSE.txt
111
123
  - CONTRIBUTING.md
112
124
  - Gemfile
@@ -137,12 +149,20 @@ files:
137
149
  - docs/ci-automation.md
138
150
  - docs/commands.md
139
151
  - docs/dns.md
152
+ - docs/grafana-opentelemetry.md
140
153
  - docs/migrating-heroku-to-control-plane.md
141
154
  - docs/postgres.md
142
155
  - docs/rds-private-networking.md
143
156
  - docs/redis.md
144
157
  - docs/releasing.md
145
158
  - docs/secrets-and-env-values.md
159
+ - docs/sidebars.ts
160
+ - docs/telemetry/application-instrumentation.md
161
+ - docs/telemetry/collector.md
162
+ - docs/telemetry/index.md
163
+ - docs/telemetry/pipelines.md
164
+ - docs/telemetry/review-apps.md
165
+ - docs/telemetry/troubleshooting.md
146
166
  - docs/terraform/details.md
147
167
  - docs/terraform/example/.controlplane/controlplane.yml
148
168
  - docs/terraform/example/.controlplane/templates/app.yml
@@ -284,7 +304,7 @@ licenses:
284
304
  metadata:
285
305
  rubygems_mfa_required: 'true'
286
306
  post_install_message: |
287
- cpflow 5.1.1 installed.
307
+ cpflow 5.3.0 installed.
288
308
 
289
309
  If this repository already uses generated cpflow GitHub Actions, update the
290
310
  checked-in wrappers so GitHub loads the matching control-plane-flow release tag: