fastlane-plugin-wpmreleasetoolkit 14.6.0 → 14.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 787be90be7c62894c1cbee1a45d28d7815991f7158a654703f31c8db0d541f61
4
- data.tar.gz: 8f6312bdc9f3ecd5f9fbc17a78d22b91483cd7ee7d4e4fa511b9bb367a26c08c
3
+ metadata.gz: 614ca9504d0baa99cc411f83e7bb94f179260a0e8154d738aecfed47a4de7ce5
4
+ data.tar.gz: 7edfec9cb051abe68182905d8ee0620c73a0da73c02e829e7dcb69f1593ea49e
5
5
  SHA512:
6
- metadata.gz: f23b5d15c71550aa20af5536f2f6517a780f4cc2a0a286e21316a431e803bcd29e66dd2e93432d1998ebb922ff72b80f0fbeaabcbf5472fb1dbf3dd600900134
7
- data.tar.gz: a69a5537a36109cdb5442c3a0c302ccc0bc8702c6380c6ac68c4c9a7beb95d0777996f53505de54f4142a4fd1f9cd6e1637d726c917a751f43529d9427825fe9
6
+ metadata.gz: 32d6b0a90838debf53ba8fc6c5fbdf729a5159a12c0b69ffa8be1709cca3be571d28e1471d2736bb911c97f67d6720b2aec2320bbcd221362a873b14d597ecf0
7
+ data.tar.gz: d9170171309d3b0f11cf102757bd4bdcdc380ef207f7b06d17e2f03e4aef17094c77bf84e1eba17de43cf2e29f833b9a8de4277dab853118a26ba6fa23e391d2
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fastlane/action'
4
+ require_relative '../../helper/github_helper'
5
+
6
+ module Fastlane
7
+ module Actions
8
+ class FindOrCreatePullRequestAction < Action
9
+ def self.run(params)
10
+ github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token])
11
+
12
+ existing_pr = github_helper.find_pull_request(
13
+ repository: params[:repository],
14
+ head: params[:head],
15
+ base: params[:base]
16
+ )
17
+
18
+ unless existing_pr.nil?
19
+ UI.message("An open Pull Request already exists for `#{params[:head]}`: #{existing_pr.html_url}")
20
+ return existing_pr.html_url
21
+ end
22
+
23
+ other_action.create_pull_request(
24
+ api_url: params[:api_url],
25
+ api_token: params[:github_token],
26
+ repo: params[:repository],
27
+ title: params[:title],
28
+ body: params[:body],
29
+ draft: params[:draft],
30
+ head: params[:head],
31
+ base: params[:base],
32
+ labels: params[:labels],
33
+ assignees: params[:assignees],
34
+ reviewers: params[:reviewers],
35
+ team_reviewers: params[:team_reviewers],
36
+ milestone: params[:milestone]
37
+ )
38
+ end
39
+
40
+ def self.description
41
+ 'Returns the URL of the open Pull Request for a head branch, creating one if none exists yet'
42
+ end
43
+
44
+ def self.details
45
+ <<~DETAILS
46
+ Looks for an open Pull Request whose head is the given branch and which targets the given base,
47
+ and returns its URL if found. Otherwise, creates a new Pull Request and returns its URL.
48
+
49
+ This is useful for "rolling" automations (e.g. a daily translations or dependency-update job) that
50
+ force-push the same head branch on every run: GitHub automatically refreshes the diff of the existing
51
+ PR, so this action only needs to open a PR the first time.
52
+ DETAILS
53
+ end
54
+
55
+ def self.authors
56
+ ['Automattic']
57
+ end
58
+
59
+ def self.return_type
60
+ :string
61
+ end
62
+
63
+ def self.return_value
64
+ 'The URL of the existing or newly-created Pull Request'
65
+ end
66
+
67
+ def self.available_options
68
+ # Parameters we forward as-is from Fastlane's `create_pull_request` action
69
+ forwarded_param_keys = %i[
70
+ api_url
71
+ draft
72
+ labels
73
+ assignees
74
+ reviewers
75
+ team_reviewers
76
+ milestone
77
+ ].freeze
78
+
79
+ forwarded_params = Fastlane::Actions::CreatePullRequestAction.available_options.select do |opt|
80
+ forwarded_param_keys.include?(opt.key)
81
+ end
82
+
83
+ [
84
+ *forwarded_params,
85
+ Fastlane::Helper::GithubHelper.github_token_config_item, # forwarded to `api_token` in the `create_pull_request` action
86
+ FastlaneCore::ConfigItem.new(
87
+ key: :repository,
88
+ env_name: 'GHHELPER_REPOSITORY',
89
+ description: 'The remote path of the GH repository on which we work, e.g. `wordpress-mobile/wordpress-ios`',
90
+ optional: false,
91
+ type: String
92
+ ),
93
+ FastlaneCore::ConfigItem.new(
94
+ key: :title,
95
+ description: 'The title of the Pull Request to create if none exists yet',
96
+ optional: false,
97
+ type: String
98
+ ),
99
+ FastlaneCore::ConfigItem.new(
100
+ key: :body,
101
+ description: 'The body of the Pull Request to create if none exists yet',
102
+ optional: true,
103
+ type: String
104
+ ),
105
+ FastlaneCore::ConfigItem.new(
106
+ key: :head,
107
+ description: 'The head branch of the Pull Request (the branch with the changes)',
108
+ optional: false,
109
+ type: String
110
+ ),
111
+ FastlaneCore::ConfigItem.new(
112
+ key: :base,
113
+ description: 'The base branch the Pull Request targets (e.g. `trunk`)',
114
+ optional: false,
115
+ type: String
116
+ ),
117
+ ]
118
+ end
119
+
120
+ def self.is_supported?(platform)
121
+ true
122
+ end
123
+ end
124
+ end
125
+ end
@@ -8,8 +8,10 @@ module Fastlane
8
8
  module Actions
