Skip to content

Commit 71d53c6

Browse files
authored
Add upload_build_to_apps_cdn action (#636)
2 parents a55c368 + 3223587 commit 71d53c6

File tree

3 files changed

+756
-1
lines changed

3 files changed

+756
-1
lines changed

Diff for: CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- Introduce `upload_build_to_apps_cdn` action to upload a build binary to the Apps CDN. [#636]
1414

1515
### Bug Fixes
1616

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
require 'net/http'
5+
require 'uri'
6+
require 'json'
7+
8+
module Fastlane
9+
module Actions
10+
module SharedValues
11+
APPS_CDN_UPLOADED_FILE_URL = :APPS_CDN_UPLOADED_FILE_URL
12+
APPS_CDN_UPLOADED_FILE_ID = :APPS_CDN_UPLOADED_FILE_ID
13+
APPS_CDN_UPLOADED_POST_ID = :APPS_CDN_UPLOADED_POST_ID
14+
APPS_CDN_UPLOADED_POST_URL = :APPS_CDN_UPLOADED_POST_URL
15+
end
16+
17+
class UploadBuildToAppsCdnAction < Action
18+
RESOURCE_TYPE = 'Build'
19+
VALID_POST_STATUS = %w[publish draft].freeze
20+
VALID_BUILD_TYPES = %w[Alpha Beta Nightly Production Prototype].freeze
21+
VALID_PLATFORMS = ['Android', 'iOS', 'Mac - Silicon', 'Mac - Intel', 'Mac - Any', 'Windows'].freeze
22+
23+
def self.run(params)
24+
UI.message('Uploading build to Apps CDN...')
25+
26+
file_path = params[:file_path]
27+
UI.user_error!("File not found at path '#{file_path}'") unless File.exist?(file_path)
28+
29+
api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/media/new"
30+
uri = URI.parse(api_endpoint)
31+
32+
# Create the request body and headers
33+
parameters = {
34+
product: params[:product],
35+
build_type: params[:build_type],
36+
visibility: params[:visibility].to_s.capitalize,
37+
platform: params[:platform],
38+
resource_type: RESOURCE_TYPE,
39+
version: params[:version],
40+
build_number: params[:build_number], # Optional: may be nil
41+
minimum_system_version: params[:minimum_system_version], # Optional: may be nil
42+
post_status: params[:post_status], # Optional: may be nil
43+
release_notes: params[:release_notes], # Optional: may be nil
44+
error_on_duplicate: params[:error_on_duplicate] # defaults to false
45+
}.compact
46+
request_body, content_type = build_multipart_request(parameters: parameters, file_path: file_path)
47+
48+
# Create and send the HTTP request
49+
request = Net::HTTP::Post.new(uri.request_uri)
50+
request.body = request_body
51+
request['Content-Type'] = content_type
52+
request['Accept'] = 'application/json'
53+
request['Authorization'] = "Bearer #{params[:api_token]}"
54+
55+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
56+
http.request(request)
57+
end
58+
59+
# Handle the response
60+
case response
61+
when Net::HTTPSuccess
62+
result = parse_successful_response(response.body)
63+
64+
Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_POST_ID] = result[:post_id]
65+
Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_POST_URL] = result[:post_url]
66+
Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_FILE_ID] = result[:media_id]
67+
Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_FILE_URL] = result[:media_url]
68+
69+
UI.success('Build successfully uploaded to Apps CDN')
70+
UI.message("Post ID: #{result[:post_id]}")
71+
UI.message("Post URL: #{result[:post_url]}")
72+
73+
result
74+
else
75+
UI.error("Failed to upload build to Apps CDN: #{response.code} #{response.message}")
76+
UI.error(response.body)
77+
UI.user_error!('Upload to Apps CDN failed')
78+
end
79+
end
80+
81+
# Builds a multipart request body for the WordPress.com Media API
82+
#
83+
# @param parameters [Hash] The parameters to include in the request as top-level form fields
84+
# @param file_path [String] The path to the file to upload
85+
# @return [Array] An array containing the request body and the content-type header
86+
#
87+
def self.build_multipart_request(parameters:, file_path:)
88+
boundary = "----WebKitFormBoundary#{SecureRandom.hex(10)}"
89+
content_type = "multipart/form-data; boundary=#{boundary}"
90+
post_body = []
91+
92+
# Add the file first
93+
post_body << "--#{boundary}"
94+
post_body << "Content-Disposition: form-data; name=\"media[]\"; filename=\"#{File.basename(file_path)}\""
95+
post_body << 'Content-Type: application/octet-stream'
96+
post_body << ''
97+
post_body << File.binread(file_path)
98+
99+
# Add each parameter as a separate form field
100+
parameters.each do |key, value|
101+
post_body << "--#{boundary}"
102+
post_body << "Content-Disposition: form-data; name=\"#{key}\""
103+
post_body << ''
104+
post_body << value.to_s
105+
end
106+
107+
# Add the closing boundary
108+
post_body << "--#{boundary}--"
109+
110+
[post_body.join("\r\n"), content_type]
111+
end
112+
113+
# Parse the successful response and return a hash with the upload details
114+
#
115+
# @param response_body [String] The raw response body from the API
116+
# @return [Hash] A hash containing the upload details
117+
def self.parse_successful_response(response_body)
118+
json_response = JSON.parse(response_body)
119+
media = json_response['media'].first
120+
media_id = media['ID']
121+
media_url = media['URL']
122+
post_id = media['post_ID']
123+
124+
# Compute the post URL using the same base URL as media_url
125+
post_url = URI.parse(media_url)
126+
post_url.path = '/'
127+
post_url.query = "p=#{post_id}"
128+
post_url = post_url.to_s
129+
130+
{
131+
post_id: post_id,
132+
post_url: post_url,
133+
media_id: media_id,
134+
media_url: media_url,
135+
mime_type: media['mime_type']
136+
}
137+
end
138+
139+
def self.description
140+
'Uploads a build binary to the Apps CDN'
141+
end
142+
143+
def self.authors
144+
['Automattic']
145+
end
146+
147+
def self.return_value
148+
'Returns a Hash containing the upload result: { post_id:, post_url:, media_id:, media_url:, mime_type: }. On error, raises a FastlaneError.'
149+
end
150+
151+
def self.details
152+
<<~DETAILS
153+
Uploads a build binary file to a WordPress blog that has the Apps CDN plugin enabled.
154+
See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin.
155+
DETAILS
156+
end
157+
158+
def self.available_options
159+
[
160+
FastlaneCore::ConfigItem.new(
161+
key: :site_id,
162+
env_name: 'APPS_CDN_SITE_ID',
163+
description: 'The WordPress.com CDN site ID to upload the media to',
164+
optional: false,
165+
type: String,
166+
verify_block: proc do |value|
167+
UI.user_error!('Site ID cannot be empty') if value.to_s.empty?
168+
end
169+
),
170+
FastlaneCore::ConfigItem.new(
171+
key: :product,
172+
env_name: 'APPS_CDN_PRODUCT',
173+
# Valid values can be found at https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-product.php
174+
description: 'The product the build belongs to (e.g. \'WordPress.com Studio\')',
175+
optional: false,
176+
type: String,
177+
verify_block: proc do |value|
178+
UI.user_error!('Product cannot be empty') if value.to_s.empty?
179+
end
180+
),
181+
FastlaneCore::ConfigItem.new(
182+
key: :platform,
183+
env_name: 'APPS_CDN_PLATFORM',
184+
# Valid values can be found at https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-platform.php
185+
description: "The platform the build runs on. One of: #{VALID_PLATFORMS.join(', ')}",
186+
optional: false,
187+
type: String,
188+
verify_block: proc do |value|
189+
UI.user_error!('Platform cannot be empty') if value.to_s.empty?
190+
UI.user_error!("Platform must be one of: #{VALID_PLATFORMS.join(', ')}") unless VALID_PLATFORMS.include?(value)
191+
end
192+
),
193+
FastlaneCore::ConfigItem.new(
194+
key: :file_path,
195+
description: 'The path to the build file to upload',
196+
optional: false,
197+
type: String,
198+
verify_block: proc do |value|
199+
UI.user_error!("File not found at path '#{value}'") unless File.exist?(value)
200+
end
201+
),
202+
FastlaneCore::ConfigItem.new(
203+
key: :build_type,
204+
# Valid values can be found at https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-build-type.php
205+
description: "The type of the build. One of: #{VALID_BUILD_TYPES.join(', ')}",
206+
optional: false,
207+
type: String,
208+
verify_block: proc do |value|
209+
UI.user_error!('Build type cannot be empty') if value.to_s.empty?
210+
UI.user_error!("Build type must be one of: #{VALID_BUILD_TYPES.join(', ')}") unless VALID_BUILD_TYPES.include?(value)
211+
end
212+
),
213+
FastlaneCore::ConfigItem.new(
214+
key: :visibility,
215+
description: 'The visibility of the build (:internal or :external)',
216+
optional: false,
217+
type: Symbol,
218+
verify_block: proc do |value|
219+
UI.user_error!('Visibility must be either :internal or :external') unless %i[internal external].include?(value)
220+
end
221+
),
222+
FastlaneCore::ConfigItem.new(
223+
key: :post_status,
224+
description: 'The post status (defaults to \'publish\')',
225+
optional: true,
226+
default_value: 'publish',
227+
type: String,
228+
verify_block: proc do |value|
229+
UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value)
230+
end
231+
),
232+
FastlaneCore::ConfigItem.new(
233+
key: :version,
234+
description: 'The version string for the build (e.g. \'20.0\', \'17.8.1\')',
235+
optional: false,
236+
type: String,
237+
verify_block: proc do |value|
238+
UI.user_error!('Version cannot be empty') if value.to_s.empty?
239+
end
240+
),
241+
FastlaneCore::ConfigItem.new(
242+
key: :build_number,
243+
description: 'The build number for the build (e.g. \'42\')',
244+
optional: true,
245+
type: String
246+
),
247+
FastlaneCore::ConfigItem.new(
248+
key: :minimum_system_version,
249+
description: 'The minimum version for the provided platform (e.g. \'13.0\' for macOS Ventura)',
250+
optional: true,
251+
type: String
252+
),
253+
FastlaneCore::ConfigItem.new(
254+
key: :release_notes,
255+
description: 'The release notes to show with the build on the blog frontend',
256+
optional: true,
257+
type: String
258+
),
259+
FastlaneCore::ConfigItem.new(
260+
key: :error_on_duplicate,
261+
description: 'If true, the action will error if a build matching the same metadata already exists. If false, any potential existing build matching the same metadata will be updated to replace the build with the new file',
262+
default_value: false,
263+
type: Boolean
264+
),
265+
FastlaneCore::ConfigItem.new(
266+
key: :api_token,
267+
env_name: 'WPCOM_API_TOKEN',
268+
description: 'The WordPress.com API token for authentication',
269+
optional: false,
270+
type: String,
271+
verify_block: proc do |value|
272+
UI.user_error!('API token cannot be empty') if value.to_s.empty?
273+
end
274+
),
275+
]
276+
end
277+
278+
def self.is_supported?(platform)
279+
true
280+
end
281+
282+
def self.output
283+
[
284+
['APPS_CDN_UPLOADED_FILE_URL', 'The URL of the uploaded file'],
285+
['APPS_CDN_UPLOADED_FILE_ID', 'The ID of the uploaded file'],
286+
['APPS_CDN_UPLOADED_POST_ID', 'The ID of the post / page created for the uploaded build'],
287+
['APPS_CDN_UPLOADED_POST_URL', 'The URL of the post / page created for the uploaded build'],
288+
]
289+
end
290+
291+
def self.example_code
292+
[
293+
'upload_build_to_apps_cdn(
294+
site_id: "12345678",
295+
api_token: ENV["WPCOM_API_TOKEN"],
296+
product: "WordPress.com Studio",
297+
build_type: "Beta",
298+
visibility: :internal,
299+
platform: "Mac - Any",
300+
version: "20.0",
301+
build_number: "42",
302+
file_path: "path/to/app.zip"
303+
)',
304+
'upload_build_to_apps_cdn(
305+
site_id: "12345678",
306+
api_token: ENV["WPCOM_API_TOKEN"],
307+
product: "WordPress.com Studio",
308+
build_type: "Beta",
309+
visibility: :external,
310+
platform: "Android",
311+
version: "20.0",
312+
build_number: "42",
313+
file_path: "path/to/app.apk",
314+
error_on_duplicate: true
315+
)',
316+
]
317+
end
318+
end
319+
end
320+
end

0 commit comments

Comments
 (0)