From b08f01dcc33b45267e7a9a0bf7e13013096093e2 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Thu, 1 Oct 2026 11:27:47 -0700 Subject: [PATCH 1/2] fix(release): fail the build when Apple rejects a notarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xcrun notarytool submit --wait` exits 0 as long as the submission itself completed, including when Apple returns `Invalid`. The sign-and-notarize hook trusted that exit code, so a rejected binary would have been archived and uploaded as if it had passed — the gate could not actually fail. Read the submission JSON with plutil (always present on macOS, so no new dependency) and require status `Accepted`. On failure, print the raw response and fetch the notary log so the rejection reason lands in the workflow output instead of requiring a separate round trip. Add scripts/smoke-sign-notarize-hook.sh covering the Accepted, Invalid, Rejected, missing-credential, missing-key-file, and non-darwin paths with codesign/ditto/xcrun stubbed on PATH, so it needs no signing identity, credentials, or network. Verified it fails against the previous exit-code-only logic and passes against the fix. Wired it into examples-smoke.yml as a macOS job. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/examples-smoke.yml | 14 +++ scripts/sign-and-notarize-darwin.sh | 35 +++++++- scripts/smoke-sign-notarize-hook.sh | 130 +++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100755 scripts/smoke-sign-notarize-hook.sh diff --git a/.github/workflows/examples-smoke.yml b/.github/workflows/examples-smoke.yml index ad08da43..f0b05450 100644 --- a/.github/workflows/examples-smoke.yml +++ b/.github/workflows/examples-smoke.yml @@ -413,3 +413,17 @@ jobs: - name: Run e2e script run: SKIP_BUILD=1 "${{ matrix.script }}" "$GITHUB_WORKSPACE" + + # Runs on macOS because the hook under test uses codesign, ditto, and plutil. + # Apple tooling is stubbed on PATH, so no signing identity, credentials, or + # network access are required. + sign-notarize-hook-smoke: + name: sign-notarize-hook-smoke + runs-on: macos-latest + + steps: + - name: Checkout ballast repo + uses: actions/checkout@v7 + + - name: Run sign-and-notarize hook smoke test + run: ./scripts/smoke-sign-notarize-hook.sh diff --git a/scripts/sign-and-notarize-darwin.sh b/scripts/sign-and-notarize-darwin.sh index 9551a269..9be6f503 100755 --- a/scripts/sign-and-notarize-darwin.sh +++ b/scripts/sign-and-notarize-darwin.sh @@ -31,16 +31,43 @@ codesign --verify --strict "$BINARY" # notarytool needs an archive, not a bare executable. A bare binary cannot be # stapled, so Gatekeeper validates these online against Apple's service. -ZIP="$(mktemp -d)/$(basename "$BINARY").zip" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +ZIP="${WORKDIR}/$(basename "$BINARY").zip" +SUBMIT_JSON="${WORKDIR}/notarytool-submit.json" ditto -c -k --keepParent "$BINARY" "$ZIP" echo "Notarizing ${BINARY}" + +# `notarytool submit --wait` exits 0 as long as the submission itself completed, +# including when Apple comes back `Invalid`. Read the status out of the JSON and +# assert it rather than trusting the exit code, or a rejected binary ships. +set +e xcrun notarytool submit "$ZIP" \ --key "$KEY_FILE" \ --key-id "$APPLE_API_KEY_ID" \ --issuer "$APPLE_API_KEY_ISSUER_ID" \ --wait \ - --timeout 20m + --timeout 20m \ + --output-format json >"$SUBMIT_JSON" +SUBMIT_EXIT=$? +set -e + +# plutil reads JSON and ships with macOS, so this needs no extra dependency. +SUBMISSION_ID="$(plutil -extract id raw -o - -- "$SUBMIT_JSON" 2>/dev/null || true)" +SUBMISSION_STATUS="$(plutil -extract status raw -o - -- "$SUBMIT_JSON" 2>/dev/null || true)" + +if [ "$SUBMIT_EXIT" -ne 0 ] || [ "$SUBMISSION_STATUS" != "Accepted" ]; then + echo "Notarization failed for ${BINARY}: status=${SUBMISSION_STATUS:-unknown} exit=${SUBMIT_EXIT}" >&2 + cat "$SUBMIT_JSON" >&2 || true + if [ -n "$SUBMISSION_ID" ]; then + echo "Notary log for submission ${SUBMISSION_ID}:" >&2 + xcrun notarytool log "$SUBMISSION_ID" \ + --key "$KEY_FILE" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_KEY_ISSUER_ID" >&2 || true + fi + exit 1 +fi -rm -f "$ZIP" -echo "Notarized ${BINARY}" +echo "Notarized ${BINARY} (submission ${SUBMISSION_ID}, status ${SUBMISSION_STATUS})" diff --git a/scripts/smoke-sign-notarize-hook.sh b/scripts/smoke-sign-notarize-hook.sh new file mode 100755 index 00000000..09bffc1e --- /dev/null +++ b/scripts/smoke-sign-notarize-hook.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# smoke-sign-notarize-hook.sh +# +# Covers the failure paths of scripts/sign-and-notarize-darwin.sh, the +# GoReleaser post-build hook that signs and notarizes darwin binaries before +# any artifact is uploaded. +# +# The case that matters most is "Invalid": `xcrun notarytool submit --wait` +# exits 0 as long as the submission itself completed, including when Apple +# rejects the binary. The hook must read the submission status and fail on +# anything other than Accepted, or a rejected binary ships in a release. +# +# codesign, ditto, and xcrun are stubbed on PATH so this runs without Apple +# credentials, a signing identity, or network access. plutil is the real one, +# which is why this smoke test is macOS-only. +# +# Usage: +# ./scripts/smoke-sign-notarize-hook.sh +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK="${REPO_ROOT}/scripts/sign-and-notarize-darwin.sh" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "SMOKE TEST SKIPPED: requires macOS for plutil and the hook's toolchain" + exit 0 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT + +mkdir -p "${WORKDIR}/bin" "${WORKDIR}/home/.private_keys" +touch "${WORKDIR}/home/.private_keys/AuthKey_TESTKEY.p8" +echo 'not a real mach-o' > "${WORKDIR}/ballast" + +cat > "${WORKDIR}/bin/codesign" <<'STUB' +#!/bin/sh +exit 0 +STUB + +# ditto -c -k --keepParent SRC DEST: only the destination archive matters here. +cat > "${WORKDIR}/bin/ditto" <<'STUB' +#!/bin/sh +eval DEST=\${$#} +: > "$DEST" +STUB + +# Returns whatever status STUB_STATUS asks for, with the exit code the real +# notarytool would use (0 even for Invalid, unless STUB_SUBMIT_EXIT overrides). +cat > "${WORKDIR}/bin/xcrun" <<'STUB' +#!/bin/sh +if [ "$1" = "notarytool" ] && [ "$2" = "submit" ]; then + printf '{"id":"sub-smoke","status":"%s","message":"Processing complete"}' "${STUB_STATUS:-Accepted}" + exit "${STUB_SUBMIT_EXIT:-0}" +fi +if [ "$1" = "notarytool" ] && [ "$2" = "log" ]; then + echo '{"issues":[{"severity":"error","message":"stub notary log"}]}' + exit 0 +fi +exit 0 +STUB + +chmod +x "${WORKDIR}/bin/"* + +failures=0 + +run_hook() { + env \ + PATH="${WORKDIR}/bin:${PATH}" \ + HOME="${WORKDIR}/home" \ + APPLE_SIGNING_IDENTITY="Developer ID Application: Smoke Test" \ + APPLE_API_KEY_ID="TESTKEY" \ + APPLE_API_KEY_ISSUER_ID="smoke-issuer" \ + "$@" \ + bash "${HOOK}" darwin "${WORKDIR}/ballast" +} + +expect_exit() { + local label="$1" + local want="$2" + shift 2 + + local output + local got=0 + output="$("$@" 2>&1)" || got=$? + + if [[ "${got}" -eq "${want}" ]]; then + echo "ok: ${label} (exit ${got})" + else + echo "FAIL: ${label} expected exit ${want}, got ${got}" + printf '%s\n' "${output}" + failures=$((failures + 1)) + fi +} + +# A clean notarization must let the build continue. +expect_exit "Accepted submission succeeds" 0 \ + run_hook STUB_STATUS=Accepted + +# The regression this smoke test exists for. +expect_exit "Invalid submission fails even though notarytool exits 0" 1 \ + run_hook STUB_STATUS=Invalid + +expect_exit "Rejected submission with a non-zero notarytool exit fails" 1 \ + run_hook STUB_STATUS=Rejected STUB_SUBMIT_EXIT=1 + +# Missing credentials must abort rather than silently skip notarization. +expect_exit "Missing APPLE_API_KEY_ISSUER_ID fails" 1 \ + env PATH="${WORKDIR}/bin:${PATH}" HOME="${WORKDIR}/home" \ + APPLE_SIGNING_IDENTITY="Developer ID Application: Smoke Test" \ + APPLE_API_KEY_ID="TESTKEY" \ + bash "${HOOK}" darwin "${WORKDIR}/ballast" + +expect_exit "Missing App Store Connect key file fails" 1 \ + env PATH="${WORKDIR}/bin:${PATH}" HOME="${WORKDIR}/empty-home" \ + APPLE_SIGNING_IDENTITY="Developer ID Application: Smoke Test" \ + APPLE_API_KEY_ID="TESTKEY" APPLE_API_KEY_ISSUER_ID="smoke-issuer" \ + bash "${HOOK}" darwin "${WORKDIR}/ballast" + +# Non-darwin builds share the hook and must be a no-op. +expect_exit "Non-darwin build is a no-op" 0 \ + env PATH="${WORKDIR}/bin:${PATH}" bash "${HOOK}" linux "${WORKDIR}/ballast" + +if [[ "${failures}" -ne 0 ]]; then + echo "SMOKE TEST FAILED: ${failures} case(s)" + exit 1 +fi + +echo "SMOKE TEST PASSED" From 32a1e8370bee28a1ab6f5d667a2e825253f670a7 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Thu, 1 Oct 2026 11:32:12 -0700 Subject: [PATCH 2/2] test(release): isolate the missing-issuer smoke case from the caller's env `env` without `-u` keeps inherited variables, so a developer or runner with APPLE_API_KEY_ISSUER_ID already exported would have sent that case down the normal notarization path and failed the smoke test for the wrong reason. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/smoke-sign-notarize-hook.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-sign-notarize-hook.sh b/scripts/smoke-sign-notarize-hook.sh index 09bffc1e..a1fee4be 100755 --- a/scripts/smoke-sign-notarize-hook.sh +++ b/scripts/smoke-sign-notarize-hook.sh @@ -106,8 +106,10 @@ expect_exit "Rejected submission with a non-zero notarytool exit fails" 1 \ run_hook STUB_STATUS=Rejected STUB_SUBMIT_EXIT=1 # Missing credentials must abort rather than silently skip notarization. +# `env -u` so an issuer id exported in the caller's shell cannot leak in and +# send this case down the normal notarization path instead. expect_exit "Missing APPLE_API_KEY_ISSUER_ID fails" 1 \ - env PATH="${WORKDIR}/bin:${PATH}" HOME="${WORKDIR}/home" \ + env -u APPLE_API_KEY_ISSUER_ID PATH="${WORKDIR}/bin:${PATH}" HOME="${WORKDIR}/home" \ APPLE_SIGNING_IDENTITY="Developer ID Application: Smoke Test" \ APPLE_API_KEY_ID="TESTKEY" \ bash "${HOOK}" darwin "${WORKDIR}/ballast"