9
9
  class OpenaiAskAction < Action
10
10
  OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze
11
+ # Preserve the previous `max_tokens` ceiling while using the current API field.
12
+ DEFAULT_MAX_COMPLETION_TOKENS = 2048
11
13
  DEFAULT_MAX_TOOL_ITERATIONS = 5
12
- DEFAULT_MODEL = 'gpt-4o'
14
+ DEFAULT_MODEL = 'gpt-4.1'
13
15
 
14
16
  PREDEFINED_PROMPTS = {
15
17
  release_notes: <<~PROMPT
@@ -39,12 +41,16 @@ module Fastlane
39
41
  }
40
42
 
41
43
  # Backwards-compatible single-shot path when no tools are provided.
42
- if tools.nil? || tools.empty?
44
+ if tools.nil?
43
45
  body = request_body(prompt: prompt, question: question, model: model)
44
46
  response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
45
47
  return parse_text_response(response)
46
48
  end
47
49
 
50
+ validate_tools_array!(tools)
51
+ validate_max_tool_iterations!(max_tool_iterations)
52
+ validate_tools!(tools)
53
+
48
54
  run_with_tools(
49
55
  prompt: prompt,
50
56
  question: question,
@@ -62,7 +68,9 @@ module Fastlane
62
68
  format_message(role: 'user', text: question),
63
69
  ].compact
64
70
 
65
- max_tool_iterations.times do
71
+ tool_iterations = 0
72
+
73
+ loop do
66
74
  body = request_body_with_messages(messages: messages, tools: tools, model: model)
67
75
  response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
68
76
  assistant_message = parse_assistant_message(response)
@@ -71,26 +79,31 @@ module Fastlane
71
79
  # No tool calls — model produced a final answer.
72
80
  return assistant_message['content'] if tool_calls.nil? || tool_calls.empty?
73
81
 
82
+ if tool_iterations >= max_tool_iterations
83
+ UI.user_error!(
84
+ "OpenAI tool-use loop did not produce a final answer after #{max_tool_iterations} tool iterations. " \
85
+ 'Refusing to execute additional tool calls. Increase `max_tool_iterations` or check that your prompt instructs the model to stop calling tools.'
86
+ )
87
+ end
88
+
74
89
  # Append the assistant's tool-call message verbatim, then run each handler
75
90
  # and append the corresponding `role: tool` results.
76
91
  messages << assistant_message
77
92
  tool_calls.each do |tool_call|
78
93
  messages << execute_tool_call(tool_call, tool_handlers)
79
94
  end
80
- end
81
95
 
82
- UI.user_error!(
83
- "OpenAI tool-use loop did not terminate after #{max_tool_iterations} iterations. " \
84
- 'Increase `max_tool_iterations` or check that your prompt instructs the model to stop calling tools.'
85
- )
96
+ tool_iterations += 1
97
+ end
86
98
  end
87
99
 
88
100
  def self.request_body(prompt:, question:, model: DEFAULT_MODEL)
