diff --git a/CHANGELOG.md b/CHANGELOG.md
index b39ab1f2d..44df305cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,8 @@ _None_
### New Features
-_None_
+- Add new `buildkite_annotate` action to add/remove annotations from the current build. [#442]
+- Add new `buildkite_metadata` action to set/get metadata from the current build. [#442]
### Bug Fixes
@@ -24,7 +25,7 @@ _None_
### New Features
-- Added Mac support to all `common` actions and any relevant `ios` actions [#439]
+- Add Mac support to all `common` actions and any relevant `ios` actions [#439]
## 6.2.0
diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_annotate_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_annotate_action.rb
new file mode 100644
index 000000000..2b0b2f34f
--- /dev/null
+++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_annotate_action.rb
@@ -0,0 +1,85 @@
+module Fastlane
+ module Actions
+ class BuildkiteAnnotateAction < Action
+ def self.run(params)
+ message = params[:message]
+ context = params[:context]
+ style = params[:style]
+
+ if message.nil?
+ # Delete an annotation, but swallow the error if the annotation didn't exist — to avoid having
+ # this action failing or printing a red log for no good reason — hence the `|| true`
+ ctx_param = "--context #{context.shellescape}" unless context.nil?
+ sh("buildkite-agent annotation remove #{ctx_param} || true")
+ else
+ # Add new annotation using `buildkite-agent`
+ extra_params = {
+ context: context,
+ style: style
+ }.compact.flat_map { |k, v| ["--#{k}", v] }
+ sh('buildkite-agent', 'annotate', *extra_params, params[:message])
+ end
+ end
+
+ #####################################################
+ # @!group Documentation
+ #####################################################
+
+ def self.description
+ 'Add or remove annotations to the current Buildkite build'
+ end
+
+ def self.details
+ <<~DETAILS
+ Add or remove annotations to the current Buildkite build.
+
+ Has to be run on a CI job (where a `buildkite-agent` is running), e.g. typically by a lane
+ that is triggered as part of a Buildkite CI step.
+
+ See https://buildkite.com/docs/agent/v3/cli-annotate
+ DETAILS
+ end
+
+ def self.available_options
+ [
+ FastlaneCore::ConfigItem.new(
+ key: :context,
+ env_name: 'BUILDKITE_ANNOTATION_CONTEXT',
+ description: 'The context of the annotation used to differentiate this annotation from others',
+ type: String,
+ optional: true
+ ),
+ FastlaneCore::ConfigItem.new(
+ key: :style,
+ env_name: 'BUILDKITE_ANNOTATION_STYLE',
+ description: 'The style of the annotation (`success`, `info`, `warning` or `error`)',
+ type: String,
+ optional: true,
+ verify_block: proc do |value|
+ valid_values = %w[success info warning error]
+ next if value.nil? || valid_values.include?(value)
+
+ UI.user_error!("Invalid value `#{value}` for parameter `style`. Valid values are: #{valid_values.join(', ')}")
+ end
+ ),
+ FastlaneCore::ConfigItem.new(
+ key: :message,
+ description: 'The message to use in the new annotation. Supports GFM-Flavored Markdown. ' \
+ + 'If message is nil, any existing annotation with the provided context will be deleted',
+ type: String,
+ optional: true,
+ default_value: nil # nil message = delete existing annotation if any
+ ),
+ ]
+ end
+
+ def self.authors
+ ['Automattic']
+ end
+
+ def self.is_supported?(platform)
+ true
+ end
+ end
+ end
+end
diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_metadata_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_metadata_action.rb
new file mode 100644
index 000000000..15d182849
--- /dev/null
+++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_metadata_action.rb
@@ -0,0 +1,67 @@
+module Fastlane
+ module Actions
+ class BuildkiteMetadataAction < Action
+ def self.run(params)
+ # Set/Add new metadata values
+ params[:set]&.each do |key, value|
+ sh('buildkite-agent', 'meta-data', 'set', key.to_s, value.to_s)
+ end
+
+ # Return value of existing metadata key
+ sh('buildkite-agent', 'meta-data', 'get', params[:get].to_s) unless params[:get].nil?
+ end
+
+ #####################################################
+ # @!group Documentation
+ #####################################################
+
+ def self.description
+ 'Set/Get metadata to the current Buildkite build'
+ end
+
+ def self.details
+ <<~DETAILS
+ Set and/or get metadata to the current Buildkite build.
+
+ Has to be run on a CI job (where a `buildkite-agent` is running), e.g. typically by a lane
+ that is triggered as part of a Buildkite CI step.
+
+ See https://buildkite.com/docs/agent/v3/cli-meta-data
+ DETAILS
+ end
+
+ def self.available_options
+ [
+ FastlaneCore::ConfigItem.new(
+ key: :set,
+ env_name: 'BUILDKITE_METADATA_SET',
+ description: 'The hash of key/value pairs of the meta-data to set',
+ type: Hash,
+ optional: true,
+ default_value: nil
+ ),
+ FastlaneCore::ConfigItem.new(
+ key: :get,
+ env_name: 'BUILDKITE_METADATA_GET',
+ description: 'The key of the metadata to get the value of',
+ type: String,
+ optional: true,
+ default_value: nil
+ ),
+ ]
+ end
+
+ def self.return_value
+ 'The value of the Buildkite metadata corresponding to the provided `get` key. `nil` if no `get` parameter was provided.'
+ end
+
+ def self.authors
+ ['Automattic']
+ end
+
+ def self.is_supported?(platform)
+ true
+ end
+ end
+ end
+end
diff --git a/spec/buildkite_annotate_action_spec.rb b/spec/buildkite_annotate_action_spec.rb
new file mode 100644
index 000000000..f042acee7
--- /dev/null
+++ b/spec/buildkite_annotate_action_spec.rb
@@ -0,0 +1,82 @@
+require 'spec_helper'
+
+describe Fastlane::Actions::BuildkiteAnnotateAction do
+ describe '`style` parameter validation' do
+ it 'errors if we use an invalid style' do
+ expect(FastlaneCore::UI).to receive(:user_error!).with('Invalid value `failure` for parameter `style`. Valid values are: success, info, warning, error')
+
+ run_described_fastlane_action(
+ context: 'ctx',
+ style: 'failure',
+ message: 'Fake message'
+ )
+ end
+
+ %w[success info warning error].each do |style|
+ it "accepts `#{style}` as a valid style" do
+ expect(FastlaneCore::UI).not_to receive(:user_error!)
+ cmd = run_described_fastlane_action(
+ context: 'ctx',
+ style: style,
+ message: 'message'
+ )
+ expect(cmd).to eq("buildkite-agent annotate --context ctx --style #{style} message")
+ end
+ end
+
+ it 'accepts `nil` as a valid style' do
+ expect(FastlaneCore::UI).not_to receive(:user_error!)
+ cmd = run_described_fastlane_action(
+ context: 'ctx',
+ message: 'message'
+ )
+ expect(cmd).to eq('buildkite-agent annotate --context ctx message')
+ end
+ end
+
+ describe 'annotation creation' do
+ it 'generates the right command to create an annotation when message is provided' do
+ cmd = run_described_fastlane_action(
+ context: 'ctx',
+ style: 'warning',
+ message: 'message'
+ )
+ expect(cmd).to eq('buildkite-agent annotate --context ctx --style warning message')
+ end
+
+ it 'properly escapes the message and context' do
+ cmd = run_described_fastlane_action(
+ context: 'some ctx',
+ style: 'warning',
+ message: 'a nice message; with fun characters & all…'
+ )
+ expect(cmd).to eq('buildkite-agent annotate --context some\ ctx --style warning a\ \nice\\ message\;\ with\ fun\ characters\ \&\ all\…')
+ end
+
+ it 'falls back to Buildkite\'s default `context` when none is provided' do
+ cmd = run_described_fastlane_action(
+ style: 'warning',
+ message: 'a nice message'
+ )
+ expect(cmd).to eq('buildkite-agent annotate --style warning a\ nice\ message')
+ end
+
+ it 'falls back to Buildkite\'s default `style` when none is provided' do
+ cmd = run_described_fastlane_action(
+ context: 'my-ctx',
+ message: 'a nice message'
+ )
+ expect(cmd).to eq('buildkite-agent annotate --context my-ctx a\ nice\ message')
+ end
+ end
+
+ describe 'annotation deletion' do
+ it 'generates the right command to delete an annotation when no message is provided' do
+ cmd = run_described_fastlane_action(
+ context: 'some ctx',
+ message: nil
+ )
+ expect(cmd).to eq('buildkite-agent annotation remove --context some\ ctx || true')
+ end
+ end
+end
diff --git a/spec/buildkite_metadata_action_spec.rb b/spec/buildkite_metadata_action_spec.rb
new file mode 100644
index 000000000..867acdea4
--- /dev/null
+++ b/spec/buildkite_metadata_action_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe Fastlane::Actions::BuildkiteMetadataAction do
+ it 'calls the right command to set a single metadata' do
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'set', 'foo', 'bar')
+
+ res = run_described_fastlane_action(set: { foo: 'bar' })
+ expect(res).to be_nil
+ end
+
+ it 'calls the commands as many times as necessary when we want to set multiple metadata at once' do
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'set', 'key1', 'value1')
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'set', 'key2', 'value2')
+
+ metadata = {
+ key1: 'value1',
+ key2: 'value2'
+ }
+ run_described_fastlane_action(set: metadata)
+ end
+
+ it 'calls the right command to get the value of metadata, and returns the right value' do
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'get', 'foo')
+ allow(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'get', 'foo').and_return('foo value')
+
+ res = run_described_fastlane_action(get: 'foo')
+ expect(res).to eq('foo value')
+ end
+
+ it 'allows both setting and getting metadata in the same call' do
+ # Might not be the main way we intend to use this action… but it's still supported.
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'set', 'key1', 'value1')
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'set', 'key2', 'value2')
+ expect(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'get', 'key3')
+ allow(Fastlane::Action).to receive(:sh).with('buildkite-agent', 'meta-data', 'get', 'key3').and_return('value3')
+
+ new_metadata = {
+ key1: 'value1',
+ key2: 'value2'
+ }
+ res = run_described_fastlane_action(set: new_metadata, get: 'key3')
+
+ expect(res).to eq('value3')
+ end
+end