From 766f65ed1979475176c08599c9ba70e19a05b7ff Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 2 Mar 2026 21:06:03 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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