89
101
  {
90
102
  model: model,
103
+ store: false,
91
104
  response_format: { type: 'text' },
92
105
  temperature: 1,
93
- max_tokens: 2048,
106
+ max_completion_tokens: DEFAULT_MAX_COMPLETION_TOKENS,
94
107
  top_p: 1,
95
108
  messages: [
96
109
  format_message(role: 'system', text: prompt),
@@ -102,9 +115,10 @@ module Fastlane
102
115
  def self.request_body_with_messages(messages:, tools:, model: DEFAULT_MODEL)
103
116
  {
104
117
  model: model,
118
+ store: false,
105
119
  response_format: { type: 'text' },
106
120
  temperature: 1,
107
- max_tokens: 2048,
121
+ max_completion_tokens: DEFAULT_MAX_COMPLETION_TOKENS,
108
122
  top_p: 1,
109
123
  messages: messages,
110
124
  tools: tools
@@ -140,8 +154,49 @@ module Fastlane
140
154
  end
141
155
  end
142
156
 
157
+ def self.validate_max_tool_iterations!(max_tool_iterations)
158
+ UI.user_error!("Parameter `max_tool_iterations` must be an Integer (got #{max_tool_iterations.class})") unless max_tool_iterations.is_a?(Integer)
159
+ UI.user_error!("Parameter `max_tool_iterations` must be >= 1 (got #{max_tool_iterations})") if max_tool_iterations < 1
160
+ end
161
+
162
+ def self.validate_tools_array!(tools)
163
+ UI.user_error!('Parameter `tools` must be a non-empty Array when provided') unless tools.is_a?(Array) && !tools.empty?
164
+ end
165
+
166
+ def self.validate_tools!(tools)
167
+ invalid_tools = tools.each_with_index.filter_map do |tool, index|
168
+ type = tool_type(tool)
169
+ next "tools[#{index}] type #{type.nil? ? '<missing>' : type.inspect}" unless type == 'function'
170
+
171
+ function = tool[:function] || tool['function']
172
+ name = function[:name] || function['name'] if function.is_a?(Hash)
173
+ next if valid_tool_name?(name)
174
+
175
+ "tools[#{index}] missing function.name"
176
+ end
177
+
178
+ return if invalid_tools.empty?
179
+
180
+ UI.user_error!(
181
+ 'Parameter `tools` only supports OpenAI function tools with a non-empty `function.name`. ' \
182
+ "Invalid tool definitions: #{invalid_tools.join(', ')}"
183
+ )
184
+ end
185
+
186
+ def self.tool_type(tool)
187
+ return nil unless tool.is_a?(Hash)
188
+
189
+ (tool[:type] || tool['type'])&.to_s
190
+ end
191
+
192
+ def self.valid_tool_name?(name)
193
+ (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.empty?
194
+ end
195
+
143
196
  def self.execute_tool_call(tool_call, tool_handlers)
144
- name = tool_call.dig('function', 'name')
197
+ return unsupported_tool_call_result(tool_call) unless function_tool_call?(tool_call)
198
+
199
+ name = tool_call.dig('function', 'name').to_s
145
200
  raw_args = tool_call.dig('function', 'arguments') || '{}'
146
201
 
147
202
  result =
@@ -151,8 +206,8 @@ module Fastlane
151
206
  rescue JSON::ParserError
152
207
  # Short-circuit: the handler never sees malformed args. Tell the model the
153
208
  # tool-call payload was invalid so it can retry with valid JSON, and log the
154
- # raw arguments locally for debugging without forwarding them to the API.
155
- UI.error("Invalid JSON arguments for tool '#{name}'. Raw payload: #{raw_args}")
209
+ # local failure without recording raw arguments that might contain secrets.
210
+ UI.error("Invalid JSON arguments for tool '#{name}' in tool call '#{tool_call['id']}'. Raw payload omitted because it may contain secrets.")
156
211
  { error: "Invalid JSON arguments for tool '#{name}' — payload could not be parsed. Retry with valid JSON." }
157
212
  end
158
213
 
@@ -163,6 +218,40 @@ module Fastlane
163
218
  }
164
219
  end
165
220
 
221
+ def self.function_tool_call?(tool_call)
222
+ return false unless tool_call['type'] == 'function'
223
+ return false unless tool_call['function'].is_a?(Hash)
224
+
225
+ name = tool_call.dig('function', 'name')
226
+ valid_tool_name?(name)
227
+ end
228
+
229
+ def self.unsupported_tool_call_result(tool_call)
230
+ type = tool_call['type'] || '<missing>'
231
+ error =
232
+ if type == 'function'
233
+ 'Function tool call is missing a non-empty function.name.'
234
+ else
235
+ "Unsupported tool call type '#{type}'. Only function tool calls are supported."
236
+ end
237
+ log_message =
238
+ if type == 'function'
239
+ "Invalid OpenAI function tool call '#{tool_call['id']}': missing a non-empty function.name."
240
+ else
241
+ "Unsupported OpenAI tool call type '#{type}' in tool call '#{tool_call['id']}'. Only function tool calls are supported."
242
+ end
243
+ UI.error(log_message)
244
+
245
+ {
246
+ role: 'tool',
247
+ tool_call_id: tool_call['id'],
248
+ content: serialize_tool_result(
249
+ name: type,
250
+ result: { error: error }
251
+ )
252
+ }
253
+ end
254
+
166
255
  # Serializes a tool result to a JSON string. Handlers are contracted to return
167
256
  # JSON-serializable values, but a buggy handler might return something like a
168
257
  # `Pathname`, `Proc`, or a custom object whose `to_json` raises. Failing the
@@ -175,7 +264,7 @@ module Fastlane
175
264
  def self.serialize_tool_result(name:, result:)
176
265
  JSON.generate(result)
177
266
  rescue StandardError => e
178
- UI.error("Could not serialize tool result for '#{name}': #{e.class}: #{e.message}. Result class: #{result.class}")
267
+ UI.error("Could not serialize tool result for '#{name}': #{e.class}. Result class: #{result.class}. Error message omitted because it may contain secrets.")
179
268
  JSON.generate({ error: "Tool result for '#{name}' could not be serialized to JSON. Returned class: #{result.class}." })
180
269
  end
181
270
 
@@ -185,9 +274,9 @@ module Fastlane
185
274
  #
186
275
  # - Missing or non-callable handler: structured `{ error: ... }` so the model can recover.
187
276
  # - Handler raised: structured `{ error:, exception: }` carrying only the exception class
188
- # so the model can see the failure category and adjust. The full message and backtrace
189
- # are logged locally via `UI.error` but NOT forwarded to the model, because tool
190
- # results are sent to OpenAI and handler exception messages can contain secrets
277
+ # so the model can see the failure category and adjust. The exception message and
278
+ # backtrace are intentionally omitted from local logs and from the model response
279
+ # because tool results and CI logs can expose release secrets
191
280
  # (tokens, file contents, internal API responses). The loop keeps going rather than
192
281
  # aborting the lane mid-conversation — the model is the better judge of whether the
193
282
  # failure is recoverable than a global `rescue` here.
@@ -198,7 +287,7 @@ module Fastlane
198
287
  begin
199
288
  handler.call(args)
200
289
  rescue StandardError => e
201
- UI.error("Handler for tool '#{name}' raised #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
290
+ UI.error("Handler for tool '#{name}' raised #{e.class}. Error message and backtrace omitted because they may contain secrets.")
202
291
  { error: "Handler for tool '#{name}' raised an exception", exception: e.class.name }
203
292
  end
204
293
  end
@@ -228,7 +317,8 @@ module Fastlane
228
317
  When `tools` and `tool_handlers` are provided, the action runs a tool-use (function-calling) loop:
229
318
  on each turn, if the model calls one or more tools, the corresponding handler is invoked locally
230
319
  and its return value is sent back to the model as a `role: tool` message. The loop ends when the
231
- model returns a plain text response, or when `max_tool_iterations` is reached.
320
+ model returns a plain text response, or before executing tool calls beyond `max_tool_iterations`.
321
+ The model gets one final API turn to answer after the last permitted local tool execution round.
232
322
  DETAILS
233
323
  end
234
324
 
@@ -306,19 +396,20 @@ module Fastlane
306
396
  sensitive: true,
307
397
  type: String),
308
398
  FastlaneCore::ConfigItem.new(key: :model,
309
- description: 'The OpenAI model to send the request to (e.g. `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`). ' \
399
+ description: 'The OpenAI model to send the request to (e.g. `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`). ' \
310
400
  "Defaults to `#{DEFAULT_MODEL}`",
311
401
  optional: true,
312
402
  default_value: DEFAULT_MODEL,
313
403
  type: String),
314
404
  FastlaneCore::ConfigItem.new(key: :tools,
315
- description: 'Optional array of tool (function-calling) definitions in OpenAI format. ' \
405
+ description: 'Optional array of OpenAI function tool definitions. Each definition must have a non-empty `function.name`. ' \
316
406
  'When provided, the action runs a tool-use loop',
317
407
  optional: true,
318
408
  default_value: nil,
319
409
  type: Array,
320
410
  verify_block: proc do |value|
321
- UI.user_error!('Parameter `tools` must be a non-empty Array when provided') if value.empty?
411
+ validate_tools_array!(value)
412
+ validate_tools!(value)
322
413
  end),
323
414
  FastlaneCore::ConfigItem.new(key: :tool_handlers,
324
415
  description: 'Hash of tool name to a callable (e.g. a Proc) invoked when the model calls that tool. ' \
@@ -332,13 +423,14 @@ module Fastlane
332
423
  UI.user_error!("Parameter `tool_handlers` values must respond to :call. Non-callable handlers: #{non_callable.keys}") if non_callable.any?
333
424
  end),
334
425
  FastlaneCore::ConfigItem.new(key: :max_tool_iterations,
335
- description: 'Maximum number of tool-use loop iterations before the action fails. ' \
426
+ description: 'Maximum number of local tool execution rounds before the action fails. ' \
427
+ 'The model can receive one final API turn to answer after the last permitted tool result. ' \
336
428
  'Only used when `tools` are provided',
337
429
  optional: true,
338
430
  default_value: DEFAULT_MAX_TOOL_ITERATIONS,
339
431
  type: Integer,
340
432
  verify_block: proc do |value|
341
- UI.user_error!("Parameter `max_tool_iterations` must be >= 1 (got #{value})") if value < 1
433
+ validate_max_tool_iterations!(value)
342
434
  end),
343
435
  ]
344
436
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fastlane/action'
4
+ require 'net/http'
5
+ require 'json'
6
+ require_relative '../../helper/apps_cdn_helper'
7
+
8
+ module Fastlane
9
+ module Actions
10
+ class UpdateAppsCdnBuildMetadataAction < Action
11
+ def self.run(params)
12
+ post_ids = params[:post_ids]
13
+ UI.message("Updating Apps CDN build metadata for #{post_ids.size} post(s): #{post_ids.join(', ')}...")
14
+
15
+ # Build the JSON body for the dedicated Apps CDN builds endpoint, which accepts
16
+ # the same string-based format as the upload flow (e.g. `visibility: 'Internal'`)
17
+ body = {}
18
+ body['post_status'] = params[:post_status] if params[:post_status]
19
+ body['visibility'] = params[:visibility].to_s.capitalize if params[:visibility]
20
+
21
+ UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if body.empty?
22
+
23
+ results = post_ids.map do |post_id|
24
+ update_single_post(site_id: params[:site_id], api_token: params[:api_token], post_id: post_id, body: body)
25
+ end
26
+
27
+ UI.success("Successfully updated Apps CDN build metadata for #{results.size} post(s)")
28
+ results
29
+ end
30
+
31
+ # Update a single CDN build post with the given body via the dedicated
32
+ # `/wpcom/v2/sites/{site_id}/a8c-cdn/builds/{post_id}` endpoint.
33
+ #
34
+ # @param site_id [String] the WordPress.com site ID
35
+ # @param api_token [String] the WordPress.com API bearer token
36
+ # @param post_id [Integer] the ID of the build post to update
37
+ # @param body [Hash] the JSON body to send in the POST request
38
+ # @return [Integer] the ID of the updated post
39
+ # @raise [FastlaneCore::Interface::FastlaneError] if the API returns a non-success response
40
+ def self.update_single_post(site_id:, api_token:, post_id:, body:)
41
+ uri = Helper::AppsCdnHelper.wpcom_v2_url(site_id: site_id, path: "a8c-cdn/builds/#{post_id}")
42
+
43
+ request = Net::HTTP::Post.new(uri.request_uri)
44
+ request.body = JSON.generate(body)
45
+ request['Content-Type'] = 'application/json'
46
+ request['Accept'] = 'application/json'
47
+ request['Authorization'] = "Bearer #{api_token}"
48
+
49
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 30) do |http|
50
+ http.request(request)
51
+ end
52
+
53
+ case response
54
+ when Net::HTTPSuccess
55
+ result = JSON.parse(response.body)
56
+ updated_id = result['id']
57
+
58
+ UI.message(" Updated post #{updated_id}")
59
+
60
+ updated_id
61
+ else
62
+ UI.error("Failed to update Apps CDN build metadata for post #{post_id}: #{response.code} #{response.message}")
63
+ UI.error(response.body)
64
+ UI.user_error!("Update of Apps CDN build metadata failed for post #{post_id}")
65
+ end
66
+ end
67
+
68
+ def self.description
69
+ 'Updates metadata of one or more existing builds on the Apps CDN'
70
+ end
71
+
72
+ def self.authors
73
+ ['Automattic']
74
+ end
75
+
76
+ def self.return_value
77
+ 'Returns an Array of post IDs (Integer) that were successfully updated. On error, raises a FastlaneError.'
78
+ end
79
+
80
+ def self.details
81
+ <<~DETAILS
82
+ Updates metadata (such as post status or visibility) for one or more existing build posts on a WordPress blog
83
+ that has the Apps CDN plugin enabled, using the dedicated `/wpcom/v2/sites/{site_id}/a8c-cdn/builds/{post_id}`
84
+ endpoint. Standard WP REST API writes are blocked for builds, so this endpoint is the only way to update them.
85
+ See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin.
86
+ DETAILS
87
+ end
88
+
89
+ def self.available_options
90
+ [
91
+ FastlaneCore::ConfigItem.new(
92
+ key: :site_id,
93
+ env_name: 'APPS_CDN_SITE_ID',
94
+ description: 'The WordPress.com CDN site ID where the build was uploaded',
95
+ optional: false,
96
+ type: String,
97
+ verify_block: proc do |value|
98
+ UI.user_error!('Site ID cannot be empty') if value.to_s.empty?
99
+ end
100
+ ),
101
+ FastlaneCore::ConfigItem.new(
102
+ key: :post_ids,
103
+ description: 'The IDs of the build posts to update',
104
+ optional: false,
105
+ type: Array,
106
+ verify_block: proc do |value|
107
+ UI.user_error!('Post IDs must be a non-empty array') unless value.is_a?(Array) && !value.empty?
108
+ value.each do |id|
109
+ UI.user_error!("Each post ID must be a positive integer, got: #{id.inspect}") unless id.is_a?(Integer) && id.positive?
110
+ end
111
+ end
112
+ ),
113
+ FastlaneCore::ConfigItem.new(
114
+ key: :api_token,
115
+ env_name: 'WPCOM_API_TOKEN',
116
+ description: 'The WordPress.com API token for authentication',
117
+ optional: false,
118
+ type: String,
119
+ verify_block: proc do |value|
120
+ UI.user_error!('API token cannot be empty') if value.to_s.empty?
121
+ end
122
+ ),
123
+ FastlaneCore::ConfigItem.new(
124
+ key: :visibility,
125
+ description: 'The new visibility for the build (:internal or :external)',
126
+ optional: true,
127
+ type: Symbol,
128
+ verify_block: Helper::AppsCdnHelper.verify_visibility_param
129
+ ),
130
+ FastlaneCore::ConfigItem.new(
131
+ key: :post_status,
132
+ description: "The new post status ('publish' or 'draft')",
133
+ optional: true,
134
+ type: String,
135
+ verify_block: Helper::AppsCdnHelper.verify_post_status_param
136
+ ),
137
+ ]
138
+ end
139
+
140
+ def self.is_supported?(platform)
141
+ true
142
+ end
143
+
144
+ def self.example_code
145
+ [
146
+ 'update_apps_cdn_build_metadata(
147
+ site_id: "12345678",
148
+ api_token: ENV["WPCOM_API_TOKEN"],
149
+ post_ids: [98765],
150
+ post_status: "publish"
151
+ )',
152
+ 'update_apps_cdn_build_metadata(
153
+ site_id: "12345678",
154
+ api_token: ENV["WPCOM_API_TOKEN"],
155
+ post_ids: [12345, 67890, 11111],
156
+ visibility: :external
157
+ )',
158
+ ]
159
+ end
160
+ end
161
+ end
162
+ end
@@ -4,6 +4,7 @@ require 'fastlane/action'
4
4
  require 'net/http'
