From 8dda378d53bb6264e864d6078556b0498a71aa36 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 23 May 2026 22:23:13 +0100 Subject: [PATCH 1/5] Add release notarization step --- .github/workflows/release.yml | 14 ++++ docs/INSTALLATION.md | 6 ++ docs/bootstrap/onboarding.md | 12 ++- scripts/ci/run-fast-checks.sh | 1 + scripts/notarize-native-app.sh | 112 ++++++++++++++++++++++++++++ scripts/test-notarize-native-app.sh | 41 ++++++++++ 6 files changed, 182 insertions(+), 4 deletions(-) create mode 100755 scripts/notarize-native-app.sh create mode 100755 scripts/test-notarize-native-app.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49fada6..1da7fb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,20 @@ jobs: ./rust/target/release/apw --version ./rust/target/release/apw status --json + - name: Build native app bundle + run: ./scripts/build-native-app.sh + + - name: Sign and notarize native app bundle + if: startsWith(github.ref, 'refs/tags/v') + env: + APPLE_DEVELOPER_CERT_P12: ${{ secrets.APPLE_DEVELOPER_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_KEY_ISSUER: ${{ secrets.APPLE_NOTARY_KEY_ISSUER }} + APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} + run: bash scripts/notarize-native-app.sh + - name: Homebrew smoke install from source tarball env: APW_SRC_TARBALL: ${{ steps.source_tarball.outputs.path }} diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d36a54b..23b42d4 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -85,6 +85,12 @@ tar -xzf apw-macos-vX.Y.Z.tar.gz After `apw app install`, the CLI copies `APW.app` into `~/.apw/native-app/installed/APW.app`. +Tagged release archives are signed and notarized by +`.github/workflows/release.yml` when the Apple Developer ID and notary secrets +listed in `docs/bootstrap/onboarding.md` are configured. If those optional +secrets are absent, the workflow emits a warning and publishes an unstapled +archive rather than failing unrelated release automation. + ## Homebrew APW uses a **formula-plus-app-install** Homebrew model for the v2 line. The diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index 7ad2536..911c3ff 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -62,10 +62,14 @@ | `APPLE_NOTARY_PRIVATE_KEY` | base64-encoded `.p8` private key for `notarytool` | | `HOMEBREW_TAP_TOKEN` | scoped `contents:write` token on the tap repo (issue #6) | - All Apple credentials are optional — when absent, the workflow emits - a `::warning::` and continues without notarization. The Homebrew tap - job is `continue-on-error` so a missing or rejected token does not - block the release. + On tagged releases, `scripts/notarize-native-app.sh` imports the Developer + ID certificate into a temporary keychain, signs the release CLI and + `APW.app`, submits the app bundle to Apple notary service, staples the + ticket, and verifies Gatekeeper assessment before the archive is packaged. + All Apple credentials are optional — when absent, the workflow emits a + `::warning::` and continues without notarization. The Homebrew tap job is + `continue-on-error` so a missing or rejected token does not block the + release. ## Home Profiles diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 564b845..3d84d49 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -44,5 +44,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-extended-validation-config.sh ./scripts/test-verify-universal-binaries.sh ./scripts/test-universal-release-config.sh +./scripts/test-notarize-native-app.sh echo "APW fast checks passed." diff --git a/scripts/notarize-native-app.sh b/scripts/notarize-native-app.sh new file mode 100755 index 0000000..808f02f --- /dev/null +++ b/scripts/notarize-native-app.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="${APW_APP_BUNDLE_PATH:-$ROOT_DIR/native-app/dist/APW.app}" +CLI_BIN="${APW_CLI_BINARY_PATH:-$ROOT_DIR/rust/target/release/apw}" +REQUIRED="${APW_NOTARIZE_REQUIRED:-0}" +DRY_RUN="${APW_NOTARIZE_DRY_RUN:-0}" + +required_env=( + APPLE_DEVELOPER_CERT_P12 + APPLE_CERT_PASSWORD + APPLE_TEAM_ID + APPLE_NOTARY_KEY_ID + APPLE_NOTARY_KEY_ISSUER + APPLE_NOTARY_PRIVATE_KEY +) + +missing=() +for name in "${required_env[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi +done + +if [[ "${#missing[@]}" -gt 0 ]]; then + message="Apple notarization credentials are incomplete; missing: ${missing[*]}" + if [[ "$REQUIRED" == "1" ]]; then + echo "$message" >&2 + exit 1 + fi + echo "::warning::$message. Skipping notarization." + exit 0 +fi + +if [[ ! -d "$APP_DIR" ]]; then + echo "APW app bundle not found: $APP_DIR" >&2 + exit 1 +fi + +if [[ "$DRY_RUN" == "1" ]]; then + echo "Notarization inputs are present for $APP_DIR; dry run requested." + exit 0 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Notarization requires macOS; current platform is $(uname -s)." >&2 + exit 1 +fi + +for tool in codesign ditto security spctl xcrun; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Required notarization tool not found on PATH: $tool" >&2 + exit 1 + fi +done + +decode_base64() { + local value="$1" + local output="$2" + if ! printf '%s' "$value" | base64 --decode >"$output" 2>/dev/null; then + printf '%s' "$value" | base64 -D >"$output" + fi +} + +tmp_dir="$(mktemp -d)" +keychain="$tmp_dir/apw-notarization.keychain-db" +cert_path="$tmp_dir/developer-id.p12" +notary_key_path="$tmp_dir/notary-key.p8" +zip_path="$tmp_dir/APW.app.zip" +keychain_password="$(uuidgen | tr -d '-')" +signing_identity="${APPLE_DEVELOPER_IDENTITY:-Developer ID Application}" + +cleanup() { + security delete-keychain "$keychain" >/dev/null 2>&1 || true + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +decode_base64 "$APPLE_DEVELOPER_CERT_P12" "$cert_path" +decode_base64 "$APPLE_NOTARY_PRIVATE_KEY" "$notary_key_path" +chmod 0600 "$cert_path" "$notary_key_path" + +security create-keychain -p "$keychain_password" "$keychain" +security set-keychain-settings -lut 21600 "$keychain" +security unlock-keychain -p "$keychain_password" "$keychain" +security import "$cert_path" -k "$keychain" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" + +existing_keychains=() +while IFS= read -r keychain_path; do + existing_keychains+=("${keychain_path//\"/}") +done < <(security list-keychains -d user) +security list-keychains -d user -s "$keychain" "${existing_keychains[@]}" + +if [[ -x "$CLI_BIN" ]]; then + codesign --force --options runtime --timestamp --sign "$signing_identity" "$CLI_BIN" +fi + +codesign --force --deep --options runtime --timestamp --sign "$signing_identity" "$APP_DIR" +codesign --verify --deep --strict --verbose=2 "$APP_DIR" + +ditto -c -k --keepParent "$APP_DIR" "$zip_path" +xcrun notarytool submit "$zip_path" \ + --key "$notary_key_path" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_KEY_ISSUER" \ + --wait +xcrun stapler staple "$APP_DIR" +spctl --assess --type execute --verbose=2 "$APP_DIR" + +echo "APW.app signed, notarized, and stapled: $APP_DIR" diff --git a/scripts/test-notarize-native-app.sh b/scripts/test-notarize-native-app.sh new file mode 100755 index 0000000..b7f3f8c --- /dev/null +++ b/scripts/test-notarize-native-app.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +fake_app="$tmp_dir/APW.app" +mkdir -p "$fake_app/Contents/MacOS" +printf '#!/usr/bin/env sh\nexit 0\n' >"$fake_app/Contents/MacOS/APW" +chmod 0755 "$fake_app/Contents/MacOS/APW" + +required_log="$tmp_dir/notary-required.log" +optional_log="$tmp_dir/notary-optional.log" +dry_run_log="$tmp_dir/notary-dry-run.log" + +if APW_NOTARIZE_REQUIRED=1 APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/notarize-native-app.sh" >"$required_log" 2>&1; then + echo "notarize-native-app accepted missing required credentials." >&2 + exit 1 +fi +grep -q "APPLE_DEVELOPER_CERT_P12" "$required_log" + +APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/notarize-native-app.sh" >"$optional_log" 2>&1 +grep -q "Skipping notarization" "$optional_log" + +env \ + APW_NOTARIZE_DRY_RUN=1 \ + APW_APP_BUNDLE_PATH="$fake_app" \ + APPLE_DEVELOPER_CERT_P12=dGVzdA== \ + APPLE_CERT_PASSWORD=test \ + APPLE_TEAM_ID=ABCDE12345 \ + APPLE_NOTARY_KEY_ID=KEYID12345 \ + APPLE_NOTARY_KEY_ISSUER=00000000-0000-0000-0000-000000000000 \ + APPLE_NOTARY_PRIVATE_KEY=dGVzdA== \ + "$ROOT_DIR/scripts/notarize-native-app.sh" >"$dry_run_log" 2>&1 +grep -q "dry run requested" "$dry_run_log" + +echo "Notarization script tests passed." From 5f6e1b68d3ff212c0af2ebf579eecba402882c89 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:35:19 +0100 Subject: [PATCH 2/5] Drop unused notarization team secret gate --- .github/workflows/release.yml | 1 - docs/bootstrap/onboarding.md | 1 - scripts/notarize-native-app.sh | 1 - scripts/test-notarize-native-app.sh | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1da7fb1..62a6bd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,6 @@ jobs: env: APPLE_DEVELOPER_CERT_P12: ${{ secrets.APPLE_DEVELOPER_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} APPLE_NOTARY_KEY_ISSUER: ${{ secrets.APPLE_NOTARY_KEY_ISSUER }} APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index 911c3ff..56fe64f 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -56,7 +56,6 @@ | ---------------------------- | ------------------------------------------------------------- | | `APPLE_DEVELOPER_CERT_P12` | base64-encoded Developer ID Application .p12 (issue #7) | | `APPLE_CERT_PASSWORD` | passphrase for the .p12 above | - | `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | | `APPLE_NOTARY_KEY_ID` | App Store Connect API key id used by `notarytool` | | `APPLE_NOTARY_KEY_ISSUER` | App Store Connect issuer UUID | | `APPLE_NOTARY_PRIVATE_KEY` | base64-encoded `.p8` private key for `notarytool` | diff --git a/scripts/notarize-native-app.sh b/scripts/notarize-native-app.sh index 808f02f..f8744fa 100755 --- a/scripts/notarize-native-app.sh +++ b/scripts/notarize-native-app.sh @@ -10,7 +10,6 @@ DRY_RUN="${APW_NOTARIZE_DRY_RUN:-0}" required_env=( APPLE_DEVELOPER_CERT_P12 APPLE_CERT_PASSWORD - APPLE_TEAM_ID APPLE_NOTARY_KEY_ID APPLE_NOTARY_KEY_ISSUER APPLE_NOTARY_PRIVATE_KEY diff --git a/scripts/test-notarize-native-app.sh b/scripts/test-notarize-native-app.sh index b7f3f8c..82056d2 100755 --- a/scripts/test-notarize-native-app.sh +++ b/scripts/test-notarize-native-app.sh @@ -31,7 +31,6 @@ env \ APW_APP_BUNDLE_PATH="$fake_app" \ APPLE_DEVELOPER_CERT_P12=dGVzdA== \ APPLE_CERT_PASSWORD=test \ - APPLE_TEAM_ID=ABCDE12345 \ APPLE_NOTARY_KEY_ID=KEYID12345 \ APPLE_NOTARY_KEY_ISSUER=00000000-0000-0000-0000-000000000000 \ APPLE_NOTARY_PRIVATE_KEY=dGVzdA== \ From 1555470544b8891792e5916639ffa549219e452c Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:07:40 +0100 Subject: [PATCH 3/5] Guard notarization credential contract Document that notarytool uses App Store Connect API key credentials and add a regression check that the script does not require an unused Apple Team ID variable. --- scripts/notarize-native-app.sh | 2 ++ scripts/test-notarize-native-app.sh | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/scripts/notarize-native-app.sh b/scripts/notarize-native-app.sh index f8744fa..a06d521 100755 --- a/scripts/notarize-native-app.sh +++ b/scripts/notarize-native-app.sh @@ -7,6 +7,8 @@ CLI_BIN="${APW_CLI_BINARY_PATH:-$ROOT_DIR/rust/target/release/apw}" REQUIRED="${APW_NOTARIZE_REQUIRED:-0}" DRY_RUN="${APW_NOTARIZE_DRY_RUN:-0}" +# The notarization flow uses App Store Connect API key credentials for +# `notarytool`; the Developer ID certificate is only for codesigning. required_env=( APPLE_DEVELOPER_CERT_P12 APPLE_CERT_PASSWORD diff --git a/scripts/test-notarize-native-app.sh b/scripts/test-notarize-native-app.sh index 82056d2..d3f5721 100755 --- a/scripts/test-notarize-native-app.sh +++ b/scripts/test-notarize-native-app.sh @@ -22,6 +22,10 @@ if APW_NOTARIZE_REQUIRED=1 APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/no exit 1 fi grep -q "APPLE_DEVELOPER_CERT_P12" "$required_log" +if grep -q "APPLE_TEAM_ID" "$ROOT_DIR/scripts/notarize-native-app.sh"; then + echo "notarize-native-app must not require unused Apple Team ID credentials." >&2 + exit 1 +fi APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/notarize-native-app.sh" >"$optional_log" 2>&1 grep -q "Skipping notarization" "$optional_log" From e2af2385c2cbcc78442f054cde031f033e292133 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:10:40 +0100 Subject: [PATCH 4/5] Split notarization credential groups Separate codesigning and notarytool credentials before assembling the required environment list so the notarization contract reflects only actually consumed inputs. --- scripts/notarize-native-app.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/notarize-native-app.sh b/scripts/notarize-native-app.sh index a06d521..8e264fa 100755 --- a/scripts/notarize-native-app.sh +++ b/scripts/notarize-native-app.sh @@ -7,16 +7,19 @@ CLI_BIN="${APW_CLI_BINARY_PATH:-$ROOT_DIR/rust/target/release/apw}" REQUIRED="${APW_NOTARIZE_REQUIRED:-0}" DRY_RUN="${APW_NOTARIZE_DRY_RUN:-0}" -# The notarization flow uses App Store Connect API key credentials for -# `notarytool`; the Developer ID certificate is only for codesigning. -required_env=( +codesigning_env=( APPLE_DEVELOPER_CERT_P12 APPLE_CERT_PASSWORD +) + +notarytool_env=( APPLE_NOTARY_KEY_ID APPLE_NOTARY_KEY_ISSUER APPLE_NOTARY_PRIVATE_KEY ) +required_env=("${codesigning_env[@]}" "${notarytool_env[@]}") + missing=() for name in "${required_env[@]}"; do if [[ -z "${!name:-}" ]]; then From 60171a67957fc60c9abaad80c52b3750fcec308b Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:16:58 +0100 Subject: [PATCH 5/5] Retry interrupted daemon test datagrams Handle EINTR while sending and receiving UDP datagrams in the native daemon test helper so CI signal delivery does not fail the lint workflow test run. --- rust/src/daemon.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/src/daemon.rs b/rust/src/daemon.rs index 6d1463e..88033b0 100644 --- a/rust/src/daemon.rs +++ b/rust/src/daemon.rs @@ -2596,13 +2596,19 @@ mod tests { .set_read_timeout(Some(StdDuration::from_secs(1))) .unwrap(); let payload = serde_json::to_vec(request).unwrap(); - socket.send_to(&payload, ("127.0.0.1", port)).unwrap(); + loop { + match socket.send_to(&payload, ("127.0.0.1", port)) { + Ok(_) => break, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => panic!("failed to send daemon test request: {error}"), + } + } let mut buffer = vec![0_u8; 4096]; let size = loop { match socket.recv(&mut buffer) { Ok(size) => break size, - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, Err(error) => panic!("failed to receive daemon test response: {error}"), } };