cpflow 5.2.0 → 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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ControlplaneApiDirect
3
+ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
4
4
  class RedactedDebugOutput
5
5
  SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
6
6
  HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
@@ -40,6 +40,9 @@ class ControlplaneApiDirect
40
40
  end
41
41
  end
42
42
 
43
+ # Raised when fetching the API token via `cpln profile token` fails.
44
+ class TokenRefreshError < StandardError; end
45
+
43
46
  API_METHODS = {
44
47
  get: Net::HTTP::Get,
45
48
  patch: Net::HTTP::Patch,
@@ -51,40 +54,200 @@ class ControlplaneApiDirect
51
54
 
52
55
  API_TOKEN_EXPIRY_SECONDS = 300
53
56
 
54
- class << self
55
- attr_accessor :trace
57
+ # GET alone retries 5xx responses or transport failures after the request may
58
+ # have reached the server. Every method may retry an explicit 429 response.
59
+ # DELETE stays out of this set because after other errors the client cannot
60
+ # know whether deletion applied server-side.
61
+ IDEMPOTENT_METHODS = %i[get].freeze
62
+
63
+ # Bounded so a retried connect failure fits within the retry deadline.
64
+ OPEN_TIMEOUT_SECONDS = 10
65
+ BEST_EFFORT_TIMEOUT_SECONDS = 5
66
+ RequestPolicy = Struct.new(:sensitive, :retry_transient, :timeout, keyword_init: true) do
67
+ def initialize(sensitive:, retry_transient:, timeout:)
68
+ super
69
+ freeze
70
+ end
56
71
  end
72
+ DEFAULT_REQUEST_POLICY = RequestPolicy.new(sensitive: false, retry_transient: true, timeout: nil)
73
+ BEST_EFFORT_SENSITIVE_REQUEST_POLICY = RequestPolicy.new(
74
+ sensitive: true,
75
+ retry_transient: false,
76
+ timeout: BEST_EFFORT_TIMEOUT_SECONDS
77
+ )
78
+
79
+ # Thread-safe API token cache. A single instance is shared process-wide by
80
+ # default (see `default_token_provider`) so each `ControlplaneApiDirect.new`
81
+ # per API call reuses the cached token; tests inject a fresh instance.
82
+ class ApiToken
83
+ def initialize
84
+ @mutex = Mutex.new
85
+ @data = nil
86
+ end
57
87
 
58
- def call(url, method:, host: :api, body: nil) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
59
- trace = ControlplaneApiDirect.trace
60
- uri = URI("#{api_host(host)}#{url}")
61
- request = API_METHODS[method].new(uri)
62
- request["Content-Type"] = "application/json"
88
+ def fetch
89
+ @mutex.synchronize do
90
+ @data = load_token if @data.nil?
91
+ refresh! if expiring_soon?
92
+ @data
93
+ end
94
+ end
63
95
 
64
- refresh_api_token if should_refresh_api_token?
96
+ def reset
97
+ @mutex.synchronize { @data = nil }
98
+ end
65
99
 
66
- request["Authorization"] = authorization_header
67
- request.body = body.to_json if body
100
+ private
68
101
 
69
- Shell.debug(method.upcase, "#{uri} #{body&.to_json}")
102
+ def load_token
103
+ token = ENV.fetch("CPLN_TOKEN", nil)
104
+ return validate!(token: token, comes_from_profile: false) if token
70
105
 
71
- http = Net::HTTP.new(uri.hostname, uri.port)
72
- http.use_ssl = uri.scheme == "https"
73
- http.set_debug_output(RedactedDebugOutput.new) if trace
106
+ validate!(token: fetch_profile_token, comes_from_profile: true)
107
+ end
74
108
 
75
- response = http.start { |ht| ht.request(request) }
109
+ def refresh!
110
+ @data = validate!(token: fetch_profile_token, comes_from_profile: true)
111
+ end
76
112
 
77
- case response
78
- when Net::HTTPOK
79
- JSON.parse(response.body)
80
- when Net::HTTPAccepted
113
+ def fetch_profile_token
114
+ result = Shell.cmd("cpln", "profile", "token")
115
+ unless result[:success]
116
+ raise TokenRefreshError,
117
+ "Failed to fetch the API token via 'cpln profile token'. " \
118
+ "Please re-run 'cpln profile login' or set the CPLN_TOKEN env variable."
119
+ end
120
+
121
+ result[:output].chomp
122
+ end
123
+
124
+ def validate!(data)
125
+ token = data[:token]
126
+ # Allow any token that does not contain line breaks. Scoped service-account
127
+ # tokens include punctuation such as '/', '+', ':', and '=', so format
128
+ # validation is deferred to the Control Plane API.
129
+ return data if token && !token.empty? && !token.match?(/[\r\n]/)
130
+
131
+ raise "Unknown API token format. " \
132
+ "Please re-run 'cpln profile login' or set the correct CPLN_TOKEN env variable."
133
+ end
134
+
135
+ # Returns `true` when a profile-sourced JWT expires within 5 minutes.
136
+ def expiring_soon?
137
+ return false unless @data[:comes_from_profile]
138
+
139
+ payload, = JWT.decode(@data[:token], nil, false, algorithms: [])
140
+ return false unless payload.is_a?(Hash) && payload["exp"]
141
+
142
+ payload["exp"].to_i - Time.now.to_i <= API_TOKEN_EXPIRY_SECONDS
143
+ rescue JWT::DecodeError
144
+ false
145
+ end
146
+ end
147
+
148
+ # Retry policy for transient failures: tracks attempts, enforces the attempt
149
+ # and elapsed-time caps, and sleeps between tries (capped `Retry-After` when
150
+ # the server provides one, exponential backoff with jitter otherwise).
151
+ # The deadline is checked only before starting a new attempt: no new attempt
152
+ # begins after `MAX_TOTAL_RETRY_SECONDS`, but an in-flight attempt may run
153
+ # past it (bounded by the per-attempt open/read timeouts).
154
+ class Retrier
155
+ MAX_ATTEMPTS = 3
156
+ MAX_TOTAL_RETRY_SECONDS = 120
157
+ BASE_RETRY_DELAY_SECONDS = 0.5
158
+ MAX_RETRY_DELAY_SECONDS = 10
159
+ TRANSIENT_NETWORK_ERRORS = [
160
+ Net::OpenTimeout, Net::ReadTimeout,
161
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE, Errno::ETIMEDOUT,
162
+ EOFError, OpenSSL::SSL::SSLError, SocketError
163
+ ].freeze
164
+
165
+ def initialize(sleeper:)
166
+ @sleeper = sleeper
167
+ @attempts_made = 1
168
+ @deadline = Retrier.monotonic_time + MAX_TOTAL_RETRY_SECONDS
169
+ end
170
+
171
+ def self.monotonic_time
172
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ end
174
+
175
+ # Retries 429 responses for every method, but limits 5xx retries to
176
+ # idempotent requests.
177
+ def retry_response?(response, idempotent)
178
+ retriable = response.is_a?(Net::HTTPTooManyRequests) ||
179
+ (idempotent && response.is_a?(Net::HTTPServerError))
180
+ return false unless retriable
181
+
182
+ retry?(retry_after: response["Retry-After"])
183
+ end
184
+
185
+ # Whether to retry a transient network error. Non-idempotent requests only
186
+ # retry when the failure happened before the request may have hit the wire.
187
+ def retry_exception?(idempotent, request_sent)
188
+ return false if request_sent && !idempotent
189
+
190
+ retry?
191
+ end
192
+
193
+ private
194
+
195
+ # When another attempt is allowed, sleeps through the delay and returns `true`.
196
+ def retry?(retry_after: nil)
197
+ return false if @attempts_made >= MAX_ATTEMPTS || Retrier.monotonic_time >= @deadline
198
+
199
+ @sleeper.call(next_delay(retry_after))
200
+ @attempts_made += 1
81
201
  true
82
- when Net::HTTPNotFound
83
- nil
84
- when Net::HTTPForbidden
85
- raise ForbiddenError.new(url: url, response: response)
86
- else
87
- raise("#{response} #{response.body}")
202
+ end
203
+
204
+ def next_delay(retry_after)
205
+ seconds = parse_retry_after(retry_after)
206
+ return seconds if seconds
207
+
208
+ backoff = [BASE_RETRY_DELAY_SECONDS * (2**(@attempts_made - 1)), MAX_RETRY_DELAY_SECONDS].min
209
+ (backoff / 2) + (rand * backoff / 2)
210
+ end
211
+
212
+ # Supports the delta-seconds form of `Retry-After`, capped so a slow or
213
+ # misconfigured server cannot stall the CLI.
214
+ def parse_retry_after(value)
215
+ return nil unless value.to_s.match?(/\A\d+\z/)
216
+
217
+ [Integer(value, 10), MAX_RETRY_DELAY_SECONDS].min
218
+ end
219
+ end
220
+
221
+ class << self
222
+ attr_accessor :trace
223
+ attr_reader :default_token_provider
224
+
225
+ # Preserved seam: `Controlplane#profile_switch` invalidates the cached
226
+ # token when the CPLN profile changes.
227
+ def reset_api_token = default_token_provider.reset
228
+ end
229
+
230
+ @default_token_provider = ApiToken.new
231
+
232
+ def initialize(token_provider: ControlplaneApiDirect.default_token_provider, sleeper: nil)
233
+ @token_provider = token_provider
234
+ @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
235
+ end
236
+
237
+ def call(url, method:, host: :api, body: nil, request_policy: DEFAULT_REQUEST_POLICY)
238
+ uri = URI("#{api_host(host)}#{url}")
239
+ # Token fetch and request construction happen outside the transient rescue
240
+ # in attempt_request so their failures (e.g. TokenRefreshError from the
241
+ # `cpln` shell-out) are never misclassified as retryable network errors.
242
+ request = build_request(uri, method, body)
243
+ retrier = Retrier.new(sleeper: @sleeper)
244
+
245
+ debug_body = request_policy.sensitive && body ? "[REDACTED]" : body&.to_json
246
+ Shell.debug(method.upcase, "#{uri} #{debug_body}")
247
+
248
+ loop do
249
+ response = attempt_request(uri, request, method, retrier, request_policy)
250
+ return handle_response(response, url, request_policy) if response
88
251
  end
89
252
  end
90
253
 
@@ -104,54 +267,85 @@ class ControlplaneApiDirect
104
267
  "Bearer #{token}"
105
268
  end
106
269
 
107
- # rubocop:disable Style/ClassVars
108
- def api_token # rubocop:disable Metrics/MethodLength
109
- return @@api_token if defined?(@@api_token)
110
-
111
- @@api_token = {
112
- token: ENV.fetch("CPLN_TOKEN", nil),
113
- comes_from_profile: false
114
- }
115
- if @@api_token[:token].nil?
116
- @@api_token = {
117
- token: Shell.cmd("cpln", "profile", "token")[:output].chomp,
118
- comes_from_profile: true
119
- }
120
- end
121
- token = @@api_token[:token]
122
- # Allow any token that does not contain line breaks. Scoped service-account
123
- # tokens include punctuation such as '/', '+', ':', and '=', so format
124
- # validation is deferred to the Control Plane API.
125
- return @@api_token if token && !token.empty? && !token.match?(/[\r\n]/)
270
+ def api_token = @token_provider.fetch
126
271
 
127
- raise "Unknown API token format. " \
128
- "Please re-run 'cpln profile login' or set the correct CPLN_TOKEN env variable."
272
+ def self.parse_org(url)
273
+ url.match(%r{^/org/([^/]+)})&.[](1)
129
274
  end
130
275
 
131
- # Returns `true` when the token is about to expire in 5 minutes
132
- def should_refresh_api_token?
133
- return false unless api_token[:comes_from_profile]
276
+ private
134
277
 
135
- payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
136
- return false unless payload.is_a?(Hash) && payload["exp"]
278
+ # Returns the response, or `nil` when the attempt failed transiently and the
279
+ # retrier approved (and already slept before) another attempt. Only the
280
+ # transport phase (connect + request) is covered by the transient rescue.
281
+ def attempt_request(uri, request, method, retrier, request_policy)
282
+ request_sent = false
283
+ idempotent = IDEMPOTENT_METHODS.include?(method)
284
+ response = transport_request(uri, request, request_policy) { request_sent = true }
285
+ return response unless request_policy.retry_transient && retrier.retry_response?(response, idempotent)
137
286
 
138
- difference_in_seconds = payload["exp"].to_i - Time.now.to_i
287
+ nil
288
+ rescue *Retrier::TRANSIENT_NETWORK_ERRORS
289
+ raise unless request_policy.retry_transient && retrier.retry_exception?(idempotent, request_sent)
139
290
 
140
- difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
141
- rescue JWT::DecodeError
142
- false
291
+ nil
143
292
  end
144
293
 
145
- def refresh_api_token
146
- @@api_token[:token] = Shell.cmd("cpln", "profile", "token")[:output].chomp
294
+ def transport_request(uri, request, request_policy)
295
+ http = build_http(uri, request_policy)
296
+ http.start
297
+ # Set request_sent before `http.request` deliberately: a reset before bytes
298
+ # are written is indistinguishable from one after a partial write, so treat
299
+ # both as sent conservatively.
300
+ yield
301
+ begin
302
+ http.request(request)
303
+ ensure
304
+ http.finish if http.started?
305
+ end
147
306
  end
148
307
 
149
- def self.reset_api_token
150
- remove_class_variable(:@@api_token) if defined?(@@api_token)
308
+ def build_request(uri, method, body)
309
+ request = API_METHODS[method].new(uri)
310
+ request["Content-Type"] = "application/json"
311
+ request["Authorization"] = authorization_header
312
+ request.body = body.to_json if body
313
+ request
151
314
  end
152
- # rubocop:enable Style/ClassVars
153
315
 
154
- def self.parse_org(url)
155
- url.match(%r{^/org/([^/]+)})&.[](1)
316
+ def build_http(uri, request_policy)
317
+ http = Net::HTTP.new(uri.hostname, uri.port)
318
+ http.use_ssl = uri.scheme == "https"
319
+ # Net::HTTP transparently re-sends requests it deems idempotent once on
320
+ # mid-flight failures; disable so the Retrier owns the entire retry policy.
321
+ http.max_retries = 0
322
+ http.open_timeout = request_policy.timeout || OPEN_TIMEOUT_SECONDS
323
+ http.read_timeout = request_policy.timeout if request_policy.timeout
324
+ http.set_debug_output(RedactedDebugOutput.new) if ControlplaneApiDirect.trace && !request_policy.sensitive
325
+ http
326
+ end
327
+
328
+ def handle_response(response, url, request_policy)
329
+ case response
330
+ when Net::HTTPOK then parse_response_body(response, request_policy)
331
+ when Net::HTTPAccepted then true
332
+ when Net::HTTPNotFound then nil
333
+ when Net::HTTPForbidden then raise(ForbiddenError.new(url: url, response: response))
334
+ else raise(response_error_message(response, request_policy))
335
+ end
336
+ end
337
+
338
+ def parse_response_body(response, request_policy)
339
+ JSON.parse(response.body)
340
+ rescue JSON::ParserError
341
+ raise unless request_policy.sensitive
342
+
343
+ raise JSON::ParserError, "Control Plane API returned invalid JSON for a sensitive request.", cause: nil
344
+ end
345
+
346
+ def response_error_message(response, request_policy)
347
+ return response.to_s if request_policy.sensitive
348
+
349
+ "#{response} #{response.body}"
156
350
  end
157
351
  end
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
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.2.0"
4
+ VERSION = "5.3.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -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>
22
+
23
+ ## GitHub Actions Secrets
19
24
 
20
- For the normal generated review-app path, GitHub needs one repository secret:
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.
@@ -54,10 +61,12 @@ Optional overrides exist for forks, clones, and unusual apps:
54
61
 
55
62
  | Name | Notes |
56
63
  | --- | --- |
57
- | `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`. |
58
65
  | `REVIEW_APP_PREFIX` | Override the review-app prefix inferred from `controlplane.yml`. |
59
66
  | `PRIMARY_WORKLOAD` | Public workload used for review URLs and health checks; defaults to `rails`. |
60
67
 
68
+ </details>
69
+
61
70
  ## Staging And Production
62
71
 
63
72
  Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`.
@@ -118,6 +127,9 @@ gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true
118
127
  Before the first promotion, bootstrap the production app the same way in the
119
128
  production org, using production-only secrets and values.
120
129
 
130
+ <details>
131
+ <summary>Version locking and advanced options</summary>
132
+
121
133
  ## Version Locking
122
134
 
123
135
  Generated wrappers pin Control Plane Flow with a release tag, for example
@@ -157,7 +169,7 @@ bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha>
157
169
  bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow
158
170
  ```
159
171
 
160
- ## Advanced Variables
172
+ ## Advanced GitHub Actions Variables
161
173
 
162
174
  Most apps do not need these:
163
175
 
@@ -176,3 +188,5 @@ that copy the workflow before configuring Control Plane can remove
176
188
  `.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the
177
189
  wrapper-level `if:` guard shown in that file, for example
178
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.2.0
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -95,6 +95,7 @@ files:
95
95
  - ".github/actions/cpflow-setup-environment/action.yml"
96
96
  - ".github/actions/cpflow-validate-config/action.yml"
97
97
  - ".github/actions/cpflow-wait-for-health/action.yml"
98
+ - ".github/pull_request_template.md"
98
99
  - ".github/workflows/check_cpln_links.yml"
99
100
  - ".github/workflows/claude-code-review.yml"
100
101
  - ".github/workflows/claude.yml"
@@ -303,7 +304,7 @@ licenses:
303
304
  metadata:
304
305
  rubygems_mfa_required: 'true'
305
306
  post_install_message: |
306
- cpflow 5.2.0 installed.
307
+ cpflow 5.3.0 installed.
307
308
 
308
309
  If this repository already uses generated cpflow GitHub Actions, update the
309
310
  checked-in wrappers so GitHub loads the matching control-plane-flow release tag: