From 766f65ed1979475176c08599c9ba70e19a05b7ff Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 2 Mar 2026 21:06:03 +0100 Subject: [PATCH 01/11] Add update_apps_cdn_build_metadata action to update visibility of existing CDN builds Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../common/update_apps_cdn_build_metadata.rb | 149 ++++++++++ spec/update_apps_cdn_build_metadata_spec.rb | 260 ++++++++++++++++++ 3 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb create mode 100644 spec/update_apps_cdn_build_metadata_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c95a2e7..bef7a816d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Added new `update_apps_cdn_build_metadata` action to update metadata (e.g. visibility) of an existing build on the Apps CDN without re-uploading the file. This enables a two-phase release flow: upload builds as Internal first, then flip to External at publish time. [#TBD] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb new file mode 100644 index 000000000..b4a253a99 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'net/http' +require 'uri' +require 'json' + +module Fastlane + module Actions + class UpdateAppsCdnBuildMetadataAction < Action + VALID_VISIBILITIES = %i[internal external].freeze + VALID_POST_STATUS = %w[publish draft].freeze + + def self.run(params) + UI.message("Updating Apps CDN build metadata for post #{params[:post_id]}...") + + api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/posts/#{params[:post_id]}" + uri = URI.parse(api_endpoint) + + # Build the update form data + form_data = {} + form_data['terms[visibility]'] = params[:visibility].to_s.capitalize if params[:visibility] + form_data['status'] = params[:post_status] if params[:post_status] + + UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if form_data.empty? + + # Create and send the HTTP request + request = Net::HTTP::Post.new(uri.request_uri) + request.body = URI.encode_www_form(form_data) + request['Content-Type'] = 'application/x-www-form-urlencoded' + 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 = JSON.parse(response.body) + post_id = result['ID'] + + UI.success("Successfully updated Apps CDN build metadata for post #{post_id}") + + { post_id: post_id } + else + UI.error("Failed to update Apps CDN build metadata: #{response.code} #{response.message}") + UI.error(response.body) + UI.user_error!('Update of Apps CDN build metadata failed') + end + end + + def self.description + 'Updates metadata of an existing build on the Apps CDN' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'Returns a Hash containing { post_id: }. On error, raises a FastlaneError.' + end + + def self.details + <<~DETAILS + Updates metadata (such as visibility) for an existing build post on a WordPress blog + that has the Apps CDN plugin enabled, using the WordPress.com REST API. + 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 where the build was uploaded', + 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: :post_id, + description: 'The ID of the build post to update', + optional: false, + type: Integer, + verify_block: proc do |value| + UI.user_error!('Post ID must be a positive integer') unless value.is_a?(Integer) && value.positive? + end + ), + 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 + ), + FastlaneCore::ConfigItem.new( + key: :visibility, + description: 'The new visibility for the build (:internal or :external)', + optional: true, + type: Symbol, + verify_block: proc do |value| + UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) + end + ), + FastlaneCore::ConfigItem.new( + key: :post_status, + description: "The new post status ('publish' or 'draft')", + optional: true, + 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 + ), + ] + end + + def self.is_supported?(platform) + true + end + + def self.example_code + [ + 'update_apps_cdn_build_metadata( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"], + post_id: 98765, + visibility: :external + )', + 'update_apps_cdn_build_metadata( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"], + post_id: 98765, + visibility: :internal, + post_status: "draft" + )', + ] + end + end + end +end diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb new file mode 100644 index 000000000..1bfb6594f --- /dev/null +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'webmock/rspec' + +describe Fastlane::Actions::UpdateAppsCdnBuildMetadataAction do + let(:test_site_id) { '12345678' } + let(:test_post_id) { 98_765 } + let(:api_url) { "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/posts/#{test_post_id}" } + let(:test_api_token) { 'test_api_token' } + + let(:stub_success_response) do + { + ID: test_post_id, + title: 'WordPress.com Studio 1.7.5', + status: 'publish', + terms: { + visibility: { + External: { ID: 1, name: 'External', slug: 'external' } + } + } + }.to_json + end + + before do + WebMock.disable_net_connect! + end + + after do + WebMock.allow_net_connect! + end + + describe 'updating visibility' do + it 'successfully updates the visibility to external' do + stub_request(:post, api_url) + .to_return( + status: 200, + body: stub_success_response, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :external + ) + + expect(result).to be_a(Hash) + expect(result[:post_id]).to eq(test_post_id) + + expect(WebMock).to( + have_requested(:post, api_url).with do |req| + expect(req.headers['Authorization']).to eq("Bearer #{test_api_token}") + expect(req.headers['Content-Type']).to eq('application/x-www-form-urlencoded') + expect(req.body).to include('terms%5Bvisibility%5D=External') + true + end + ) + end + + it 'successfully updates the visibility to internal' do + internal_response = { + ID: test_post_id, + terms: { + visibility: { + Internal: { ID: 2, name: 'Internal', slug: 'internal' } + } + } + }.to_json + + stub_request(:post, api_url) + .to_return( + status: 200, + body: internal_response, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :internal + ) + + expect(result[:post_id]).to eq(test_post_id) + + expect(WebMock).to( + have_requested(:post, api_url).with do |req| + expect(req.body).to include('terms%5Bvisibility%5D=Internal') + true + end + ) + end + end + + describe 'updating post_status' do + it 'successfully updates the post_status' do + stub_request(:post, api_url) + .to_return( + status: 200, + body: stub_success_response, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + post_status: 'draft' + ) + + expect(result[:post_id]).to eq(test_post_id) + + expect(WebMock).to( + have_requested(:post, api_url).with do |req| + expect(req.body).to include('status=draft') + true + end + ) + end + end + + describe 'updating multiple fields' do + it 'successfully updates both visibility and post_status' do + stub_request(:post, api_url) + .to_return( + status: 200, + body: stub_success_response, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :external, + post_status: 'publish' + ) + + expect(result[:post_id]).to eq(test_post_id) + + expect(WebMock).to( + have_requested(:post, api_url).with do |req| + expect(req.body).to include('terms%5Bvisibility%5D=External') + expect(req.body).to include('status=publish') + true + end + ) + end + end + + describe 'error handling' do + it 'handles API errors properly' do + stub_request(:post, api_url) + .to_return( + status: 403, + body: { error: 'unauthorized', message: 'You are not authorized to access this resource.' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') + end + + it 'handles server errors properly' do + stub_request(:post, api_url) + .to_return( + status: 500, + body: 'Internal Server Error', + headers: { 'Content-Type' => 'text/plain' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') + end + end + + describe 'parameter validation' do + it 'fails if site_id is empty' do + expect do + run_described_fastlane_action( + site_id: '', + api_token: test_api_token, + post_id: test_post_id, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Site ID cannot be empty') + end + + it 'fails if api_token is empty' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: '', + post_id: test_post_id, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') + end + + it 'fails if post_id is not a positive integer' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: -1, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post ID must be a positive integer') + end + + it 'fails if visibility is not a valid symbol' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :public + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Visibility must be one of: `:internal`, `:external`') + end + + it 'fails if post_status is not a valid value' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + post_status: 'invalid_status' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post status must be one of: publish, draft') + end + + it 'fails if no metadata to update is provided' do + stub_request(:post, api_url) # Shouldn't be reached + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'No metadata to update. Provide at least one of: visibility, post_status') + end + end + +end From 345dad8560fb24c010f9766a19a974fd13efc21b Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 3 Mar 2026 11:33:39 +0100 Subject: [PATCH 02/11] Address PR review: add HTTP timeouts and fix changelog PR link Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../actions/common/update_apps_cdn_build_metadata.rb | 2 ++ spec/update_apps_cdn_build_metadata_spec.rb | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bef7a816d..8d6210303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Added new `update_apps_cdn_build_metadata` action to update metadata (e.g. visibility) of an existing build on the Apps CDN without re-uploading the file. This enables a two-phase release flow: upload builds as Internal first, then flip to External at publish time. [#TBD] +- Added new `update_apps_cdn_build_metadata` action to update metadata (e.g. visibility) of an existing build on the Apps CDN without re-uploading the file. This enables a two-phase release flow: upload builds as Internal first, then flip to External at publish time. [#701] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index b4a253a99..66d4c337c 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -32,6 +32,8 @@ def self.run(params) request['Authorization'] = "Bearer #{params[:api_token]}" response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.open_timeout = 10 + http.read_timeout = 30 http.request(request) end diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb index 1bfb6594f..bdabf2d75 100644 --- a/spec/update_apps_cdn_build_metadata_spec.rb +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -256,5 +256,4 @@ end.to raise_error(FastlaneCore::Interface::FastlaneError, 'No metadata to update. Provide at least one of: visibility, post_status') end end - end From 4094e588cad73df3427e9eb506123920f5598332 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 3 Mar 2026 16:46:51 +0100 Subject: [PATCH 03/11] Remove WebMock.allow_net_connect! leak from spec after hook Co-Authored-By: Claude Opus 4.6 --- spec/update_apps_cdn_build_metadata_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb index bdabf2d75..1d61b226a 100644 --- a/spec/update_apps_cdn_build_metadata_spec.rb +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -26,10 +26,6 @@ WebMock.disable_net_connect! end - after do - WebMock.allow_net_connect! - end - describe 'updating visibility' do it 'successfully updates the visibility to external' do stub_request(:post, api_url) From 36b03d8c4250848256eb1090166ffdf4b92e81d5 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 4 Mar 2026 13:52:56 +0100 Subject: [PATCH 04/11] Switch update_apps_cdn_build_metadata from v1.1 to WP REST API v2 The v1.1 API returns 500 for a8c_cdn_build custom post types. The WP REST API v2 endpoint works correctly for both reads and writes. - POST to /wp/v2/sites/{site_id}/a8c_cdn_build/{post_id} with JSON body - Visibility changes now look up taxonomy term IDs first - Response uses lowercase 'id' instead of 'ID' Co-Authored-By: Claude Opus 4.6 --- .../common/update_apps_cdn_build_metadata.rb | 56 +++++++--- spec/update_apps_cdn_build_metadata_spec.rb | 101 +++++++++++++----- 2 files changed, 115 insertions(+), 42 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index 66d4c337c..9af072850 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -14,20 +14,24 @@ class UpdateAppsCdnBuildMetadataAction < Action def self.run(params) UI.message("Updating Apps CDN build metadata for post #{params[:post_id]}...") - api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/posts/#{params[:post_id]}" - uri = URI.parse(api_endpoint) + # Build the JSON body for the WP REST API v2 + body = {} + body['status'] = params[:post_status] if params[:post_status] - # Build the update form data - form_data = {} - form_data['terms[visibility]'] = params[:visibility].to_s.capitalize if params[:visibility] - form_data['status'] = params[:post_status] if params[:post_status] + if params[:visibility] + term_id = lookup_visibility_term_id(site_id: params[:site_id], api_token: params[:api_token], visibility: params[:visibility]) + body['visibility'] = [term_id] + end - UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if form_data.empty? + UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if body.empty? + + api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{params[:site_id]}/a8c_cdn_build/#{params[:post_id]}" + uri = URI.parse(api_endpoint) # Create and send the HTTP request request = Net::HTTP::Post.new(uri.request_uri) - request.body = URI.encode_www_form(form_data) - request['Content-Type'] = 'application/x-www-form-urlencoded' + request.body = JSON.generate(body) + request['Content-Type'] = 'application/json' request['Accept'] = 'application/json' request['Authorization'] = "Bearer #{params[:api_token]}" @@ -41,7 +45,7 @@ def self.run(params) case response when Net::HTTPSuccess result = JSON.parse(response.body) - post_id = result['ID'] + post_id = result['id'] UI.success("Successfully updated Apps CDN build metadata for post #{post_id}") @@ -53,6 +57,32 @@ def self.run(params) end end + # Look up the taxonomy term ID for a visibility value (e.g. :internal -> 1316) + def self.lookup_visibility_term_id(site_id:, api_token:, visibility:) + slug = visibility.to_s.downcase + api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{site_id}/visibility?slug=#{slug}" + uri = URI.parse(api_endpoint) + + request = Net::HTTP::Get.new(uri.request_uri) + request['Accept'] = 'application/json' + request['Authorization'] = "Bearer #{api_token}" + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.open_timeout = 10 + http.read_timeout = 30 + http.request(request) + end + + case response + when Net::HTTPSuccess + terms = JSON.parse(response.body) + UI.user_error!("No visibility term found for '#{slug}'") if terms.empty? + terms.first['id'] + else + UI.user_error!("Failed to look up visibility term '#{slug}': #{response.code} #{response.message}") + end + end + def self.description 'Updates metadata of an existing build on the Apps CDN' end @@ -67,8 +97,8 @@ def self.return_value def self.details <<~DETAILS - Updates metadata (such as visibility) for an existing build post on a WordPress blog - that has the Apps CDN plugin enabled, using the WordPress.com REST API. + Updates metadata (such as post status or visibility) for an existing build post on a WordPress blog + that has the Apps CDN plugin enabled, using the WordPress.com REST API (WP v2). See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin. DETAILS end @@ -135,7 +165,7 @@ def self.example_code site_id: "12345678", api_token: ENV["WPCOM_API_TOKEN"], post_id: 98765, - visibility: :external + post_status: "publish" )', 'update_apps_cdn_build_metadata( site_id: "12345678", diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb index 1d61b226a..8a8db2554 100644 --- a/spec/update_apps_cdn_build_metadata_spec.rb +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -6,19 +6,20 @@ describe Fastlane::Actions::UpdateAppsCdnBuildMetadataAction do let(:test_site_id) { '12345678' } let(:test_post_id) { 98_765 } - let(:api_url) { "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/posts/#{test_post_id}" } + let(:api_url) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}/a8c_cdn_build/#{test_post_id}" } + let(:visibility_term_url) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}/visibility" } let(:test_api_token) { 'test_api_token' } + let(:external_term_id) { 21_293 } + let(:internal_term_id) { 1_316 } + let(:stub_success_response) do { - ID: test_post_id, - title: 'WordPress.com Studio 1.7.5', + id: test_post_id, + title: { rendered: 'WordPress.com Studio 1.7.5' }, status: 'publish', - terms: { - visibility: { - External: { ID: 1, name: 'External', slug: 'external' } - } - } + visibility: [external_term_id], + class_list: ['visibility-external'] }.to_json end @@ -28,6 +29,14 @@ describe 'updating visibility' do it 'successfully updates the visibility to external' do + stub_request(:get, visibility_term_url) + .with(query: { 'slug' => 'external' }) + .to_return( + status: 200, + body: [{ 'id' => external_term_id, 'name' => 'External', 'slug' => 'external' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + stub_request(:post, api_url) .to_return( status: 200, @@ -48,21 +57,27 @@ expect(WebMock).to( have_requested(:post, api_url).with do |req| expect(req.headers['Authorization']).to eq("Bearer #{test_api_token}") - expect(req.headers['Content-Type']).to eq('application/x-www-form-urlencoded') - expect(req.body).to include('terms%5Bvisibility%5D=External') + expect(req.headers['Content-Type']).to eq('application/json') + body = JSON.parse(req.body) + expect(body['visibility']).to eq([external_term_id]) true end ) end it 'successfully updates the visibility to internal' do + stub_request(:get, visibility_term_url) + .with(query: { 'slug' => 'internal' }) + .to_return( + status: 200, + body: [{ 'id' => internal_term_id, 'name' => 'Internal', 'slug' => 'internal' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + internal_response = { - ID: test_post_id, - terms: { - visibility: { - Internal: { ID: 2, name: 'Internal', slug: 'internal' } - } - } + id: test_post_id, + visibility: [internal_term_id], + class_list: ['visibility-internal'] }.to_json stub_request(:post, api_url) @@ -83,7 +98,8 @@ expect(WebMock).to( have_requested(:post, api_url).with do |req| - expect(req.body).to include('terms%5Bvisibility%5D=Internal') + body = JSON.parse(req.body) + expect(body['visibility']).to eq([internal_term_id]) true end ) @@ -95,7 +111,7 @@ stub_request(:post, api_url) .to_return( status: 200, - body: stub_success_response, + body: { id: test_post_id, status: 'draft' }.to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -110,7 +126,8 @@ expect(WebMock).to( have_requested(:post, api_url).with do |req| - expect(req.body).to include('status=draft') + body = JSON.parse(req.body) + expect(body['status']).to eq('draft') true end ) @@ -119,6 +136,14 @@ describe 'updating multiple fields' do it 'successfully updates both visibility and post_status' do + stub_request(:get, visibility_term_url) + .with(query: { 'slug' => 'external' }) + .to_return( + status: 200, + body: [{ 'id' => external_term_id, 'name' => 'External', 'slug' => 'external' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + stub_request(:post, api_url) .to_return( status: 200, @@ -138,8 +163,9 @@ expect(WebMock).to( have_requested(:post, api_url).with do |req| - expect(req.body).to include('terms%5Bvisibility%5D=External') - expect(req.body).to include('status=publish') + body = JSON.parse(req.body) + expect(body['visibility']).to eq([external_term_id]) + expect(body['status']).to eq('publish') true end ) @@ -151,7 +177,7 @@ stub_request(:post, api_url) .to_return( status: 403, - body: { error: 'unauthorized', message: 'You are not authorized to access this resource.' }.to_json, + body: { code: 'rest_forbidden', message: 'You are not authorized.' }.to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -160,7 +186,7 @@ site_id: test_site_id, api_token: test_api_token, post_id: test_post_id, - visibility: :external + post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') end @@ -178,10 +204,29 @@ site_id: test_site_id, api_token: test_api_token, post_id: test_post_id, - visibility: :external + post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') end + + it 'handles visibility term lookup failure' do + stub_request(:get, visibility_term_url) + .with(query: { 'slug' => 'external' }) + .to_return( + status: 200, + body: [].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_id: test_post_id, + visibility: :external + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "No visibility term found for 'external'") + end end describe 'parameter validation' do @@ -191,7 +236,7 @@ site_id: '', api_token: test_api_token, post_id: test_post_id, - visibility: :external + post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Site ID cannot be empty') end @@ -202,7 +247,7 @@ site_id: test_site_id, api_token: '', post_id: test_post_id, - visibility: :external + post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') end @@ -213,7 +258,7 @@ site_id: test_site_id, api_token: test_api_token, post_id: -1, - visibility: :external + post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post ID must be a positive integer') end @@ -241,8 +286,6 @@ end it 'fails if no metadata to update is provided' do - stub_request(:post, api_url) # Shouldn't be reached - expect do run_described_fastlane_action( site_id: test_site_id, From 3276a267e7277013e3d6047d3987d5512055951a Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Fri, 6 Mar 2026 19:10:28 +0100 Subject: [PATCH 05/11] Accept array of post_ids in update_apps_cdn_build_metadata Change post_id (Integer) to post_ids (Array) so callers can update multiple builds in one call. The visibility term lookup is performed only once and reused across all posts, avoiding redundant API calls. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../common/update_apps_cdn_build_metadata.rb | 56 +++++---- spec/update_apps_cdn_build_metadata_spec.rb | 118 ++++++++++++++---- 3 files changed, 127 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6210303..5e101aa13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Added new `update_apps_cdn_build_metadata` action to update metadata (e.g. visibility) of an existing build on the Apps CDN without re-uploading the file. This enables a two-phase release flow: upload builds as Internal first, then flip to External at publish time. [#701] +- Added new `update_apps_cdn_build_metadata` action to update metadata (e.g. visibility) of one or more existing builds on the Apps CDN without re-uploading the files. Accepts an array of `post_ids` and performs the visibility term lookup only once. This enables a two-phase release flow: upload builds as Internal first, then flip to External at publish time. [#701] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index 9af072850..b1cefd187 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -12,9 +12,10 @@ class UpdateAppsCdnBuildMetadataAction < Action VALID_POST_STATUS = %w[publish draft].freeze def self.run(params) - UI.message("Updating Apps CDN build metadata for post #{params[:post_id]}...") + post_ids = params[:post_ids] + UI.message("Updating Apps CDN build metadata for #{post_ids.size} post(s): #{post_ids.join(', ')}...") - # Build the JSON body for the WP REST API v2 + # Build the base JSON body for the WP REST API v2 body = {} body['status'] = params[:post_status] if params[:post_status] @@ -25,15 +26,24 @@ def self.run(params) UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if body.empty? - api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{params[:site_id]}/a8c_cdn_build/#{params[:post_id]}" + results = post_ids.map do |post_id| + update_single_post(site_id: params[:site_id], api_token: params[:api_token], post_id: post_id, body: body) + end + + UI.success("Successfully updated Apps CDN build metadata for #{results.size} post(s)") + results + end + + # Update a single CDN build post with the given body. + def self.update_single_post(site_id:, api_token:, post_id:, body:) + api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{site_id}/a8c_cdn_build/#{post_id}" uri = URI.parse(api_endpoint) - # Create and send the HTTP request request = Net::HTTP::Post.new(uri.request_uri) request.body = JSON.generate(body) request['Content-Type'] = 'application/json' request['Accept'] = 'application/json' - request['Authorization'] = "Bearer #{params[:api_token]}" + request['Authorization'] = "Bearer #{api_token}" response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.open_timeout = 10 @@ -41,19 +51,18 @@ def self.run(params) http.request(request) end - # Handle the response case response when Net::HTTPSuccess result = JSON.parse(response.body) - post_id = result['id'] + updated_id = result['id'] - UI.success("Successfully updated Apps CDN build metadata for post #{post_id}") + UI.message(" Updated post #{updated_id}") - { post_id: post_id } + { post_id: updated_id } else - UI.error("Failed to update Apps CDN build metadata: #{response.code} #{response.message}") + UI.error("Failed to update Apps CDN build metadata for post #{post_id}: #{response.code} #{response.message}") UI.error(response.body) - UI.user_error!('Update of Apps CDN build metadata failed') + UI.user_error!("Update of Apps CDN build metadata failed for post #{post_id}") end end @@ -84,7 +93,7 @@ def self.lookup_visibility_term_id(site_id:, api_token:, visibility:) end def self.description - 'Updates metadata of an existing build on the Apps CDN' + 'Updates metadata of one or more existing builds on the Apps CDN' end def self.authors @@ -92,13 +101,14 @@ def self.authors end def self.return_value - 'Returns a Hash containing { post_id: }. On error, raises a FastlaneError.' + 'Returns an Array of Hashes, each containing { post_id: }. On error, raises a FastlaneError.' end def self.details <<~DETAILS - Updates metadata (such as post status or visibility) for an existing build post on a WordPress blog + Updates metadata (such as post status or visibility) for one or more existing build posts on a WordPress blog that has the Apps CDN plugin enabled, using the WordPress.com REST API (WP v2). + When updating visibility for multiple posts, the visibility term ID is looked up only once. See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin. DETAILS end @@ -116,12 +126,15 @@ def self.available_options end ), FastlaneCore::ConfigItem.new( - key: :post_id, - description: 'The ID of the build post to update', + key: :post_ids, + description: 'The IDs of the build posts to update', optional: false, - type: Integer, + type: Array, verify_block: proc do |value| - UI.user_error!('Post ID must be a positive integer') unless value.is_a?(Integer) && value.positive? + UI.user_error!('Post IDs must be a non-empty array') unless value.is_a?(Array) && !value.empty? + value.each do |id| + UI.user_error!("Each post ID must be a positive integer, got: #{id.inspect}") unless id.is_a?(Integer) && id.positive? + end end ), FastlaneCore::ConfigItem.new( @@ -164,15 +177,14 @@ def self.example_code 'update_apps_cdn_build_metadata( site_id: "12345678", api_token: ENV["WPCOM_API_TOKEN"], - post_id: 98765, + post_ids: [98765], post_status: "publish" )', 'update_apps_cdn_build_metadata( site_id: "12345678", api_token: ENV["WPCOM_API_TOKEN"], - post_id: 98765, - visibility: :internal, - post_status: "draft" + post_ids: [12345, 67890, 11111], + visibility: :external )', ] end diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb index 8a8db2554..b00090a49 100644 --- a/spec/update_apps_cdn_build_metadata_spec.rb +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -6,7 +6,9 @@ describe Fastlane::Actions::UpdateAppsCdnBuildMetadataAction do let(:test_site_id) { '12345678' } let(:test_post_id) { 98_765 } + let(:second_post_id) { 54_321 } let(:api_url) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}/a8c_cdn_build/#{test_post_id}" } + let(:second_api_url) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}/a8c_cdn_build/#{second_post_id}" } let(:visibility_term_url) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}/visibility" } let(:test_api_token) { 'test_api_token' } @@ -44,15 +46,16 @@ headers: { 'Content-Type' => 'application/json' } ) - result = run_described_fastlane_action( + results = run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], visibility: :external ) - expect(result).to be_a(Hash) - expect(result[:post_id]).to eq(test_post_id) + expect(results).to be_an(Array) + expect(results.size).to eq(1) + expect(results.first[:post_id]).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -87,14 +90,14 @@ headers: { 'Content-Type' => 'application/json' } ) - result = run_described_fastlane_action( + results = run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], visibility: :internal ) - expect(result[:post_id]).to eq(test_post_id) + expect(results.first[:post_id]).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -106,6 +109,47 @@ end end + describe 'batch updating multiple posts' do + it 'updates all posts with a single visibility term lookup' do + stub_request(:get, visibility_term_url) + .with(query: { 'slug' => 'external' }) + .to_return( + status: 200, + body: [{ 'id' => external_term_id, 'name' => 'External', 'slug' => 'external' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_request(:post, api_url) + .to_return( + status: 200, + body: { id: test_post_id }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_request(:post, second_api_url) + .to_return( + status: 200, + body: { id: second_post_id }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + results = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_ids: [test_post_id, second_post_id], + visibility: :external + ) + + expect(results.size).to eq(2) + expect(results.map { |r| r[:post_id] }).to eq([test_post_id, second_post_id]) + + # Visibility term lookup should have been called only once + expect(WebMock).to have_requested(:get, visibility_term_url).with(query: { 'slug' => 'external' }).once + expect(WebMock).to have_requested(:post, api_url).once + expect(WebMock).to have_requested(:post, second_api_url).once + end + end + describe 'updating post_status' do it 'successfully updates the post_status' do stub_request(:post, api_url) @@ -115,14 +159,14 @@ headers: { 'Content-Type' => 'application/json' } ) - result = run_described_fastlane_action( + results = run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'draft' ) - expect(result[:post_id]).to eq(test_post_id) + expect(results.first[:post_id]).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -151,15 +195,15 @@ headers: { 'Content-Type' => 'application/json' } ) - result = run_described_fastlane_action( + results = run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], visibility: :external, post_status: 'publish' ) - expect(result[:post_id]).to eq(test_post_id) + expect(results.first[:post_id]).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -185,10 +229,10 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'publish' ) - end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') + end.to raise_error(FastlaneCore::Interface::FastlaneError, "Update of Apps CDN build metadata failed for post #{test_post_id}") end it 'handles server errors properly' do @@ -203,10 +247,10 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'publish' ) - end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Update of Apps CDN build metadata failed') + end.to raise_error(FastlaneCore::Interface::FastlaneError, "Update of Apps CDN build metadata failed for post #{test_post_id}") end it 'handles visibility term lookup failure' do @@ -222,7 +266,7 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], visibility: :external ) end.to raise_error(FastlaneCore::Interface::FastlaneError, "No visibility term found for 'external'") @@ -235,7 +279,7 @@ run_described_fastlane_action( site_id: '', api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Site ID cannot be empty') @@ -246,21 +290,43 @@ run_described_fastlane_action( site_id: test_site_id, api_token: '', - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'publish' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') end - it 'fails if post_id is not a positive integer' do + it 'fails if post_ids is a single integer instead of an array' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_ids: test_post_id, + post_status: 'publish' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /value must be either `Array` or `comma-separated String`/) + end + + it 'fails if post_ids is empty' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_ids: [], + post_status: 'publish' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post IDs must be a non-empty array') + end + + it 'fails if post_ids contains a non-positive integer' do expect do run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: -1, + post_ids: [-1], post_status: 'publish' ) - end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post ID must be a positive integer') + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Each post ID must be a positive integer, got: -1') end it 'fails if visibility is not a valid symbol' do @@ -268,7 +334,7 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], visibility: :public ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Visibility must be one of: `:internal`, `:external`') @@ -279,7 +345,7 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id, + post_ids: [test_post_id], post_status: 'invalid_status' ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post status must be one of: publish, draft') @@ -290,7 +356,7 @@ run_described_fastlane_action( site_id: test_site_id, api_token: test_api_token, - post_id: test_post_id + post_ids: [test_post_id] ) end.to raise_error(FastlaneCore::Interface::FastlaneError, 'No metadata to update. Provide at least one of: visibility, post_status') end From f0a30219935b63e4c95c8dab7b3e29308890f106 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 10 Mar 2026 21:08:42 +0100 Subject: [PATCH 06/11] Extract shared Apps CDN constants and URL helpers into AppsCdnHelper --- .../common/update_apps_cdn_build_metadata.rb | 9 ++--- .../common/upload_build_to_apps_cdn.rb | 9 +++-- .../helper/apps_cdn_helper.rb | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index b1cefd187..c785cade0 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -4,12 +4,13 @@ require 'net/http' require 'uri' require 'json' +require_relative '../../helper/apps_cdn_helper' module Fastlane module Actions class UpdateAppsCdnBuildMetadataAction < Action - VALID_VISIBILITIES = %i[internal external].freeze - VALID_POST_STATUS = %w[publish draft].freeze + VALID_VISIBILITIES = Helper::AppsCdnHelper::VALID_VISIBILITIES + VALID_POST_STATUS = Helper::AppsCdnHelper::VALID_POST_STATUS def self.run(params) post_ids = params[:post_ids] @@ -36,7 +37,7 @@ def self.run(params) # Update a single CDN build post with the given body. def self.update_single_post(site_id:, api_token:, post_id:, body:) - api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{site_id}/a8c_cdn_build/#{post_id}" + api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "a8c_cdn_build/#{post_id}") uri = URI.parse(api_endpoint) request = Net::HTTP::Post.new(uri.request_uri) @@ -69,7 +70,7 @@ def self.update_single_post(site_id:, api_token:, post_id:, body:) # Look up the taxonomy term ID for a visibility value (e.g. :internal -> 1316) def self.lookup_visibility_term_id(site_id:, api_token:, visibility:) slug = visibility.to_s.downcase - api_endpoint = "https://public-api.wordpress.com/wp/v2/sites/#{site_id}/visibility?slug=#{slug}" + api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "visibility?slug=#{slug}") uri = URI.parse(api_endpoint) request = Net::HTTP::Get.new(uri.request_uri) 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 index 740c50ef3..f09b349a2 100644 --- 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 @@ -4,6 +4,7 @@ require 'net/http' require 'uri' require 'json' +require_relative '../../helper/apps_cdn_helper' module Fastlane module Actions @@ -17,8 +18,7 @@ module SharedValues class UploadBuildToAppsCdnAction < Action # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-resource-type.php RESOURCE_TYPE = 'Build' - # These are from the WordPress.com API, not the Apps CDN plugin - VALID_POST_STATUS = %w[publish draft].freeze + VALID_POST_STATUS = Helper::AppsCdnHelper::VALID_POST_STATUS # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-build-type.php VALID_BUILD_TYPES = %w[ Alpha @@ -46,8 +46,7 @@ class UploadBuildToAppsCdnAction < Action 'Full Install', 'Update', ].freeze - # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-visibility.php - VALID_VISIBILITIES = %i[internal external].freeze + VALID_VISIBILITIES = Helper::AppsCdnHelper::VALID_VISIBILITIES def self.run(params) UI.message('Uploading build to Apps CDN...') @@ -55,7 +54,7 @@ def self.run(params) 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" + api_endpoint = Helper::AppsCdnHelper.rest_v1_1_url(site_id: params[:site_id], path: 'media/new') uri = URI.parse(api_endpoint) # Create the request body and headers diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb new file mode 100644 index 000000000..524e103bf --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Fastlane + module Helper + module AppsCdnHelper + API_BASE_URL = 'https://public-api.wordpress.com' + + # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-visibility.php + VALID_VISIBILITIES = %i[internal external].freeze + + # These are from the WordPress.com API, not the Apps CDN plugin + VALID_POST_STATUS = %w[publish draft].freeze + + # Builds a WordPress.com REST API v1.1 URL scoped to a site. + # + # @param site_id [String] the WordPress.com site ID + # @param path [String] the API path relative to the site (e.g. 'media/new') + # @return [String] the full API URL + def self.rest_v1_1_url(site_id:, path:) + "#{API_BASE_URL}/rest/v1.1/sites/#{site_id}/#{path}" + end + + # Builds a WordPress.com WP REST API v2 URL scoped to a site. + # + # @param site_id [String] the WordPress.com site ID + # @param path [String] the API path relative to the site (e.g. 'a8c_cdn_build/123') + # @return [String] the full API URL + def self.wp_v2_url(site_id:, path:) + "#{API_BASE_URL}/wp/v2/sites/#{site_id}/#{path}" + end + end + end +end From 6df3e3e013ea37e50b4fb53cd2270b6e3fe60a4b Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 10 Mar 2026 21:09:37 +0100 Subject: [PATCH 07/11] Return post ID directly from update_single_post instead of Hash --- .../actions/common/update_apps_cdn_build_metadata.rb | 4 ++-- spec/update_apps_cdn_build_metadata_spec.rb | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index c785cade0..c77f7b1e7 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -59,7 +59,7 @@ def self.update_single_post(site_id:, api_token:, post_id:, body:) UI.message(" Updated post #{updated_id}") - { post_id: updated_id } + updated_id else UI.error("Failed to update Apps CDN build metadata for post #{post_id}: #{response.code} #{response.message}") UI.error(response.body) @@ -102,7 +102,7 @@ def self.authors end def self.return_value - 'Returns an Array of Hashes, each containing { post_id: }. On error, raises a FastlaneError.' + 'Returns an Array of post IDs (Integer) that were successfully updated. On error, raises a FastlaneError.' end def self.details diff --git a/spec/update_apps_cdn_build_metadata_spec.rb b/spec/update_apps_cdn_build_metadata_spec.rb index b00090a49..5cc120a75 100644 --- a/spec/update_apps_cdn_build_metadata_spec.rb +++ b/spec/update_apps_cdn_build_metadata_spec.rb @@ -55,7 +55,7 @@ expect(results).to be_an(Array) expect(results.size).to eq(1) - expect(results.first[:post_id]).to eq(test_post_id) + expect(results.first).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -97,7 +97,7 @@ visibility: :internal ) - expect(results.first[:post_id]).to eq(test_post_id) + expect(results.first).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -141,7 +141,7 @@ ) expect(results.size).to eq(2) - expect(results.map { |r| r[:post_id] }).to eq([test_post_id, second_post_id]) + expect(results).to eq([test_post_id, second_post_id]) # Visibility term lookup should have been called only once expect(WebMock).to have_requested(:get, visibility_term_url).with(query: { 'slug' => 'external' }).once @@ -166,7 +166,7 @@ post_status: 'draft' ) - expect(results.first[:post_id]).to eq(test_post_id) + expect(results.first).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| @@ -203,7 +203,7 @@ post_status: 'publish' ) - expect(results.first[:post_id]).to eq(test_post_id) + expect(results.first).to eq(test_post_id) expect(WebMock).to( have_requested(:post, api_url).with do |req| From d4d539d07e93859c1d8bd56082fd4d8676459ec7 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 10 Mar 2026 21:10:21 +0100 Subject: [PATCH 08/11] Add YARD docs to update_single_post and lookup_visibility_term_id --- .../common/update_apps_cdn_build_metadata.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index c77f7b1e7..86d32ba14 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -35,7 +35,14 @@ def self.run(params) results end - # Update a single CDN build post with the given body. + # Update a single CDN build post with the given body via the WP REST API v2. + # + # @param site_id [String] the WordPress.com site ID + # @param api_token [String] the WordPress.com API bearer token + # @param post_id [Integer] the ID of the post to update + # @param body [Hash] the JSON body to send in the POST request + # @return [Integer] the ID of the updated post + # @raise [FastlaneCore::Interface::FastlaneError] if the API returns a non-success response def self.update_single_post(site_id:, api_token:, post_id:, body:) api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "a8c_cdn_build/#{post_id}") uri = URI.parse(api_endpoint) @@ -67,7 +74,13 @@ def self.update_single_post(site_id:, api_token:, post_id:, body:) end end - # Look up the taxonomy term ID for a visibility value (e.g. :internal -> 1316) + # Look up the taxonomy term ID for a visibility value (e.g. :internal -> 1316). + # + # @param site_id [String] the WordPress.com site ID + # @param api_token [String] the WordPress.com API bearer token + # @param visibility [Symbol] the visibility to look up (:internal or :external) + # @return [Integer] the taxonomy term ID for the given visibility + # @raise [FastlaneCore::Interface::FastlaneError] if no term is found or the API returns a non-success response def self.lookup_visibility_term_id(site_id:, api_token:, visibility:) slug = visibility.to_s.downcase api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "visibility?slug=#{slug}") From 185f5ed6266e6f714c3be1d1d14c61e72a941333 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 11 Mar 2026 20:59:28 +0100 Subject: [PATCH 09/11] Update `AppsCdnHelper` to return parsed URIs --- .../common/update_apps_cdn_build_metadata.rb | 7 ++----- .../actions/common/upload_build_to_apps_cdn.rb | 3 +-- .../wpmreleasetoolkit/helper/apps_cdn_helper.rb | 14 ++++++++------ 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index 86d32ba14..c239271e4 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -2,7 +2,6 @@ require 'fastlane/action' require 'net/http' -require 'uri' require 'json' require_relative '../../helper/apps_cdn_helper' @@ -44,8 +43,7 @@ def self.run(params) # @return [Integer] the ID of the updated post # @raise [FastlaneCore::Interface::FastlaneError] if the API returns a non-success response def self.update_single_post(site_id:, api_token:, post_id:, body:) - api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "a8c_cdn_build/#{post_id}") - uri = URI.parse(api_endpoint) + uri = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "a8c_cdn_build/#{post_id}") request = Net::HTTP::Post.new(uri.request_uri) request.body = JSON.generate(body) @@ -83,8 +81,7 @@ def self.update_single_post(site_id:, api_token:, post_id:, body:) # @raise [FastlaneCore::Interface::FastlaneError] if no term is found or the API returns a non-success response def self.lookup_visibility_term_id(site_id:, api_token:, visibility:) slug = visibility.to_s.downcase - api_endpoint = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "visibility?slug=#{slug}") - uri = URI.parse(api_endpoint) + uri = Helper::AppsCdnHelper.wp_v2_url(site_id: site_id, path: "visibility?slug=#{slug}") request = Net::HTTP::Get.new(uri.request_uri) request['Accept'] = 'application/json' 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 index f09b349a2..20ed5a21d 100644 --- 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 @@ -54,8 +54,7 @@ def self.run(params) file_path = params[:file_path] UI.user_error!("File not found at path '#{file_path}'") unless File.exist?(file_path) - api_endpoint = Helper::AppsCdnHelper.rest_v1_1_url(site_id: params[:site_id], path: 'media/new') - uri = URI.parse(api_endpoint) + uri = Helper::AppsCdnHelper.rest_v1_1_url(site_id: params[:site_id], path: 'media/new') # Create the request body and headers parameters = { diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb index 524e103bf..e9a58cdb0 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'uri' + module Fastlane module Helper module AppsCdnHelper @@ -11,22 +13,22 @@ module AppsCdnHelper # These are from the WordPress.com API, not the Apps CDN plugin VALID_POST_STATUS = %w[publish draft].freeze - # Builds a WordPress.com REST API v1.1 URL scoped to a site. + # Builds a WordPress.com REST API v1.1 URI scoped to a site. # # @param site_id [String] the WordPress.com site ID # @param path [String] the API path relative to the site (e.g. 'media/new') - # @return [String] the full API URL + # @return [URI] the parsed full API URI def self.rest_v1_1_url(site_id:, path:) - "#{API_BASE_URL}/rest/v1.1/sites/#{site_id}/#{path}" + URI.parse("#{API_BASE_URL}/rest/v1.1/sites/#{site_id}/#{path}") end - # Builds a WordPress.com WP REST API v2 URL scoped to a site. + # Builds a WordPress.com WP REST API v2 URI scoped to a site. # # @param site_id [String] the WordPress.com site ID # @param path [String] the API path relative to the site (e.g. 'a8c_cdn_build/123') - # @return [String] the full API URL + # @return [URI] the parsed full API URI def self.wp_v2_url(site_id:, path:) - "#{API_BASE_URL}/wp/v2/sites/#{site_id}/#{path}" + URI.parse("#{API_BASE_URL}/wp/v2/sites/#{site_id}/#{path}") end end end From a79094ea1ebcaecdafc8bdf50ee90f73c9316da9 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 11 Mar 2026 21:37:17 +0100 Subject: [PATCH 10/11] Update code to move visibilities validation into AppsCdnHelper --- .../common/update_apps_cdn_build_metadata.rb | 11 ++-------- .../common/upload_build_to_apps_cdn.rb | 11 ++-------- .../helper/apps_cdn_helper.rb | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index c239271e4..693707593 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -8,9 +8,6 @@ module Fastlane module Actions class UpdateAppsCdnBuildMetadataAction < Action - VALID_VISIBILITIES = Helper::AppsCdnHelper::VALID_VISIBILITIES - VALID_POST_STATUS = Helper::AppsCdnHelper::VALID_POST_STATUS - def self.run(params) post_ids = params[:post_ids] UI.message("Updating Apps CDN build metadata for #{post_ids.size} post(s): #{post_ids.join(', ')}...") @@ -163,18 +160,14 @@ def self.available_options description: 'The new visibility for the build (:internal or :external)', optional: true, type: Symbol, - verify_block: proc do |value| - UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) - end + verify_block: Helper::AppsCdnHelper.verify_visibility ), FastlaneCore::ConfigItem.new( key: :post_status, description: "The new post status ('publish' or 'draft')", optional: true, 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 + verify_block: Helper::AppsCdnHelper.verify_post_status ), ] end 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 index 20ed5a21d..9e03eb9d3 100644 --- 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 @@ -18,7 +18,6 @@ module SharedValues class UploadBuildToAppsCdnAction < Action # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-resource-type.php RESOURCE_TYPE = 'Build' - VALID_POST_STATUS = Helper::AppsCdnHelper::VALID_POST_STATUS # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-build-type.php VALID_BUILD_TYPES = %w[ Alpha @@ -46,8 +45,6 @@ class UploadBuildToAppsCdnAction < Action 'Full Install', 'Update', ].freeze - VALID_VISIBILITIES = Helper::AppsCdnHelper::VALID_VISIBILITIES - def self.run(params) UI.message('Uploading build to Apps CDN...') @@ -257,9 +254,7 @@ def self.available_options description: 'The visibility of the build (:internal or :external)', optional: false, type: Symbol, - verify_block: proc do |value| - UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) - end + verify_block: Helper::AppsCdnHelper.verify_visibility ), FastlaneCore::ConfigItem.new( key: :post_status, @@ -267,9 +262,7 @@ def self.available_options 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 + verify_block: Helper::AppsCdnHelper.verify_post_status ), FastlaneCore::ConfigItem.new( key: :version, diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb index e9a58cdb0..dc73d2129 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb @@ -30,6 +30,26 @@ def self.rest_v1_1_url(site_id:, path:) def self.wp_v2_url(site_id:, path:) URI.parse("#{API_BASE_URL}/wp/v2/sites/#{site_id}/#{path}") end + + # Returns a proc that validates a visibility value against {VALID_VISIBILITIES}. + # Intended for use as a `verify_block` in Fastlane ConfigItem definitions. + # + # @return [Proc] a proc that raises FastlaneError if the value is invalid + def self.verify_visibility + proc do |value| + UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) + end + end + + # Returns a proc that validates a post status value against {VALID_POST_STATUS}. + # Intended for use as a `verify_block` in Fastlane ConfigItem definitions. + # + # @return [Proc] a proc that raises FastlaneError if the value is invalid + def self.verify_post_status + proc do |value| + UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value) + end + end end end end From 1a4e9d3bfce72ab9c596555d0ca7aa81ad5f5f96 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 11 Mar 2026 22:09:30 +0100 Subject: [PATCH 11/11] Update code to make it clearer validation methods are parameters --- .../actions/common/update_apps_cdn_build_metadata.rb | 4 ++-- .../actions/common/upload_build_to_apps_cdn.rb | 4 ++-- .../plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb index 693707593..26c18ed36 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/update_apps_cdn_build_metadata.rb @@ -160,14 +160,14 @@ def self.available_options description: 'The new visibility for the build (:internal or :external)', optional: true, type: Symbol, - verify_block: Helper::AppsCdnHelper.verify_visibility + verify_block: Helper::AppsCdnHelper.verify_visibility_param ), FastlaneCore::ConfigItem.new( key: :post_status, description: "The new post status ('publish' or 'draft')", optional: true, type: String, - verify_block: Helper::AppsCdnHelper.verify_post_status + verify_block: Helper::AppsCdnHelper.verify_post_status_param ), ] end 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 index 9e03eb9d3..6da7b2c92 100644 --- 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 @@ -254,7 +254,7 @@ def self.available_options description: 'The visibility of the build (:internal or :external)', optional: false, type: Symbol, - verify_block: Helper::AppsCdnHelper.verify_visibility + verify_block: Helper::AppsCdnHelper.verify_visibility_param ), FastlaneCore::ConfigItem.new( key: :post_status, @@ -262,7 +262,7 @@ def self.available_options optional: true, default_value: 'publish', type: String, - verify_block: Helper::AppsCdnHelper.verify_post_status + verify_block: Helper::AppsCdnHelper.verify_post_status_param ), FastlaneCore::ConfigItem.new( key: :version, diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb index dc73d2129..97db097b3 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb @@ -31,21 +31,21 @@ def self.wp_v2_url(site_id:, path:) URI.parse("#{API_BASE_URL}/wp/v2/sites/#{site_id}/#{path}") end - # Returns a proc that validates a visibility value against {VALID_VISIBILITIES}. + # Returns a proc that validates a visibility parameter value against {VALID_VISIBILITIES}. # Intended for use as a `verify_block` in Fastlane ConfigItem definitions. # # @return [Proc] a proc that raises FastlaneError if the value is invalid - def self.verify_visibility + def self.verify_visibility_param proc do |value| UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) end end - # Returns a proc that validates a post status value against {VALID_POST_STATUS}. + # Returns a proc that validates a post status parameter value against {VALID_POST_STATUS}. # Intended for use as a `verify_block` in Fastlane ConfigItem definitions. # # @return [Proc] a proc that raises FastlaneError if the value is invalid - def self.verify_post_status + def self.verify_post_status_param proc do |value| UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value) end