5
5
  require 'uri'
6
6
  require 'json'
7
+ require_relative '../../helper/apps_cdn_helper'
7
8
 
8
9
  module Fastlane
9
10
  module Actions
@@ -17,8 +18,6 @@ module Fastlane
17
18
  class UploadBuildToAppsCdnAction < Action
18
19
  # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-resource-type.php
19
20
  RESOURCE_TYPE = 'Build'
20
- # These are from the WordPress.com API, not the Apps CDN plugin
21
- VALID_POST_STATUS = %w[publish draft].freeze
22
21
  # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-build-type.php
23
22
  VALID_BUILD_TYPES = %w[
24
23
  Alpha
@@ -48,17 +47,13 @@ module Fastlane
48
47
  'Full Install',
49
48
  'Update',
50
49
  ].freeze
51
- # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-visibility.php
52
- VALID_VISIBILITIES = %i[internal external].freeze
53
-
54
50
  def self.run(params)
55
51
  UI.message('Uploading build to Apps CDN...')
56
52
 
57
53
  file_path = params[:file_path]
58
54
  UI.user_error!("File not found at path '#{file_path}'") unless File.exist?(file_path)
59
55
 
60
- api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/media/new"
61
- uri = URI.parse(api_endpoint)
56
+ uri = Helper::AppsCdnHelper.rest_v1_1_url(site_id: params[:site_id], path: 'media/new')
62
57
 
