diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49fada6..62a6bd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,19 @@ 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_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..56fe64f 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -56,16 +56,19 @@ | ---------------------------- | ------------------------------------------------------------- | | `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` | | `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/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}"), } }; 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..8e264fa --- /dev/null +++ b/scripts/notarize-native-app.sh @@ -0,0 +1,116 @@ +#!/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}" + +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 + 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..d3f5721 --- /dev/null +++ b/scripts/test-notarize-native-app.sh @@ -0,0 +1,44 @@ +#!/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" +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" + +env \ + APW_NOTARIZE_DRY_RUN=1 \ + APW_APP_BUNDLE_PATH="$fake_app" \ + APPLE_DEVELOPER_CERT_P12=dGVzdA== \ + APPLE_CERT_PASSWORD=test \ + 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."