From 624d39e9b54035f8b73d3fd123026e283dac6599 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Sun, 19 Jul 2026 16:36:32 +1000 Subject: [PATCH 01/10] Add macos_verify_code_signing action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from the `verify_code_signing` lane in the WordPress Contributor Toolkit (WordPress/experimental-wp-dev-env#22), so that the other apps we build for macOS can assert their artifacts are signed and notarized instead of shipping a silently unsigned build. `electron-builder` only warns when it can't find a valid signing identity — it skips signing and produces an artifact that looks fine until users try to launch it. That's what motivated the original lane. On top of the original lane's checks, this also runs `xcrun stapler validate` so a missing notarization ticket fails the job, and makes the notarization checks opt-out for callers that verify at a point where the app is signed but not yet notarized, such as an `electron-builder` `afterSign` hook. Part of AINFRA-2709. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- CHANGELOG.md | 2 +- .../macos/macos_verify_code_signing.rb | 95 ++++++++++++ spec/macos_verify_code_signing_spec.rb | 139 ++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb create mode 100644 spec/macos_verify_code_signing_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e8d8666..6944b4e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- New `macos_verify_code_signing` action, asserting that a macOS app bundle is signed, signed by the expected authority, accepted by Gatekeeper, and has a notarization ticket stapled to it. [#PRNUMBER] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb new file mode 100644 index 000000000..9078dfacd --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Fastlane + module Actions + class MacosVerifyCodeSigningAction < Action + def self.run(params) + app_paths = Array(params[:app_path]) + UI.user_error!('No app bundle to verify: `app_path` is empty') if app_paths.empty? + + app_paths.each do |app_path| + UI.user_error!("There is no app bundle at #{app_path}") unless File.exist?(app_path) + + UI.message("Verifying code signing of #{app_path}") + + verify!("The code signature of #{app_path} is not valid", 'codesign', '--verify', '--deep', '--strict', '--verbose=2', app_path) + verify_authority!(app_path: app_path, expected_authority: params[:expected_authority]) unless params[:expected_authority].nil? + + next unless params[:verify_notarization] + + verify!("#{app_path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', app_path) + verify!("#{app_path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', app_path) + end + + UI.success("Verified code signing of #{app_paths.length} app bundle(s)") + end + + def self.verify!(error_message, *command) + sh(*command, error_callback: ->(_) { UI.user_error!(error_message) }) + end + + def self.verify_authority!(app_path:, expected_authority:) + details = sh('codesign', '--display', '--verbose=2', app_path) + return if details.include?("Authority=#{expected_authority}") + + UI.user_error!("#{app_path} is not signed by '#{expected_authority}':\n#{details}") + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Verify that macOS app bundles are properly code signed and notarized' + end + + def self.details + <<~DETAILS + Verify that the given macOS app bundles are signed, and optionally notarized, so that a build + that is unsigned or signed with the wrong identity fails the CI job instead of shipping. + + `electron-builder`, in particular, only warns when it can't find a valid signing identity: it + skips signing and produces an artifact that looks fine until users try to launch it. + + Run with `verify_notarization: false` at a point in the build where the app has been signed + but not notarized yet, such as from an `electron-builder` `afterSign` hook. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :app_path, + description: 'The path, or list of paths, to the `.app` bundle(s) to verify', + is_string: false, + verify_block: proc do |value| + UI.user_error!('`app_path` must be a String or an Array of Strings') unless value.is_a?(String) || value.is_a?(Array) + end + ), + FastlaneCore::ConfigItem.new( + key: :expected_authority, + description: 'The signing authority the app is expected to be signed by, e.g. `Developer ID Application: Automattic, Inc. (ABCDE12345)`. ' \ + + 'When omitted, any valid signature is accepted', + type: String, + optional: true, + default_value: nil + ), + FastlaneCore::ConfigItem.new( + key: :verify_notarization, + description: 'Whether to also assert that the app is accepted by Gatekeeper and has a notarization ticket stapled to it', + type: Boolean, + default_value: true + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + platform == :mac + end + end + end +end diff --git a/spec/macos_verify_code_signing_spec.rb b/spec/macos_verify_code_signing_spec.rb new file mode 100644 index 000000000..92d16d09d --- /dev/null +++ b/spec/macos_verify_code_signing_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::MacosVerifyCodeSigningAction do + let(:authority) { 'Developer ID Application: Automattic, Inc. (ABCDE12345)' } + + # Runs the action against `.app` paths that exist on disk, so that the existence + # check doesn't get in the way of asserting on the commands the action runs. + # + def with_app_bundles(*names) + in_tmp_dir do |tmp_dir| + paths = names.map do |name| + path = File.join(tmp_dir, name) + FileUtils.mkdir_p(path) + path + end + yield(paths) + end + end + + def expect_codesign_verify(app_path, exitstatus: 0) + expect_shell_command('codesign', '--verify', '--deep', '--strict', '--verbose=2', app_path, exitstatus: exitstatus) + end + + def expect_codesign_display(app_path, authority:) + expect_shell_command('codesign', '--display', '--verbose=2', app_path, output: "Executable=#{app_path}\nAuthority=#{authority}\n") + end + + def expect_gatekeeper_assess(app_path, exitstatus: 0) + expect_shell_command('spctl', '--assess', '--type', 'execute', '--verbose=2', app_path, exitstatus: exitstatus) + end + + def expect_stapler_validate(app_path, exitstatus: 0) + expect_shell_command('xcrun', 'stapler', 'validate', app_path, exitstatus: exitstatus) + end + + before do + allow_fastlane_action_sh + end + + it 'verifies the signature, Gatekeeper acceptance and stapled ticket of a single app' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect_gatekeeper_assess(app_path) + expect_stapler_validate(app_path) + + run_described_fastlane_action(app_path: app_path) + end + end + + it 'verifies every app when given a list of paths' do + with_app_bundles('One.app', 'Two.app') do |paths| + paths.each do |app_path| + expect_codesign_verify(app_path) + expect_gatekeeper_assess(app_path) + expect_stapler_validate(app_path) + end + + run_described_fastlane_action(app_path: paths) + end + end + + it 'skips the notarization checks when `verify_notarization` is `false`' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + + run_described_fastlane_action(app_path: app_path, verify_notarization: false) + end + end + + it 'passes when the app is signed by the expected authority' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect_codesign_display(app_path, authority: authority) + expect_gatekeeper_assess(app_path) + expect_stapler_validate(app_path) + + run_described_fastlane_action(app_path: app_path, expected_authority: authority) + end + end + + it 'fails when the app is signed by a different authority' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect_codesign_display(app_path, authority: 'Apple Development: Someone Else (ZZZZZ99999)') + + expect { run_described_fastlane_action(app_path: app_path, expected_authority: authority) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /is not signed by '#{Regexp.escape(authority)}'/) + end + end + + it 'fails when the signature is not valid' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path, exitstatus: 1) + + expect { run_described_fastlane_action(app_path: app_path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.app is not valid/) + end + end + + it 'fails when Gatekeeper rejects the app' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect_gatekeeper_assess(app_path, exitstatus: 3) + + expect { run_described_fastlane_action(app_path: app_path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app was rejected by Gatekeeper/) + end + end + + it 'fails when the app has no notarization ticket stapled' do + with_app_bundles('Test.app') do |(app_path)| + expect_codesign_verify(app_path) + expect_gatekeeper_assess(app_path) + expect_stapler_validate(app_path, exitstatus: 65) + + expect { run_described_fastlane_action(app_path: app_path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app has no notarization ticket stapled to it/) + end + end + + it 'fails when there is no app at the given path' do + expect { run_described_fastlane_action(app_path: '/path/to/Missing.app') } + .to raise_error(FastlaneCore::Interface::FastlaneError, %r{There is no app bundle at /path/to/Missing\.app}) + end + + it 'fails when given an empty list of paths' do + expect { run_described_fastlane_action(app_path: []) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`app_path` is empty/) + end + + it 'fails when `app_path` is neither a String nor an Array' do + expect { run_described_fastlane_action(app_path: 42) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`app_path` must be a String or an Array of Strings/) + end +end From 004b7b62eb0e2b47b4f1b7b706e476a3d57bff39 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Sun, 19 Jul 2026 16:37:03 +1000 Subject: [PATCH 02/10] Reference the PR in the CHANGELOG entry --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6944b4e66..3a897c6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- New `macos_verify_code_signing` action, asserting that a macOS app bundle is signed, signed by the expected authority, accepted by Gatekeeper, and has a notarization ticket stapled to it. [#PRNUMBER] +- New `macos_verify_code_signing` action, asserting that a macOS app bundle is signed, signed by the expected authority, accepted by Gatekeeper, and has a notarization ticket stapled to it. [#757] ### Bug Fixes From 419d130cce350b26e30c74a3ac4239f3bdfed111 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 10:02:47 +1000 Subject: [PATCH 03/10] Verify disk images too, dispatching on extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disk images can't reuse the app bundle's checks. Measured against two real `.dmg`s, including one of our own electron-builder artifacts: neither is codesigned — electron-builder signs only the app inside — and Gatekeeper rejects an unsigned image with `no usable signature` even when it does carry a notarization ticket. So `codesign --verify` and `spctl` are the wrong checks for our images; the stapled ticket is the only one that means anything. Hence dispatch on the extension rather than a separate action per artifact type: callers pass paths and get the checks that apply. An unsigned image warns and skips its signature checks, while a *broken* signature still fails — a distinction worth keeping, since the first is expected and the second never is. `app_path` becomes `artifact_path` now that it takes more than app bundles. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- CHANGELOG.md | 2 +- .../macos/macos_verify_code_signing.rb | 93 +++++-- spec/macos_verify_code_signing_spec.rb | 231 ++++++++++++------ 3 files changed, 229 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a897c6bf..12bed920d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- New `macos_verify_code_signing` action, asserting that a macOS app bundle is signed, signed by the expected authority, accepted by Gatekeeper, and has a notarization ticket stapled to it. [#757] +- New `macos_verify_code_signing` action, asserting that macOS artifacts are signed, signed by the expected authority, accepted by Gatekeeper, and have a notarization ticket stapled to them. Handles `.app` bundles and `.dmg` disk images, picking the checks that apply to each. [#757] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 9078dfacd..c46ec6cb3 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -4,35 +4,74 @@ module Fastlane module Actions class MacosVerifyCodeSigningAction < Action def self.run(params) - app_paths = Array(params[:app_path]) - UI.user_error!('No app bundle to verify: `app_path` is empty') if app_paths.empty? + paths = Array(params[:artifact_path]) + UI.user_error!('No artifact to verify: `artifact_path` is empty') if paths.empty? + + paths.each do |path| + UI.user_error!("There is no artifact at #{path}") unless File.exist?(path) + + UI.message("Verifying #{path}") + + case File.extname(path).downcase + when '.app' + verify_app_bundle(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization]) + when '.dmg' + verify_disk_image(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization]) + else + UI.user_error!("Don't know how to verify #{path}. Supported artifacts are `.app` bundles and `.dmg` disk images") + end + end - app_paths.each do |app_path| - UI.user_error!("There is no app bundle at #{app_path}") unless File.exist?(app_path) + UI.success("Verified #{paths.length} artifact(s)") + end - UI.message("Verifying code signing of #{app_path}") + def self.verify_app_bundle(path:, expected_authority:, verify_notarization:) + verify!("The code signature of #{path} is not valid", 'codesign', '--verify', '--deep', '--strict', '--verbose=2', path) + verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? - verify!("The code signature of #{app_path} is not valid", 'codesign', '--verify', '--deep', '--strict', '--verbose=2', app_path) - verify_authority!(app_path: app_path, expected_authority: params[:expected_authority]) unless params[:expected_authority].nil? + return unless verify_notarization - next unless params[:verify_notarization] + verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', path) + verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) + end - verify!("#{app_path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', app_path) - verify!("#{app_path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', app_path) + # Unlike an app bundle, a disk image is often not signed at all — `electron-builder`, for one, + # only signs the app inside it. Gatekeeper then rejects the image itself with `no usable + # signature` even when it carries a notarization ticket, so the stapled ticket is the only + # check that means anything for an unsigned image. + # + def self.verify_disk_image(path:, expected_authority:, verify_notarization:) + if signed?(path) + verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? + verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path) if verify_notarization + else + UI.important("#{path} is not signed — skipping its signature checks. Only the app it contains carries a signature.") end - UI.success("Verified code signing of #{app_paths.length} app bundle(s)") + verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) if verify_notarization + end + + # Distinguishes an artifact that carries no signature at all from one whose signature is + # broken: the former is expected for a disk image, the latter always a failure. + # + def self.signed?(path) + exitstatus, output = sh('codesign', '--verify', '--strict', '--verbose=2', path) { |status, result, _| [status.exitstatus, result] } + + return true if exitstatus.zero? + return false if output.include?('not signed at all') + + UI.user_error!("The code signature of #{path} is not valid:\n#{output}") end def self.verify!(error_message, *command) sh(*command, error_callback: ->(_) { UI.user_error!(error_message) }) end - def self.verify_authority!(app_path:, expected_authority:) - details = sh('codesign', '--display', '--verbose=2', app_path) + def self.verify_authority!(path:, expected_authority:) + details = sh('codesign', '--display', '--verbose=2', path) return if details.include?("Authority=#{expected_authority}") - UI.user_error!("#{app_path} is not signed by '#{expected_authority}':\n#{details}") + UI.user_error!("#{path} is not signed by '#{expected_authority}':\n#{details}") end ##################################################### @@ -40,35 +79,43 @@ def self.verify_authority!(app_path:, expected_authority:) ##################################################### def self.description - 'Verify that macOS app bundles are properly code signed and notarized' + 'Verify that macOS artifacts are properly code signed and notarized' end def self.details <<~DETAILS - Verify that the given macOS app bundles are signed, and optionally notarized, so that a build + Verify that the given macOS artifacts are signed, and optionally notarized, so that a build that is unsigned or signed with the wrong identity fails the CI job instead of shipping. `electron-builder`, in particular, only warns when it can't find a valid signing identity: it skips signing and produces an artifact that looks fine until users try to launch it. - Run with `verify_notarization: false` at a point in the build where the app has been signed - but not notarized yet, such as from an `electron-builder` `afterSign` hook. + The checks that apply are picked from the artifact's extension: + + - `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts + the bundle for execution, and a notarization ticket is stapled to it. + - `.dmg` — a notarization ticket is stapled to the image. Disk images are frequently left + unsigned (`electron-builder` signs only the app inside), in which case the signature checks + are skipped with a warning rather than failing. + + Run with `verify_notarization: false` at a point in the build where the artifact has been + signed but not notarized yet, such as from an `electron-builder` `afterSign` hook. DETAILS end def self.available_options [ FastlaneCore::ConfigItem.new( - key: :app_path, - description: 'The path, or list of paths, to the `.app` bundle(s) to verify', + key: :artifact_path, + description: 'The path, or list of paths, to the `.app` bundle(s) or `.dmg` disk image(s) to verify', is_string: false, verify_block: proc do |value| - UI.user_error!('`app_path` must be a String or an Array of Strings') unless value.is_a?(String) || value.is_a?(Array) + UI.user_error!('`artifact_path` must be a String or an Array of Strings') unless value.is_a?(String) || value.is_a?(Array) end ), FastlaneCore::ConfigItem.new( key: :expected_authority, - description: 'The signing authority the app is expected to be signed by, e.g. `Developer ID Application: Automattic, Inc. (ABCDE12345)`. ' \ + description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: Automattic, Inc. (ABCDE12345)`. ' \ + 'When omitted, any valid signature is accepted', type: String, optional: true, @@ -76,7 +123,7 @@ def self.available_options ), FastlaneCore::ConfigItem.new( key: :verify_notarization, - description: 'Whether to also assert that the app is accepted by Gatekeeper and has a notarization ticket stapled to it', + description: 'Whether to also assert that the artifact is accepted by Gatekeeper and has a notarization ticket stapled to it', type: Boolean, default_value: true ), diff --git a/spec/macos_verify_code_signing_spec.rb b/spec/macos_verify_code_signing_spec.rb index 92d16d09d..2f3c93a82 100644 --- a/spec/macos_verify_code_signing_spec.rb +++ b/spec/macos_verify_code_signing_spec.rb @@ -5,13 +5,15 @@ describe Fastlane::Actions::MacosVerifyCodeSigningAction do let(:authority) { 'Developer ID Application: Automattic, Inc. (ABCDE12345)' } - # Runs the action against `.app` paths that exist on disk, so that the existence + # Runs the action against artifacts that exist on disk, so that the existence # check doesn't get in the way of asserting on the commands the action runs. # - def with_app_bundles(*names) + def with_artifacts(*names) in_tmp_dir do |tmp_dir| paths = names.map do |name| path = File.join(tmp_dir, name) + # A `.app` is a directory and a `.dmg` a file, but the action only calls + # `File.exist?`, so either satisfies it. FileUtils.mkdir_p(path) path end @@ -19,121 +21,204 @@ def with_app_bundles(*names) end end - def expect_codesign_verify(app_path, exitstatus: 0) - expect_shell_command('codesign', '--verify', '--deep', '--strict', '--verbose=2', app_path, exitstatus: exitstatus) + def expect_codesign_verify_deep(path, exitstatus: 0) + expect_shell_command('codesign', '--verify', '--deep', '--strict', '--verbose=2', path, exitstatus: exitstatus) end - def expect_codesign_display(app_path, authority:) - expect_shell_command('codesign', '--display', '--verbose=2', app_path, output: "Executable=#{app_path}\nAuthority=#{authority}\n") + def expect_codesign_verify(path, exitstatus: 0, output: '') + expect_shell_command('codesign', '--verify', '--strict', '--verbose=2', path, exitstatus: exitstatus, output: output) end - def expect_gatekeeper_assess(app_path, exitstatus: 0) - expect_shell_command('spctl', '--assess', '--type', 'execute', '--verbose=2', app_path, exitstatus: exitstatus) + def expect_codesign_display(path, authority:) + expect_shell_command('codesign', '--display', '--verbose=2', path, output: "Executable=#{path}\nAuthority=#{authority}\n") end - def expect_stapler_validate(app_path, exitstatus: 0) - expect_shell_command('xcrun', 'stapler', 'validate', app_path, exitstatus: exitstatus) + def expect_gatekeeper_assess_execute(path, exitstatus: 0) + expect_shell_command('spctl', '--assess', '--type', 'execute', '--verbose=2', path, exitstatus: exitstatus) + end + + def expect_gatekeeper_assess_open(path, exitstatus: 0) + expect_shell_command('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path, exitstatus: exitstatus) + end + + def expect_stapler_validate(path, exitstatus: 0) + expect_shell_command('xcrun', 'stapler', 'validate', path, exitstatus: exitstatus) end before do allow_fastlane_action_sh end - it 'verifies the signature, Gatekeeper acceptance and stapled ticket of a single app' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect_gatekeeper_assess(app_path) - expect_stapler_validate(app_path) + describe 'app bundles' do + it 'verifies the signature, Gatekeeper acceptance and stapled ticket' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path) - run_described_fastlane_action(app_path: app_path) + run_described_fastlane_action(artifact_path: path) + end end - end - it 'verifies every app when given a list of paths' do - with_app_bundles('One.app', 'Two.app') do |paths| - paths.each do |app_path| - expect_codesign_verify(app_path) - expect_gatekeeper_assess(app_path) - expect_stapler_validate(app_path) + it 'skips the notarization checks when `verify_notarization` is `false`' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + + run_described_fastlane_action(artifact_path: path, verify_notarization: false) end + end - run_described_fastlane_action(app_path: paths) + it 'passes when signed by the expected authority' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_codesign_display(path, authority: authority) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end end - end - it 'skips the notarization checks when `verify_notarization` is `false`' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect(Open3).not_to receive(:popen2e).with('spctl', any_args) - expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + it 'fails when signed by a different authority' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_codesign_display(path, authority: 'Apple Development: Someone Else (ZZZZZ99999)') - run_described_fastlane_action(app_path: app_path, verify_notarization: false) + expect { run_described_fastlane_action(artifact_path: path, expected_authority: authority) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /is not signed by '#{Regexp.escape(authority)}'/) + end end - end - it 'passes when the app is signed by the expected authority' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect_codesign_display(app_path, authority: authority) - expect_gatekeeper_assess(app_path) - expect_stapler_validate(app_path) + it 'fails when the signature is not valid' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path, exitstatus: 1) - run_described_fastlane_action(app_path: app_path, expected_authority: authority) + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.app is not valid/) + end end - end - it 'fails when the app is signed by a different authority' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect_codesign_display(app_path, authority: 'Apple Development: Someone Else (ZZZZZ99999)') + it 'fails when Gatekeeper rejects it' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path, exitstatus: 3) - expect { run_described_fastlane_action(app_path: app_path, expected_authority: authority) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /is not signed by '#{Regexp.escape(authority)}'/) + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app was rejected by Gatekeeper/) + end end - end - it 'fails when the signature is not valid' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path, exitstatus: 1) + it 'fails when it has no notarization ticket stapled' do + with_artifacts('Test.app') do |(path)| + expect_codesign_verify_deep(path) + expect_gatekeeper_assess_execute(path) + expect_stapler_validate(path, exitstatus: 65) - expect { run_described_fastlane_action(app_path: app_path) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.app is not valid/) + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app has no notarization ticket stapled to it/) + end end end - it 'fails when Gatekeeper rejects the app' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect_gatekeeper_assess(app_path, exitstatus: 3) + describe 'disk images' do + # `electron-builder` signs the app inside the image but not the image itself, + # which is what our own `.dmg` artifacts look like. + it 'checks only the stapled ticket when the image is not signed' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(FastlaneCore::UI).to receive(:important).with(/is not signed — skipping its signature checks/) + + run_described_fastlane_action(artifact_path: path) + end + end + + it 'does not assert the authority of an unsigned image' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path) + expect(Open3).not_to receive(:popen2e).with('codesign', '--display', any_args) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end + end + + it 'checks Gatekeeper and the authority when the image is signed' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path) + expect_codesign_display(path, authority: authority) + expect_gatekeeper_assess_open(path) + expect_stapler_validate(path) + + run_described_fastlane_action(artifact_path: path, expected_authority: authority) + end + end + + it 'fails when the image carries a broken signature' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: invalid signature (code or signature have been modified)\n") - expect { run_described_fastlane_action(app_path: app_path) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app was rejected by Gatekeeper/) + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /The code signature of .*Test\.dmg is not valid/) + end + end + + it 'fails when it has no notarization ticket stapled' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path, exitstatus: 1, output: "#{path}: code object is not signed at all\n") + expect_stapler_validate(path, exitstatus: 65) + + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.dmg has no notarization ticket stapled to it/) + end + end + + it 'skips every check but the signature when `verify_notarization` is `false`' do + with_artifacts('Test.dmg') do |(path)| + expect_codesign_verify(path) + expect(Open3).not_to receive(:popen2e).with('spctl', any_args) + expect(Open3).not_to receive(:popen2e).with('xcrun', any_args) + + run_described_fastlane_action(artifact_path: path, verify_notarization: false) + end end end - it 'fails when the app has no notarization ticket stapled' do - with_app_bundles('Test.app') do |(app_path)| - expect_codesign_verify(app_path) - expect_gatekeeper_assess(app_path) - expect_stapler_validate(app_path, exitstatus: 65) + it 'verifies every artifact when given a list of paths' do + with_artifacts('One.app', 'Two.dmg') do |(app_path, dmg_path)| + expect_codesign_verify_deep(app_path) + expect_gatekeeper_assess_execute(app_path) + expect_stapler_validate(app_path) + + expect_codesign_verify(dmg_path, exitstatus: 1, output: "#{dmg_path}: code object is not signed at all\n") + expect_stapler_validate(dmg_path) + + run_described_fastlane_action(artifact_path: [app_path, dmg_path]) + end + end - expect { run_described_fastlane_action(app_path: app_path) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /Test\.app has no notarization ticket stapled to it/) + it 'fails on an artifact it does not know how to verify' do + with_artifacts('Test.zip') do |(path)| + expect { run_described_fastlane_action(artifact_path: path) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Don't know how to verify .*Test\.zip/) end end - it 'fails when there is no app at the given path' do - expect { run_described_fastlane_action(app_path: '/path/to/Missing.app') } - .to raise_error(FastlaneCore::Interface::FastlaneError, %r{There is no app bundle at /path/to/Missing\.app}) + it 'fails when there is no artifact at the given path' do + expect { run_described_fastlane_action(artifact_path: '/path/to/Missing.app') } + .to raise_error(FastlaneCore::Interface::FastlaneError, %r{There is no artifact at /path/to/Missing\.app}) end it 'fails when given an empty list of paths' do - expect { run_described_fastlane_action(app_path: []) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /`app_path` is empty/) + expect { run_described_fastlane_action(artifact_path: []) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` is empty/) end - it 'fails when `app_path` is neither a String nor an Array' do - expect { run_described_fastlane_action(app_path: 42) } - .to raise_error(FastlaneCore::Interface::FastlaneError, /`app_path` must be a String or an Array of Strings/) + it 'fails when `artifact_path` is neither a String nor an Array' do + expect { run_described_fastlane_action(artifact_path: 42) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/) end end From 090fe89f65c2fac1fac9184ca6dfda643a4aca20 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 10:07:17 +1000 Subject: [PATCH 04/10] Check the element type of an artifact_path Array The check claimed to accept "an Array of Strings" but only checked for an Array, so `[42]` reached `File.exist?` and raised a bare TypeError instead of the message the check exists to produce. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- .../actions/macos/macos_verify_code_signing.rb | 5 ++++- spec/macos_verify_code_signing_spec.rb | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index c46ec6cb3..f22ae2775 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -110,7 +110,10 @@ def self.available_options description: 'The path, or list of paths, to the `.app` bundle(s) or `.dmg` disk image(s) to verify', is_string: false, verify_block: proc do |value| - UI.user_error!('`artifact_path` must be a String or an Array of Strings') unless value.is_a?(String) || value.is_a?(Array) + next if value.is_a?(String) + next if value.is_a?(Array) && value.all?(String) + + UI.user_error!('`artifact_path` must be a String or an Array of Strings') end ), FastlaneCore::ConfigItem.new( diff --git a/spec/macos_verify_code_signing_spec.rb b/spec/macos_verify_code_signing_spec.rb index 2f3c93a82..dca333e4f 100644 --- a/spec/macos_verify_code_signing_spec.rb +++ b/spec/macos_verify_code_signing_spec.rb @@ -221,4 +221,9 @@ def expect_stapler_validate(path, exitstatus: 0) expect { run_described_fastlane_action(artifact_path: 42) } .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/) end + + it 'fails when `artifact_path` is an Array of something other than Strings' do + expect { run_described_fastlane_action(artifact_path: [42]) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /`artifact_path` must be a String or an Array of Strings/) + end end From 898632d8cf3d29a61f213792b425f97fed69757c Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 10:08:00 +1000 Subject: [PATCH 05/10] Tidy up verbose AI action description Co-authored-by: Gio Lodi --- .../actions/macos/macos_verify_code_signing.rb | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index f22ae2775..561bbd29b 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -84,22 +84,13 @@ def self.description def self.details <<~DETAILS - Verify that the given macOS artifacts are signed, and optionally notarized, so that a build - that is unsigned or signed with the wrong identity fails the CI job instead of shipping. - - `electron-builder`, in particular, only warns when it can't find a valid signing identity: it - skips signing and produces an artifact that looks fine until users try to launch it. + Verify that the given macOS artifacts are signed, and optionally notarized. The checks that apply are picked from the artifact's extension: - `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts the bundle for execution, and a notarization ticket is stapled to it. - - `.dmg` — a notarization ticket is stapled to the image. Disk images are frequently left - unsigned (`electron-builder` signs only the app inside), in which case the signature checks - are skipped with a warning rather than failing. - - Run with `verify_notarization: false` at a point in the build where the artifact has been - signed but not notarized yet, such as from an `electron-builder` `afterSign` hook. + - `.dmg` — a notarization ticket is stapled to the image. DETAILS end From 22b05990529cf5acba4597958c1135c54c56f5bd Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 10:08:24 +1000 Subject: [PATCH 06/10] Use ACME instead of Automattic for example company --- .../actions/macos/macos_verify_code_signing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 561bbd29b..929267434 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -109,7 +109,7 @@ def self.available_options ), FastlaneCore::ConfigItem.new( key: :expected_authority, - description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: Automattic, Inc. (ABCDE12345)`. ' \ + description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: ACME, Inc. (ABCDE12345)`. ' \ + 'When omitted, any valid signature is accepted', type: String, optional: true, From eb24b509908ad5146cef034042b9d0dbd93ef113 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 11:31:32 +1000 Subject: [PATCH 07/10] Say the disk image codesign probe may fail before it does `sh` logs a non-zero exit in red whether or not the caller handles it, so the unsigned-image path painted a passing job's log with what looks like a failure. The explanation came after, by which point a reader skimming for red has already stopped. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- .../actions/macos/macos_verify_code_signing.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 929267434..38dafe099 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -41,6 +41,10 @@ def self.verify_app_bundle(path:, expected_authority:, verify_notarization:) # check that means anything for an unsigned image. # def self.verify_disk_image(path:, expected_authority:, verify_notarization:) + # Said up front because `sh` logs a non-zero exit in red regardless of it being handled, + # which reads as a broken build in the CI log of an otherwise passing job. + UI.message("Checking whether #{path} is signed. Disk images usually aren't, so a `codesign` failure below is expected and tolerated.") + if signed?(path) verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path) if verify_notarization From 03161988f639410f390abfd756c84020621037ba Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 21 Jul 2026 10:05:24 +1000 Subject: [PATCH 08/10] Explicitly require fastlane dependencies in macOS code sign check action Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../actions/macos/macos_verify_code_signing.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 38dafe099..6e3780553 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'fastlane/action' +require 'fastlane_core/ui/ui' + module Fastlane module Actions class MacosVerifyCodeSigningAction < Action From a734d6431884dfdaa28cd4ce385f6a523a74ef5e Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 21 Jul 2026 10:05:59 +1000 Subject: [PATCH 09/10] Refine "tolerated failure" log message Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../actions/macos/macos_verify_code_signing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 6e3780553..011442801 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -46,7 +46,7 @@ def self.verify_app_bundle(path:, expected_authority:, verify_notarization:) def self.verify_disk_image(path:, expected_authority:, verify_notarization:) # Said up front because `sh` logs a non-zero exit in red regardless of it being handled, # which reads as a broken build in the CI log of an otherwise passing job. - UI.message("Checking whether #{path} is signed. Disk images usually aren't, so a `codesign` failure below is expected and tolerated.") + UI.message("Checking whether #{path} is signed. Disk images usually aren't, so a `codesign` failure due to the image being unsigned is expected and tolerated (other signature failures will still fail).") if signed?(path) verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil? From 70ca8d881e18a8bbb33bd610f1dd400dd71da3a4 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 21 Jul 2026 10:06:43 +1000 Subject: [PATCH 10/10] Refine dmg flow description Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../actions/macos/macos_verify_code_signing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb index 011442801..1824b0bcc 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb @@ -97,7 +97,7 @@ def self.details - `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts the bundle for execution, and a notarization ticket is stapled to it. - - `.dmg` — a notarization ticket is stapled to the image. + - `.dmg` — if the image is signed, the signature is valid (and can be checked against the expected authority) and Gatekeeper accepts opening it; when `verify_notarization` is true, a notarization ticket is stapled to the image. DETAILS end