63
58
  # Create the request body and headers
64
59
  parameters = {
@@ -261,9 +256,7 @@ module Fastlane
261
256
  description: 'The visibility of the build (:internal or :external)',
262
257
  optional: false,
263
258
  type: Symbol,
264
- verify_block: proc do |value|
265
- UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym)
266
- end
259
+ verify_block: Helper::AppsCdnHelper.verify_visibility_param
267
260
  ),
268
261
  FastlaneCore::ConfigItem.new(
269
262
  key: :post_status,
@@ -271,9 +264,7 @@ module Fastlane
271
264
  optional: true,
272
265
  default_value: 'publish',
273
266
  type: String,
274
- verify_block: proc do |value|
275
- UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value)
276
- end
267
+ verify_block: Helper::AppsCdnHelper.verify_post_status_param
277
268
  ),
278
269
  FastlaneCore::ConfigItem.new(
279
270
  key: :version,
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Fastlane
6
+ module Helper
7
+ module AppsCdnHelper
8
+ API_BASE_URL = 'https://public-api.wordpress.com'
9
+
10
+ # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-visibility.php
11
+ VALID_VISIBILITIES = %i[internal external].freeze
12
+
13
+ # These are from the WordPress.com API, not the Apps CDN plugin
14
+ VALID_POST_STATUS = %w[publish draft].freeze
15
+
16
+ # Builds a WordPress.com REST API v1.1 URI scoped to a site.
17
+ #
18
+ # @param site_id [String] the WordPress.com site ID
19
+ # @param path [String] the API path relative to the site (e.g. 'media/new')
20
+ # @return [URI] the parsed full API URI
21
+ def self.rest_v1_1_url(site_id:, path:)
22
+ URI.parse("#{API_BASE_URL}/rest/v1.1/sites/#{site_id}/#{path}")
23
+ end
24
+
25
+ # Builds a WordPress.com REST API wpcom/v2 URI scoped to a site.
26
+ #
27
+ # @param site_id [String] the WordPress.com site ID
28
+ # @param path [String] the API path relative to the site (e.g. 'a8c-cdn/builds/123')
29
+ # @return [URI] the parsed full API URI
30
+ def self.wpcom_v2_url(site_id:, path:)
31
+ URI.parse("#{API_BASE_URL}/wpcom/v2/sites/#{site_id}/#{path}")
32
+ end
33
+
34
+ # Returns a proc that validates a visibility parameter value against {VALID_VISIBILITIES}.
35
+ # Intended for use as a `verify_block` in Fastlane ConfigItem definitions.
36
+ #
37
+ # @return [Proc] a proc that raises FastlaneError if the value is invalid
38
+ def self.verify_visibility_param
39
+ proc do |value|
40
+ UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym)
41
+ end
42
+ end
43
+
44
+ # Returns a proc that validates a post status parameter value against {VALID_POST_STATUS}.
45
+ # Intended for use as a `verify_block` in Fastlane ConfigItem definitions.
46
+ #
47
+ # @return [Proc] a proc that raises FastlaneError if the value is invalid
48
+ def self.verify_post_status_param
49
+ proc do |value|
50
+ UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -151,7 +151,7 @@ module Fastlane
151
151
  end
