Skip to content

Sign and notarize macOS release binaries with Apple Developer ID #135

@laradji

Description

@laradji

Decision (locked 2026-04-17)

Sign and notarize macOS release binaries using a paid Apple Developer ID Individual account (99 USD/yr). Alternatives considered and rejected:

  • Ad-hoc sign (codesign -s -): leaves the Gatekeeper "unidentified developer" prompt; user still needs to right-click → Open or run xattr -d com.apple.quarantine on first launch. Half-measure.
  • Document the xattr -d workaround in README: shifts friction onto every first-time user — terrible first impression for a pre-1.0 project.
  • No signing at all (status quo): macOS 14+ blocks browser-downloaded tarballs outright; brew install via the tap hits the same block.

Paid Developer ID + notarize = zero-friction install; correct long-term choice.

Why

release.yml builds darwin/arm64 and darwin/amd64 binaries on macos-15 runners (lines 40-41, 134-135) but performs no codesigning or notarization. A user downloading the release tarball or installing via the Homebrew tap (#123) is blocked by Gatekeeper on first run. Target milestone is 0.3 — 0.1 and 0.2 already shipped unsigned, but the signing gap should close before broader distribution effort ramps up.

Acceptance criteria

  • Active Apple Developer ID Individual enrollment; Developer ID Application certificate + private key exported as .p12, base64-encoded, stored as repo secret APPLE_DEVELOPER_ID_CERT_P12_B64.
  • Additional secrets stored on laradji/deadzone:
    • APPLE_DEVELOPER_ID_CERT_PASSWORD — the .p12 export password
    • APPLE_ID — the Apple ID email
    • APPLE_APP_PASSWORD — app-specific password generated at appleid.apple.com
    • APPLE_TEAM_ID — 10-char team identifier from developer.apple.com/account
  • .github/apple/entitlements.plist checked in, minimal set necessary for the hardened runtime to coexist with the runtime dlopen of libonnxruntime in internal/ort.Bootstrap. Start from the sketch below, but trim empirically: each entitlement is an attack-surface admission — keep only those that are actually required for the scrape/consolidate/server smoke tests to pass on a signed+notarized binary.
  • .github/workflows/release.yml has a new sign-notarize step that runs only on darwin matrix entries, after go build and before archiving, and performs (in order):
    • Import cert into an ephemeral keychain via security create-keychain / security import / security set-key-partition-list.
    • codesign --force --options runtime --timestamp --entitlements .github/apple/entitlements.plist --sign "Developer ID Application: <team>" deadzone.
    • ditto -c -k --keepParent deadzone deadzone.zip (required for notarytool submit).
    • xcrun notarytool submit deadzone.zip --apple-id <secret> --team-id <secret> --password <secret> --wait.
    • xcrun stapler staple deadzone (embeds the notarization ticket so it verifies offline).
    • codesign --verify --deep --strict --verbose=2 deadzone → exits 0.
    • spctl --assess --type execute --verbose deadzone → prints source=Notarized Developer ID.
  • Temporary keychain is deleted in an if: always() cleanup step so a mid-job failure doesn't leak keys on the runner.
  • CI fails loudly (not silently) if any of the five Apple secrets is missing — no skip-when-empty passthrough.
  • Smoke test on a clean macOS machine (or via curl download on macOS): after tar xzf, running ./deadzone -version does not show a Gatekeeper prompt and does not require xattr -d com.apple.quarantine.
  • CLAUDE.md## Release operator secrets section gains an entry for each new Apple secret, following the same format as the existing HOMEBREW_TAP_TOKEN entry: what it does, which workflow consumes it, how failures surface, and the renewal procedure (Apple cert is 1-year validity, Apple sends a reminder email ~30 days before expiry).

Implementation notes

Entitlements sketch (.github/apple/entitlements.plist) — start here, then minimize:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
  <key>com.apple.security.cs.disable-library-validation</key><true/>
</dict>
</plist>

Rationale: internal/ort.Bootstrap downloads libonnxruntime at first launch from a SHA256-pinned URL and dlopens it. The hardened runtime (mandatory for notarization) will block that dlopen without disable-library-validation (the dylib is not signed by the same Team ID as the deadzone binary). allow-dyld-environment-variables is needed because hugot sometimes reads DYLD_LIBRARY_PATH. allow-unsigned-executable-memory may also be required for ORT's tensor arena — determine empirically by checking whether ./deadzone scrape against a single-URL fixture completes without a crash under the hardened runtime. Do not add entitlements speculatively.

release.yml skeleton (sketch, not prescriptive — finalize in implementation; currently matrix entries macos-15 / goos: darwin at lines 40-41 and 134-135):

- name: Import Developer ID cert
  if: matrix.goos == 'darwin'
  env:
    CERT_B64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_P12_B64 }}
    CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }}
  run: |
    set -euo pipefail
    KEYCHAIN=$RUNNER_TEMP/build.keychain
    echo "$CERT_B64" | base64 --decode > $RUNNER_TEMP/cert.p12
    security create-keychain -p "" "$KEYCHAIN"
    security set-keychain-settings -lut 21600 "$KEYCHAIN"
    security unlock-keychain -p "" "$KEYCHAIN"
    security import $RUNNER_TEMP/cert.p12 -k "$KEYCHAIN" -P "$CERT_PASS" -T /usr/bin/codesign
    security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
    security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"
    rm $RUNNER_TEMP/cert.p12

- name: Sign and notarize
  if: matrix.goos == 'darwin'
  env:
    APPLE_ID: ${{ secrets.APPLE_ID }}
    APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
    APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
  run: |
    set -euo pipefail
    codesign --force --options runtime --timestamp \
      --entitlements .github/apple/entitlements.plist \
      --sign "Developer ID Application: ${APPLE_TEAM_ID}" \
      deadzone
    ditto -c -k --keepParent deadzone deadzone.zip
    xcrun notarytool submit deadzone.zip \
      --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \
      --password "$APPLE_APP_PASSWORD" --wait
    xcrun stapler staple deadzone
    codesign --verify --deep --strict --verbose=2 deadzone
    spctl --assess --type execute --verbose deadzone

- name: Cleanup keychain
  if: always() && matrix.goos == 'darwin'
  run: security delete-keychain $RUNNER_TEMP/build.keychain || true

Operator runbook — document in CLAUDE.md the one-time setup steps (enroll, create Developer ID Application cert in Keychain Access, export .p12, encode base64 on macOS with base64 -i cert.p12 -o cert.b64, paste into GH secret) and the yearly renewal path.

Out of scope

Test commands

Local dry-run (ad-hoc sign, skips notarize):

  • just build && codesign --force --options runtime --sign - ./deadzone && codesign --verify --strict --verbose=2 ./deadzone — validates that the entitlements plist parses and the binary survives codesign with the hardened runtime flag.

Post-tag verification against published asset:

  • curl -L -o dz.tar.gz <release-asset-url> && tar xzf dz.tar.gz
  • codesign --verify --deep --strict --verbose=2 deadzone → exits 0, prints "deadzone: valid on disk" and "satisfies its Designated Requirement"
  • spctl --assess --type execute --verbose deadzone → exits 0, prints "source=Notarized Developer ID"
  • ./deadzone -version → runs immediately, no Gatekeeper prompt, no quarantine attribute

Dependencies

Metadata

Metadata

Assignees

Labels

P2Normal — clear value, not urgentfeatureNew feature

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions