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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +13 -2
- data/.agents/bin/README.md +4 -3
- data/.github/actions/cpflow-setup-environment/action.yml +1 -1
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/claude.yml +92 -1
- data/.github/workflows/cpflow-delete-review-app.yml +621 -33
- data/.github/workflows/cpflow-deploy-review-app.yml +640 -20
- data/.github/workflows/cpflow-review-app-help.yml +5 -13
- data/AGENTS.md +6 -49
- data/CHANGELOG.md +33 -1
- data/CONTRIBUTING.md +9 -1
- data/Gemfile.lock +1 -1
- data/docs/ci-automation.md +105 -40
- data/docs/commands.md +19 -2
- data/docs/secrets-and-env-values.md +8 -0
- data/docs/tips.md +18 -0
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +52 -3
- data/lib/command/deploy_image.rb +16 -2
- data/lib/command/run.rb +133 -10
- data/lib/command/setup_app.rb +10 -5
- data/lib/core/controlplane.rb +38 -5
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/shell.rb +9 -2
- data/lib/cpflow/version.rb +1 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +24 -10
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
- metadata +3 -2
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
96
|
+
def reset
|
|
97
|
+
@mutex.synchronize { @data = nil }
|
|
98
|
+
end
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
request.body = body.to_json if body
|
|
100
|
+
private
|
|
68
101
|
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
http.set_debug_output(RedactedDebugOutput.new) if trace
|
|
106
|
+
validate!(token: fetch_profile_token, comes_from_profile: true)
|
|
107
|
+
end
|
|
74
108
|
|
|
75
|
-
|
|
109
|
+
def refresh!
|
|
110
|
+
@data = validate!(token: fetch_profile_token, comes_from_profile: true)
|
|
111
|
+
end
|
|
76
112
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
272
|
+
def self.parse_org(url)
|
|
273
|
+
url.match(%r{^/org/([^/]+)})&.[](1)
|
|
129
274
|
end
|
|
130
275
|
|
|
131
|
-
|
|
132
|
-
def should_refresh_api_token?
|
|
133
|
-
return false unless api_token[:comes_from_profile]
|
|
276
|
+
private
|
|
134
277
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
287
|
+
nil
|
|
288
|
+
rescue *Retrier::TRANSIENT_NETWORK_ERRORS
|
|
289
|
+
raise unless request_policy.retry_transient && retrier.retry_exception?(idempotent, request_sent)
|
|
139
290
|
|
|
140
|
-
|
|
141
|
-
rescue JWT::DecodeError
|
|
142
|
-
false
|
|
291
|
+
nil
|
|
143
292
|
end
|
|
144
293
|
|
|
145
|
-
def
|
|
146
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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
|
#
|
data/lib/cpflow/version.rb
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
# Review App
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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
|
-
|
|
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` |
|
|
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.
|
|
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.
|
|
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:
|