152
152
 
153
153
  ### A helper function to extract the distance from the provided string.
154
- ### (ie – this function will recieve "behind 2" or "ahead 6" and return 2 or 6, respectively.
154
+ ### (ie – this function will receive "behind 2" or "ahead 6" and return 2 or 6, respectively.
155
155
  def self.parse_distance(match)
156
156
  distance = match.to_s.scan(/\d+/).first
157
157
 
@@ -265,7 +265,7 @@ module Fastlane
265
265
  end
266
266
 
267
267
  ## Updates the project encryption key defined in ~/.mobile-secrets/keys.json
268
- ## The updated file is commited and push to the repo
268
+ ## The updated file is committed and pushed to the repo
269
269
  def self.update_project_encryption_key
270
270
  # Update keys.json with the new key
271
271
  keys_json = mobile_secrets_keys_json
@@ -304,6 +304,23 @@ module Fastlane
304
304
  reuse_identifier
305
305
  end
306
306
 
307
+ # Find an existing Pull Request matching the given head (and optionally base) branch.
308
+ #
309
+ # @param [String] repository The repository name, including the organization (e.g. `wordpress-mobile/wordpress-ios`)
310
+ # @param [String] head The head branch to look for. May be given as `branch` or as the fully-qualified `owner:branch`;
311
+ # when unqualified, it is automatically prefixed with the repository's owner.
312
+ # @param [String?] base The base branch the PR should target. If nil, PRs targeting any base are considered.
313
+ # @param [String] state The PR state to match (`open`, `closed`, or `all`). Defaults to `open`.
314
+ # @return [Sawyer::Resource, nil] The first matching Pull Request, or nil if none matches.
315
+ #
316
+ def find_pull_request(repository:, head:, base: nil, state: 'open')
317
+ qualified_head = head.include?(':') ? head : "#{repository.split('/').first}:#{head}"
318
+ options = { state: state, head: qualified_head }
319
+ options[:base] = base unless base.nil?
320
+
321
+ client.pull_requests(repository, options).first
322
+ end
323
+
307
324
  # Update a milestone for a repository
