diff --git a/CHANGELOG.md b/CHANGELOG.md index 81721ae32..371b7170b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Introduce `upload_build_to_apps_cdn` action to upload a build binary to the Apps CDN. [#636] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_build_to_apps_cdn.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_build_to_apps_cdn.rb new file mode 100644 index 000000000..426a48a98 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_build_to_apps_cdn.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'net/http' +require 'uri' +require 'json' + +module Fastlane + module Actions + module SharedValues + APPS_CDN_UPLOADED_FILE_URL = :APPS_CDN_UPLOADED_FILE_URL + APPS_CDN_UPLOADED_FILE_ID = :APPS_CDN_UPLOADED_FILE_ID + APPS_CDN_UPLOADED_POST_ID = :APPS_CDN_UPLOADED_POST_ID + APPS_CDN_UPLOADED_POST_URL = :APPS_CDN_UPLOADED_POST_URL + end + + class UploadBuildToAppsCdnAction < Action + RESOURCE_TYPE = 'Build' + VALID_POST_STATUS = %w[publish draft].freeze + VALID_BUILD_TYPES = %w[Alpha Beta Nightly Production Prototype].freeze + VALID_PLATFORMS = ['Android', 'iOS', 'Mac - Silicon', 'Mac - Intel', 'Mac - Any', 'Windows'].freeze + + def self.run(params) + UI.message('Uploading build to Apps CDN...') + + file_path = params[:file_path] + UI.user_error!("File not found at path '#{file_path}'") unless File.exist?(file_path) + + api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/media/new" + uri = URI.parse(api_endpoint) + + # Create the request body and headers + parameters = { + product: params[:product], + build_type: params[:build_type], + visibility: params[:visibility].to_s.capitalize, + platform: params[:platform], + resource_type: RESOURCE_TYPE, + version: params[:version], + build_number: params[:build_number], # Optional: may be nil + minimum_system_version: params[:minimum_system_version], # Optional: may be nil + post_status: params[:post_status], # Optional: may be nil + release_notes: params[:release_notes], # Optional: may be nil + error_on_duplicate: params[:error_on_duplicate] # defaults to false + }.compact + request_body, content_type = build_multipart_request(parameters: parameters, file_path: file_path) + + # Create and send the HTTP request + request = Net::HTTP::Post.new(uri.request_uri) + request.body = request_body + request['Content-Type'] = content_type + request['Accept'] = 'application/json' + request['Authorization'] = "Bearer #{params[:api_token]}" + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(request) + end + + # Handle the response + case response + when Net::HTTPSuccess + result = parse_successful_response(response.body) + + Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_POST_ID] = result[:post_id] + Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_POST_URL] = result[:post_url] + Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_FILE_ID] = result[:media_id] + Actions.lane_context[SharedValues::APPS_CDN_UPLOADED_FILE_URL] = result[:media_url] + + UI.success('Build successfully uploaded to Apps CDN') + UI.message("Post ID: #{result[:post_id]}") + UI.message("Post URL: #{result[:post_url]}") + + result + else + UI.error("Failed to upload build to Apps CDN: #{response.code} #{response.message}") + UI.error(response.body) + UI.user_error!('Upload to Apps CDN failed') + end + end + + # Builds a multipart request body for the WordPress.com Media API + # + # @param parameters [Hash] The parameters to include in the request as top-level form fields + # @param file_path [String] The path to the file to upload + # @return [Array] An array containing the request body and the content-type header + # + def self.build_multipart_request(parameters:, file_path:) + boundary = "----WebKitFormBoundary#{SecureRandom.hex(10)}" + content_type = "multipart/form-data; boundary=#{boundary}" + post_body = [] + + # Add the file first + post_body << "--#{boundary}" + post_body << "Content-Disposition: form-data; name=\"media[]\"; filename=\"#{File.basename(file_path)}\"" + post_body << 'Content-Type: application/octet-stream' + post_body << '' + post_body << File.binread(file_path) + + # Add each parameter as a separate form field + parameters.each do |key, value| + post_body << "--#{boundary}" + post_body << "Content-Disposition: form-data; name=\"#{key}\"" + post_body << '' + post_body << value.to_s + end + + # Add the closing boundary + post_body << "--#{boundary}--" + + [post_body.join("\r\n"), content_type] + end + + # Parse the successful response and return a hash with the upload details + # + # @param response_body [String] The raw response body from the API + # @return [Hash] A hash containing the upload details + def self.parse_successful_response(response_body) + json_response = JSON.parse(response_body) + media = json_response['media'].first + media_id = media['ID'] + media_url = media['URL'] + post_id = media['post_ID'] + + # Compute the post URL using the same base URL as media_url + post_url = URI.parse(media_url) + post_url.path = '/' + post_url.query = "p=#{post_id}" + post_url = post_url.to_s + + { + post_id: post_id, + post_url: post_url, + media_id: media_id, + media_url: media_url, + mime_type: media['mime_type'] + } + end + + def self.description + 'Uploads a build binary to the Apps CDN' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'Returns a Hash containing the upload result: { post_id:, post_url:, media_id:, media_url:, mime_type: }. On error, raises a FastlaneError.' + end + + def self.details + <<~DETAILS + Uploads a build binary file to a WordPress blog that has the Apps CDN plugin enabled. + See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :site_id, + env_name: 'APPS_CDN_SITE_ID', + description: 'The WordPress.com CDN site ID to upload the media to', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Site ID cannot be empty') if value.to_s.empty? + end + ), + FastlaneCore::ConfigItem.new( + key: :product, + env_name: 'APPS_CDN_PRODUCT', + # Valid values can be found at https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-product.php + description: 'The product the build belongs to (e.g. \'WordPress.com Studio\')', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Product cannot be empty') if value.to_s.empty? + end + ), + FastlaneCore::ConfigItem.new( + key: :platform, + env_name: 'APPS_CDN_PLATFORM', + # Valid values can be found at https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-platform.php + description: "The platform the build runs on. One of: #{VALID_PLATFORMS.join(', ')}", + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Platform cannot be empty') if value.to_s.empty? + UI.user_error!("Platform must be one of: #{VALID_PLATFORMS.join(', ')}") unless VALID_PLATFORMS.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :file_path, + description: 'The path to the build file to upload', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!("File not found at path '#{value}'") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :build_type, + # 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 + description: "The type of the build. One of: #{VALID_BUILD_TYPES.join(', ')}", + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Build type cannot be empty') if value.to_s.empty? + UI.user_error!("Build type must be one of: #{VALID_BUILD_TYPES.join(', ')}") unless VALID_BUILD_TYPES.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :visibility, + description: 'The visibility of the build (:internal or :external)', + optional: false, + type: Symbol, + verify_block: proc do |value| + UI.user_error!('Visibility must be either :internal or :external') unless %i[internal external].include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :post_status, + description: 'The post status (defaults to \'publish\')', + optional: true, + default_value: 'publish', + type: String, + verify_block: proc do |value| + UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :version, + description: 'The version string for the build (e.g. \'20.0\', \'17.8.1\')', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Version cannot be empty') if value.to_s.empty? + end + ), + FastlaneCore::ConfigItem.new( + key: :build_number, + description: 'The build number for the build (e.g. \'42\')', + optional: true, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :minimum_system_version, + description: 'The minimum version for the provided platform (e.g. \'13.0\' for macOS Ventura)', + optional: true, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :release_notes, + description: 'The release notes to show with the build on the blog frontend', + optional: true, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :error_on_duplicate, + 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', + default_value: false, + type: Boolean + ), + FastlaneCore::ConfigItem.new( + key: :api_token, + env_name: 'WPCOM_API_TOKEN', + description: 'The WordPress.com API token for authentication', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('API token cannot be empty') if value.to_s.empty? + end + ), + ] + end + + def self.is_supported?(platform) + true + end + + def self.output + [ + ['APPS_CDN_UPLOADED_FILE_URL', 'The URL of the uploaded file'], + ['APPS_CDN_UPLOADED_FILE_ID', 'The ID of the uploaded file'], + ['APPS_CDN_UPLOADED_POST_ID', 'The ID of the post / page created for the uploaded build'], + ['APPS_CDN_UPLOADED_POST_URL', 'The URL of the post / page created for the uploaded build'], + ] + end + + def self.example_code + [ + 'upload_build_to_apps_cdn( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"], + product: "WordPress.com Studio", + build_type: "Beta", + visibility: :internal, + platform: "Mac - Any", + version: "20.0", + build_number: "42", + file_path: "path/to/app.zip" + )', + 'upload_build_to_apps_cdn( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"], + product: "WordPress.com Studio", + build_type: "Beta", + visibility: :external, + platform: "Android", + version: "20.0", + build_number: "42", + file_path: "path/to/app.apk", + error_on_duplicate: true + )', + ] + end + end + end +end diff --git a/spec/upload_build_to_apps_cdn_spec.rb b/spec/upload_build_to_apps_cdn_spec.rb new file mode 100644 index 000000000..d4c0bc8e5 --- /dev/null +++ b/spec/upload_build_to_apps_cdn_spec.rb @@ -0,0 +1,435 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'webmock/rspec' + +describe Fastlane::Actions::UploadBuildToAppsCdnAction do + let(:test_site_id) { '12345678' } + let(:test_api_token) { 'test_api_token' } + let(:test_product) { 'WordPress.com Studio' } + let(:test_build_type) { 'Beta' } + let(:test_visibility) { :internal } + let(:test_platform) { 'Mac - Any' } + let(:test_version) { '20.0' } + let(:test_build_number) { '42' } + let(:test_media_id) { '987654' } + let(:test_media_url) { 'https://example.com/uploads/app.zip' } + let(:test_post_id) { '12345' } + let(:test_post_url) { 'https://example.com/?p=12345' } + let(:test_date) { '2023-06-15T12:00:00Z' } + let(:test_mime_type) { 'application/zip' } + let(:test_filename) { 'test_app.zip' } + let(:test_file_content) { 'test app binary' } + + before do + WebMock.disable_net_connect! + end + + after do + WebMock.allow_net_connect! + end + + # Helper method to build the expected multipart form data part + def expected_form_part(boundary:, name:, value:, filename: nil) + lines = ["--#{boundary}"] + if filename + lines << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"" + lines << 'Content-Type: application/octet-stream' + else + lines << "Content-Disposition: form-data; name=\"#{name}\"" + end + + lines << '' + lines << value + lines << "--#{boundary}" + lines.join("\r\n") + end + + describe 'uploading a build with valid parameters' do + it 'successfully uploads the build and returns the media details' do + with_tmp_file(named: test_filename, content: test_file_content) do |file_path| + # Stub the WordPress.com API request + stub_request(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new") + .to_return( + status: 200, + body: { + media: [ + { + ID: test_media_id, + URL: test_media_url, + date: test_date, + mime_type: test_mime_type, + file: test_filename, + post_ID: test_post_id + }, + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Run the action + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + build_number: test_build_number, + file_path: file_path + ) + + # Verify the result + expect(result).to be_a(Hash) + expect(result[:post_id]).to eq(test_post_id) + expect(result[:post_url]).to eq(test_post_url) + expect(result[:media_id]).to eq(test_media_id) + expect(result[:media_url]).to eq(test_media_url) + expect(result[:mime_type]).to eq(test_mime_type) + + # Verify the shared values + expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::APPS_CDN_UPLOADED_FILE_URL]).to eq(test_media_url) + expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::APPS_CDN_UPLOADED_FILE_ID]).to eq(test_media_id) + expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::APPS_CDN_UPLOADED_POST_ID]).to eq(test_post_id) + expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::APPS_CDN_UPLOADED_POST_URL]).to eq(test_post_url) + + # Verify that the request was made with the correct parameters + expect(WebMock).to( + have_requested(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new").with do |req| + # Check that the request contains the expected headers + expect(req.headers['Content-Type']).to include('multipart/form-data') + expect(req.headers['Authorization']).to eq("Bearer #{test_api_token}") + + boundary = req.headers['Content-Type'].match(/boundary=([^;]+)/)[1] + + # Verify the media file is included with proper attributes + expect(req.body).to include(expected_form_part(boundary: boundary, name: 'media[]', value: test_file_content, filename: test_filename)) + + # Verify each parameter has the correct value + { + 'product' => test_product, + 'build_type' => test_build_type, + 'visibility' => 'Internal', # Capitalized from :internal + 'platform' => test_platform, + 'resource_type' => 'Build', # RESOURCE_TYPE constant + 'version' => test_version, + 'build_number' => test_build_number + }.each do |name, value| + expect(req.body).to include(expected_form_part(boundary: boundary, name: name, value: value)) + end + + true + end + ) + end + end + + it 'successfully uploads the build with more optional parameters' do + with_tmp_file(named: test_filename, content: test_file_content) do |file_path| + # Stub the WordPress.com API request + stub_request(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new") + .to_return( + status: 200, + body: { + media: [ + { + ID: test_media_id, + URL: test_media_url, + date: test_date, + mime_type: test_mime_type, + file: test_filename, + post_ID: test_post_id + }, + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Run the action with external visibility and error_on_duplicate + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: :external, + platform: test_platform, + version: test_version, + build_number: test_build_number, + file_path: file_path, + error_on_duplicate: true + ) + + # Verify the result + expect(result).to be_a(Hash) + expect(result[:post_id]).to eq(test_post_id) + expect(result[:post_url]).to eq(test_post_url) + expect(result[:media_id]).to eq(test_media_id) + expect(result[:media_url]).to eq(test_media_url) + expect(result[:mime_type]).to eq(test_mime_type) + + # Verify that the request was made with the correct parameters + expect(WebMock).to( + have_requested(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new").with do |req| + boundary = req.headers['Content-Type'].match(/boundary=([^;]+)/)[1] + + # Check that the visibility is set to External + expect(req.body).to include(expected_form_part(boundary: boundary, name: 'visibility', value: 'External')) + true + end + ) + end + end + + it 'handles API validation errors properly' do + with_tmp_file(named: test_filename, content: test_file_content) do |file_path| + # Stub the WordPress.com API request to return a validation error + stub_request(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new") + .to_return( + status: 400, + body: { + errors: [ + { + file: 'test.txt', + error: 'validation_error', + message: 'A build with this data already exists, and you configured this request to error if a duplicate is found.' + }, + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Run the action and expect it to raise an error + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + build_number: test_build_number, + file_path: file_path, + error_on_duplicate: true + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Upload to Apps CDN failed') + end + end + + it 'handles non-JSON API errors properly' do + with_tmp_file(named: test_filename, content: test_file_content) do |file_path| + # Stub the WordPress.com API request to return a non-JSON error + stub_request(:post, "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/media/new") + .to_return( + status: 500, + body: 'Internal Server Error', + headers: { 'Content-Type' => 'text/plain' } + ) + + # Run the action and expect it to raise an error + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + build_number: test_build_number, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Upload to Apps CDN failed') + end + end + end + + describe 'parameter validation' do + it 'fails if site_id is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: '', + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Site ID cannot be empty') + end + end + + it 'fails if api_token is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: '', + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') + end + end + + it 'fails if product is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: '', + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Product cannot be empty') + end + end + + it 'fails if build_type is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: '', + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Build type cannot be empty') + end + end + + it 'fails if build_type is not a valid value' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: 'InvalidType', + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Build type must be one of: Alpha, Beta, Nightly, Production, Prototype') + end + end + + it 'fails if visibility is not a valid symbol' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: :public, # Invalid value + platform: test_platform, + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Visibility must be either :internal or :external') + end + end + + it 'fails if platform is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: '', + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Platform cannot be empty') + end + end + + it 'fails if platform is not a valid value' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: 'InvalidPlatform', + version: test_version, + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Platform must be one of: Android, iOS, Mac - Silicon, Mac - Intel, Mac - Any, Windows') + end + end + + it 'fails if version is empty' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: '', + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Version cannot be empty') + end + end + + it 'fails if post_status is not a valid value' do + with_tmp_file(named: test_filename) do |file_path| + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + post_status: 'invalid_status', + file_path: file_path + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post status must be one of: publish, draft') + end + end + + it 'fails if the file does not exist' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + product: test_product, + build_type: test_build_type, + visibility: test_visibility, + platform: test_platform, + version: test_version, + file_path: 'non_existent_file.zip' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "File not found at path 'non_existent_file.zip'") + end + end +end