Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ _None_

### New Features

_None_
- 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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# frozen_string_literal: true

require 'fastlane/action'
require 'net/http'
require 'json'
require_relative '../../helper/apps_cdn_helper'

module Fastlane
module Actions
class UpdateAppsCdnBuildMetadataAction < Action
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(', ')}...")

# Build the base JSON body for the WP REST API v2
body = {}
body['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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably more of a question for Hannah but it feels surprising to me that the API would technically allow an array if values for this metadata instead of a single one 🤔

Like, what would happen at the API level if we called the API with {"visibility":[1345, 1367]} body (where, say, 1345 is internal and 1367 is external, for example) 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checked using the API. It indeed accepts multiple term IDs and assigns both:

POST /wp/v2/sites/{site_id}/a8c_cdn_build/737
{"visibility": [1316, 21293]}

→ 200 OK
{"id": 737, "visibility": [21293, 1316], ...}

So a post would end up being both "internal" and "external" at the same time 😅 it just treats visibility as a taxonomy that supports multiple terms.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! I wonder why we didn't have to pass an array but just a string in the API call that creates the build in upload_build_to_apps_cdn then 🤔 🤷

Also, this doesn't match the table in the FieldGuide documentation (interal ref: PCYsg-15tP-p2#How-to-upload-a-build-to-a-CDN-blog), so I guess one of the 2 should probably be updated.
I'd vote for only updating the AppsCDN plugin for accepting only one visibility (both in the API call and on the visibility metadata stored for a post taxonomy), but I'm not sure if that's technically feasible/easy (vs if this is an inherent technical limitation of how taxonomies work in WordPress? cc @hannahtinkler).

Anyway, I think ultimately it's not really critical (given the way we use the API and this visibility taxonomy we always set only one value in practice in all our call sites); as long as we're sure that calling update_build_to_apps_cdn with a one-item array [term_id] on a post that already had a different value set for visibility indeed replaces the visibility of that post with a single visibility value of term_id—as opposed to adding it to the existing ones.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I didn’t realise how annoying uploading via that endpoint would be with having to use the internal IDs, pass arrays etc. I looked at customising the update functionality for this post type, but it got complex to the point it seemed better to add a dedicated upload endpoint - you can find the docs for that here: PCYsg-15tP-p2 🙂

I also blocked edits via the standard REST post endpoints so the validation can be enforced - this should all be available in prod now! FYI, I also added a test site so we don't need to clean up testing calls in the prod CDN sites - you can find the details for that in AINFRA-2171.

end

UI.user_error!('No metadata to update. Provide at least one of: visibility, post_status') if body.empty?

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 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:)
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)
request['Content-Type'] = 'application/json'
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
result = JSON.parse(response.body)
updated_id = result['id']

UI.message(" Updated post #{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)
UI.user_error!("Update of Apps CDN build metadata failed for post #{post_id}")
end
end

# 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
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'
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 one or more existing builds on the Apps CDN'
end

def self.authors
['Automattic']
end

def self.return_value
'Returns an Array of post IDs (Integer) that were successfully updated. On error, raises a FastlaneError.'
end

def self.details
<<~DETAILS
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

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_ids,
description: 'The IDs of the build posts to update',
optional: false,
type: Array,
verify_block: proc do |value|
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(
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: 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_param
),
]
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_ids: [98765],
post_status: "publish"
)',
'update_apps_cdn_build_metadata(
site_id: "12345678",
api_token: ENV["WPCOM_API_TOKEN"],
post_ids: [12345, 67890, 11111],
visibility: :external
)',
]
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'net/http'
require 'uri'
require 'json'
require_relative '../../helper/apps_cdn_helper'

module Fastlane
module Actions
Expand All @@ -17,8 +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'
# These are from the WordPress.com API, not the Apps CDN plugin
VALID_POST_STATUS = %w[publish draft].freeze
# 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
Expand Down Expand Up @@ -46,17 +45,13 @@ 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

def self.run(params)
UI.message('Uploading build to Apps CDN...')

file_path = params[:file_path]
UI.user_error!("File not found at path '#{file_path}'") unless File.exist?(file_path)

api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/media/new"
uri = URI.parse(api_endpoint)
uri = Helper::AppsCdnHelper.rest_v1_1_url(site_id: params[:site_id], path: 'media/new')

# Create the request body and headers
parameters = {
Expand Down Expand Up @@ -259,19 +254,15 @@ 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_param
),
FastlaneCore::ConfigItem.new(
key: :post_status,
description: 'The post status (defaults to \'publish\')',
optional: true,
default_value: 'publish',
type: String,
verify_block: proc do |value|
UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value)
end
verify_block: Helper::AppsCdnHelper.verify_post_status_param
),
FastlaneCore::ConfigItem.new(
key: :version,
Expand Down
55 changes: 55 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/helper/apps_cdn_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require 'uri'

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 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 [URI] the parsed full API URI
def self.rest_v1_1_url(site_id:, path:)
URI.parse("#{API_BASE_URL}/rest/v1.1/sites/#{site_id}/#{path}")
end

# 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 [URI] the parsed full API URI
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 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_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 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_param
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
Loading