308
325
  #
309
326
  # @param [String] repository The repository name (including the organization)
@@ -14,7 +14,7 @@ module Fastlane
14
14
  attr_reader :install_path, :version
15
15
 
16
16
  # @param [String] install_path The path to install SwiftGen to. Usually something like "$PROJECT_DIR/vendor/swiftgen/#{SWIFTGEN_VERSION}".
17
- # It's recommended to provide an absolute path here rather than a relative one, to ensure it's not dependant on where the action is run from.
17
+ # It's recommended to provide an absolute path here rather than a relative one, to ensure it's not dependent on where the action is run from.
18
18
  # @param [String] version The version of SwiftGen to use. This will be used both:
19
19
  # - to check if the current version located in `install_path`, if it already exists, is the expected one
20
20
  # - to know which version to download if there is not one installed in `install_path` yet
@@ -3,6 +3,6 @@
3
3
  module Fastlane
4
4
  module Wpmreleasetoolkit
5
5
  NAME = 'fastlane-plugin-wpmreleasetoolkit'
6
- VERSION = '14.6.0'
6
+ VERSION = '14.8.0'
7
7
  end
8
8
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastlane
4
+ module Wpmreleasetoolkit
5
+ module Versioning
6
+ # Google Play Store's maximum allowed versionCode.
7
+ MAX_PLAY_STORE_VERSION_CODE = 2_100_000_000
8
+
9
+ # The `ContinuousBuildCodeFormatter` derives an Android Play Store `versionCode` for a
10
+ # "continuous trunk" release model, where the low-order term is a high-cardinality,
11
+ # monotonically increasing build number (e.g. a Buildkite build number).
12
+ #
13
+ # The build code is computed as:
14
+ #
15
+ # versionCode = (major * 10 + minor) * 10^build_digits + build_number
16
+ #
17
+ # It takes `major`, `minor`, and `build_number` as explicit arguments rather than an
18
+ # `AppVersion`, because the inputs come from different sources and an `AppVersion` does not
19
+ # model them: the marketing `major`/`minor` come from the parsed version, while `build_number`
20
+ # is an independent CI counter (e.g. `BUILDKITE_BUILD_NUMBER`). Notably, `AppVersion#build_number`
21
+ # means something else in this domain (the RC/beta iteration counter, e.g. `-rc-1`), so taking an
22
+ # `AppVersion` here would invite reading the wrong field. There is also no `patch`: in a
23
+ # continuous-trunk model the build number strictly orders every build and subsumes patch's
24
+ # ordering role (hotfixes get a new build number, not a patch digit in the code).
25
+ #
26
+ # Because the build number is globally monotonic and the version prefix only ever increases,
27
+ # the resulting code is always strictly increasing — even if the build number eventually
28
+ # exceeds `10^build_digits` (which only costs human-readability, not ordering). The only hard
29
+ # correctness constraint is staying at or below the Play Store's max versionCode.
30
+ #
31
+ # Unlike `DerivedBuildCodeFormatter` (fixed-width string concatenation capped at 8 total digits
32
+ # and 3 digits per component, i.e. build <= 999), this formatter can hold a large build number.
33
+ # The two formatters target different release models; this one does not replace the other.
34
+ class ContinuousBuildCodeFormatter
35
+ # @param [Integer] build_digits Number of digits reserved for the build number, which sets the
36
+ # multiplier applied to the `major * 10 + minor` prefix (multiplier = 10^build_digits).
37
+ # Must be a positive integer. Defaults to 6 (multiplier = 1_000_000).
38
+ #
39
+ def initialize(build_digits: 6)
40
+ validate_build_digits!(build_digits)
41
+ @build_digits = build_digits
42
+ end
43
+
44
+ # Derive the build code (Android `versionCode`).
45
+ #
46
+ # @param [Integer] major The major (marketing) version number.
47
+ # @param [Integer] minor The minor (marketing) version number. Must be 9 or lower.
48
+ # @param [Integer] build_number A high-cardinality, monotonically increasing build number
49
+ # (e.g. a Buildkite build number). This is a CI counter, not `AppVersion#build_number`.
50
+ #
51
+ # @return [Integer] The derived `versionCode`.
52
+ #
53
+ def build_code(major:, minor:, build_number:)
54
+ # Validate up front so bad input (e.g. strings from env vars or file reads) raises a
55
+ # user-friendly error rather than an opaque `TypeError` from the arithmetic below.
56
+ validate_component!('major', major)
57
+ validate_component!('minor', minor)
58
+ validate_component!('build_number', build_number)
59
+
60
+ # `major * 10 + minor` is only unambiguous while minor is a single digit.
61
+ if minor > 9
62
+ UI.user_error!("Minor version (#{minor}) must be 9 or lower to derive an unambiguous build code with `#{self.class.name}`")
63
+ end
64
+
65
+ prefix = (major * 10) + minor
66
+ code = (prefix * (10**@build_digits)) + build_number
67
+
68
+ # Sanity check: Play Store versionCodes must be positive integers.
69
+ if code <= 0
70
+ UI.user_error!("Derived build code (#{code}) must be a positive integer")
71
+ end
72
+
73
+ if code > MAX_PLAY_STORE_VERSION_CODE
74
+ UI.user_error!("Derived build code (#{code}) exceeds the maximum allowed Play Store versionCode (#{MAX_PLAY_STORE_VERSION_CODE})")
75
+ end
76
+
77
+ code
78
+ end
79
+
80
+ private
81
+
82
+ # Validates that a version component is a non-negative integer.
83
+ #
84
+ # @param [String] name The component name, used in the error message
85
+ # @param [Integer] value The value to validate
86
+ #
87
+ # @raise [StandardError] If the value is not a non-negative integer
88
+ #
89
+ def validate_component!(name, value)
90
+ unless value.is_a?(Integer)
91
+ UI.user_error!("`#{name}` must be an integer, got: #{value.class}")
92
+ end
93
+
94
+ return unless value.negative?
95
+
96
+ UI.user_error!("`#{name}` must be a non-negative integer, got: #{value}")
97
+ end
98
+
99
+ # Validates that `build_digits` is a positive integer.
100
+ #
101
+ # @param [Integer] build_digits The build digit count to validate
102
+ #
103
+ # @raise [StandardError] If the value is not a positive integer
104
+ #
105
+ def validate_build_digits!(build_digits)
106
+ unless build_digits.is_a?(Integer)
107
+ UI.user_error!("`build_digits` must be an integer, got: #{build_digits.class}")
108
+ end
109
+
110
+ return if build_digits.positive?
111
+
112
+ UI.user_error!("`build_digits` must be a positive integer, got: #{build_digits}")
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fastlane-plugin-wpmreleasetoolkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 14.6.0
4
+ version: 14.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Automattic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: buildkit
@@ -72,14 +72,14 @@ dependencies:
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '2.231'
75
+ version: '2.235'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '2.231'
82
+ version: '2.235'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: gettext
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -439,6 +439,7 @@ files:
439
439
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/create_new_milestone_action.rb
440
440
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/create_release_backmerge_pull_request_action.rb
441
441
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/extract_release_notes_for_version_action.rb
442
+ - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb
442
443
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_previous_tag.rb
443
444
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/firebase_login.rb
444
445
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/get_prs_between_tags.rb
@@ -451,6 +452,7 @@ files:
451
452
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/remove_branch_protection_action.rb
452
453
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/set_branch_protection_action.rb
453
454
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/set_milestone_frozen_marker_action.rb
455
+ - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb
454
456
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_assigned_milestone_action.rb
455
457
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_build_to_apps_cdn.rb
456
458
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_to_s3.rb
@@ -479,6 +481,7 @@ files:
479
481
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_tools_path_helper.rb
480
482
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_version_helper.rb
481
483
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/app_size_metrics_helper.rb
484
+ - lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb
482
485
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/buildkite_aware_log_groups.rb
483
486
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/ci_helper.rb
484
487
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/configure_helper.rb
@@ -517,6 +520,7 @@ files:
517
520
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/files/android_version_file.rb
518
521
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/files/ios_version_file.rb
519
522
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/formatters/abstract_version_formatter.rb
523
+ - lib/fastlane/plugin/wpmreleasetoolkit/versioning/formatters/continuous_build_code_formatter.rb
520
524
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/formatters/derived_build_code_formatter.rb
521
525
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/formatters/four_part_build_code_formatter.rb
522
526
  - lib/fastlane/plugin/wpmreleasetoolkit/versioning/formatters/four_part_version_formatter.rb