diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94ed18b..8d234d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,18 @@ jobs: name: macOS Universal (fat binary) needs: build-macos runs-on: macos-15 + # The signing secrets live in the "ENV" environment, so this job must opt + # into it to read them. + environment: ENV + # Surface signing secrets as env so steps can gate on them (the `secrets` + # context is not allowed in `if:`). Empty when unconfigured → ad-hoc path. + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} steps: - uses: actions/checkout@v4 @@ -102,9 +114,26 @@ jobs: target/x86_64-apple-darwin/release/holler \ -output target/universal-apple-darwin/release/holler + # Import the Developer ID cert into a throwaway keychain so codesign can + # find it. Skipped (→ ad-hoc signing) until the secret is configured. + - name: Import signing certificate + if: env.MACOS_CERT_P12_BASE64 != '' + run: | + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PW="$(openssl rand -base64 24)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed s/\"//g) + - name: Bundle universal .app env: BINARY_PATH: target/universal-apple-darwin/release/holler + # Empty when unconfigured → bundle-macos.sh falls back to ad-hoc. + SIGN_IDENTITY: ${{ env.MACOS_SIGN_IDENTITY }} run: | if [ "$GITHUB_REF_TYPE" = "tag" ]; then export VERSION="${GITHUB_REF_NAME#v}"; fi bash scripts/bundle-macos.sh @@ -116,6 +145,16 @@ jobs: -ov -format UDZO \ Holler-macOS-universal.dmg + # Notarize the DMG with Apple and staple the ticket so it installs without + # a Gatekeeper warning, even offline. Skipped until secrets are configured. + - name: Notarize & staple + if: env.APPLE_ID != '' + run: | + xcrun notarytool submit Holler-macOS-universal.dmg \ + --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" --wait + xcrun stapler staple Holler-macOS-universal.dmg + - name: Upload universal DMG uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index d2b8349..10d4456 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,10 @@ cargo run # run from the terminal (see logs); injection/keychain For logs while using the bundle’s stable identity, run the inner binary: `./Holler.app/Contents/MacOS/holler`. -> Note: ad-hoc signing ties permissions to the exact binary, so after -> rebuilding the bundle you may need to re-approve Accessibility. A Developer ID -> signature (Phase 3) makes the grant permanent. +> Note: local/ad-hoc signing ties permissions to the exact binary, so after +> rebuilding you may need to re-approve Accessibility. Release DMGs are signed +> with a **Developer ID** and notarized once the signing secrets are configured +> (see [`docs/SIGNING.md`](docs/SIGNING.md)), which also makes the grant stick. ### Troubleshooting diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1a9d4d1..0f5a0a6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ Captured during the planning session on **2026-06-08** with Yassir. These are ** | Copy memory | Clipboard set **and** searchable SQLite history | Yassir chose "Both". | | AI providers | Provider-agnostic/BYOK behind traits — Claude, OpenAI, Deepgram, local OpenAI-compatible (Ollama) | Flexibility. **Key storage REVISED 2026-06-10:** moved from the OS keychain (`keyring`) to a `0600` `secrets.toml` in the config dir (separate from `config.toml`). Ad-hoc-signed macOS bundles change identity each rebuild, so the keychain TCC grant never stuck and macOS re-prompted on every run. `HOLLER__KEY` env var overrides the file. | | TTS | Deferred to phase 3 | De-risk v1; design traits now, build later. | -| Distribution | Public eventually → plan signing/notarization + permission onboarding from the start | Stable Developer ID; clean macOS TCC flow. | +| Distribution | Public eventually → plan signing/notarization + permission onboarding from the start | Stable Developer ID; clean macOS TCC flow. **2026-06-10:** repo now public; Developer ID signing + notarization wired into CI (gated on secrets, ad-hoc fallback) — see `docs/SIGNING.md`. | ## Recommendations I made on Yassir's behalf (open to challenge) - **Defer GUI to phase 2**, ship Phase 1 with a TOML config file. Removes the tray+hotkey+egui main-thread-loop integration risk from the MVP critical path. diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..04fa20a --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,67 @@ +# macOS code signing & notarization + +The release pipeline signs the universal macOS DMG with a **Developer ID** +certificate, notarizes it with Apple, and staples the ticket — so it installs +without a Gatekeeper warning on any Mac, and the Accessibility/Microphone grants +survive app rebuilds (a stable signing identity). Until the secrets below are +set, CI falls back to **ad-hoc** signing (Gatekeeper still warns). + +## One-time setup + +### 1. Create a "Developer ID Application" certificate +Xcode → Settings → Accounts → your Apple ID → **Manage Certificates** → + → +**Developer ID Application**. (Requires the paid Apple Developer Program.) + +Find its full identity string and your Team ID: +```bash +security find-identity -v -p codesigning +# → "Developer ID Application: Your Name (ABCDE12345)" ← TEAMID is the (...) +``` + +### 2. Export the cert as a `.p12` and base64 it +Keychain Access → My Certificates → right-click the *Developer ID Application* +cert → **Export** → `.p12` (set an export password). Then: +```bash +base64 -i Certificate.p12 | pbcopy # base64 now on the clipboard +``` + +### 3. Create an app-specific password +[appleid.apple.com](https://appleid.apple.com) → Sign-In & Security → +**App-Specific Passwords** → generate one for "Holler notarization". + +### 4. Add these GitHub repo Secrets +Repo → Settings → Secrets and variables → **Actions** → New repository secret: + +| Secret | Value | +|---|---| +| `MACOS_CERT_P12_BASE64` | the base64 string from step 2 | +| `MACOS_CERT_PASSWORD` | the `.p12` export password | +| `MACOS_SIGN_IDENTITY` | `Developer ID Application: Your Name (TEAMID)` (exact string from step 1) | +| `APPLE_ID` | your Apple ID email | +| `APPLE_TEAM_ID` | the Team ID, e.g. `ABCDE12345` | +| `APPLE_APP_PASSWORD` | the app-specific password from step 3 | + +> Never put these in the repo or paste them in chat — repo Secrets only. + +### 5. Release +Tag as usual — the next release DMG is signed, notarized, and stapled: +```bash +git tag -a v0.1.1 -m "..." && git push origin v0.1.1 +``` +Verify a downloaded DMG would pass Gatekeeper on a clean Mac: +```bash +spctl -a -t open --context context:primary-signature -v Holler-macOS-universal.dmg # → accepted +``` + +## Signing locally (optional) +On your own Mac with the cert in your keychain: +```bash +SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bash scripts/bundle-macos.sh +``` +Notarization then mirrors the CI step (`xcrun notarytool submit … --wait` → +`xcrun stapler staple …`). Entitlements live in `scripts/holler.entitlements` +(hardened runtime needs `com.apple.security.device.audio-input` for the mic). + +## Windows +Windows signing (an OV/EV Authenticode cert to avoid SmartScreen) is a separate, +still-deferred task — the current Windows ZIP is unsigned. diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index f41aff3..af5319c 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -72,9 +72,24 @@ cat > "$APP_DIR/Contents/Info.plist" < PLIST -echo "==> Ad-hoc code signing" -codesign --force --sign - --timestamp=none "$APP_DIR/Contents/MacOS/holler" -codesign --force --sign - --timestamp=none "$APP_DIR" +# Sign with a real Developer ID when SIGN_IDENTITY is set (CI/release), so the +# app passes Gatekeeper for everyone and the TCC grant survives rebuilds. +# Otherwise fall back to ad-hoc for local dev (Gatekeeper will warn). +ENTITLEMENTS="$(dirname "$0")/holler.entitlements" +if [[ -n "${SIGN_IDENTITY:-}" ]]; then + echo "==> Developer ID signing + hardened runtime ($SIGN_IDENTITY)" + # Entitlements + hardened runtime go on the executable; the bundle wrapper is + # then sealed over it. A secure --timestamp is required for notarization. + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_IDENTITY" "$APP_DIR/Contents/MacOS/holler" + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$APP_DIR" +else + echo "==> Ad-hoc code signing (no SIGN_IDENTITY — Gatekeeper will warn on other Macs)" + codesign --force --sign - --timestamp=none "$APP_DIR/Contents/MacOS/holler" + codesign --force --sign - --timestamp=none "$APP_DIR" +fi codesign --verify --verbose "$APP_DIR" echo diff --git a/scripts/holler.entitlements b/scripts/holler.entitlements new file mode 100644 index 0000000..1ca21b5 --- /dev/null +++ b/scripts/holler.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.device.audio-input + + +