diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..707cdac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Cut a signed + notarized DMG and publish it to a GitHub Release when a +# v* tag is pushed, e.g.: git tag v0.2.0 && git push origin v0.2.0 +on: + push: + tags: ["v*"] + +permissions: + contents: write # required to create the Release and upload assets + +jobs: + release: + name: Sign, notarize & publish + # Dormant until you opt in: set repository variable RELEASE_ENABLED=true + # (Settings → Secrets and variables → Actions → Variables) once the signing + # secrets are in place. Until then a v* tag push is a no-op. + if: vars.RELEASE_ENABLED == 'true' + runs-on: macos-15 + defaults: + run: + working-directory: driver + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags + full history for version/build stamping + + # --- Import the Developer ID cert into an ephemeral keychain ------------ + - name: Import signing certificate + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + # Make it the default so codesign finds the identity. + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + rm -f "$RUNNER_TEMP/cert.p12" + + # --- Write the App Store Connect API key for notarytool ---------------- + - name: Write notarization API key + env: + NOTARY_KEY: ${{ secrets.NOTARY_KEY }} + run: echo "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary_key.p8" + + - name: Build app bundle + run: scripts/build-app.sh build + + - name: Sign, notarize & package DMG + env: + SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + NOTARY_KEY_PATH: ${{ runner.temp }}/notary_key.p8 + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: scripts/release.sh build + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + build/Nib-*.dmg + + - name: Clean up keychain & key + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/notary_key.p8" diff --git a/.gitignore b/.gitignore index e13e31a..b15c396 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ driver/.build/ *.o *.swiftmodule +# Release artifacts (signed app bundle + DMG) +driver/build/ + # Compiled RE tools (source is tracked; rebuild with clang) captures/hidinfo diff --git a/driver/INSTALL.md b/driver/INSTALL.md index d6f67e5..7467cbe 100644 --- a/driver/INSTALL.md +++ b/driver/INSTALL.md @@ -71,6 +71,7 @@ rm ~/Library/LaunchAgents/com.modfox.nib.plist rm -rf ~/Applications/Nib.app ``` -## TODO for a distributable build -- Sign with a Developer ID identity + notarize so the cdhash is stable and grants persist - across updates (ad-hoc re-signing currently resets TCC). +## Distributable builds +The steps above use the self-signed `modfox-codesign` identity, which is fine for a +single machine. To produce a signed + notarized `.dmg` for GitHub Releases (stable +cdhash, so grants persist across updates), see **[RELEASING.md](RELEASING.md)**. diff --git a/driver/RELEASING.md b/driver/RELEASING.md new file mode 100644 index 0000000..0aee29b --- /dev/null +++ b/driver/RELEASING.md @@ -0,0 +1,126 @@ +# Releasing a signed, notarized build + +Distributable builds are **signed with a Developer ID Application certificate and +notarized by Apple**, then published as a `.dmg` on GitHub Releases. Notarization +is what lets other people download `Nib.app` and open it without Gatekeeper +warnings — and, because the code hash is stable across notarized updates, it keeps +the Input Monitoring + Accessibility (TCC) grants from resetting on every update +(the problem called out in `INSTALL.md`). + +This requires an **Apple Developer Program** membership ($99/yr). + +--- + +## One-time setup + +### 1. Create the Developer ID Application certificate + +In Xcode: **Settings → Accounts → (your team) → Manage Certificates → `+` → +Developer ID Application**. (Or create it at +.) + +Confirm it's installed and note the exact identity string: + +``` +security find-identity -v -p codesigning +# -> "Developer ID Application: Your Name (TEAMID)" +``` + +That full string (including the `(TEAMID)`) is your **`SIGNING_IDENTITY`**. + +### 2. Create an App Store Connect API key (for notarization) + +`notarytool` authenticates with an API key instead of your Apple ID + password — +better for CI (no 2FA prompts). At + → **Keys** → `+`: + +- Access role: **Developer** is sufficient for notarization. +- Download the `AuthKey_XXXXXXXX.p8` (**you can only download it once**). +- Note the **Key ID** and the **Issuer ID** shown on that page. + +### 3. Verify the whole pipeline locally (recommended before wiring CI) + +Store the notarization credentials as a reusable keychain profile: + +``` +xcrun notarytool store-credentials nib-notary \ + --key /path/to/AuthKey_XXXXXXXX.p8 \ + --key-id \ + --issuer +``` + +Then build → sign → notarize → staple in one go: + +``` +cd driver +scripts/build-app.sh build +SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ +NOTARY_PROFILE=nib-notary \ + scripts/release.sh build +# -> build/Nib-.dmg (signed, notarized, stapled) +``` + +A green run ends with `The staple and validate action worked!` and a Gatekeeper +`accepted` line. That same DMG is exactly what CI produces. + +--- + +## CI setup (GitHub Actions) + +The `.github/workflows/release.yml` workflow runs on any `v*` tag: it imports the +cert into an ephemeral keychain, builds the universal app, signs + notarizes + +staples the DMG, and publishes it to a GitHub Release. + +> **The workflow is dormant by default.** Its job is gated on +> `if: vars.RELEASE_ENABLED == 'true'`, so a `v*` tag push does nothing until you +> opt in. When you're ready, set a repository **variable** `RELEASE_ENABLED` to +> `true` (Settings → Secrets and variables → Actions → **Variables**). Removing the +> variable (or setting it to anything else) disables releases again. + +Add these under **Settings → Secrets and variables → Actions → New repository +secret**: + +| Secret | How to produce it | +| -------------------------- | ----------------- | +| `MACOS_CERTIFICATE` | Export the Developer ID cert **with its private key** from Keychain Access as a `.p12`, then `base64 -i cert.p12 \| pbcopy`. | +| `MACOS_CERTIFICATE_PWD` | The password you set when exporting the `.p12`. | +| `MACOS_SIGNING_IDENTITY` | The full `Developer ID Application: Your Name (TEAMID)` string. | +| `KEYCHAIN_PASSWORD` | Any random string — names the throwaway CI keychain. | +| `NOTARY_KEY` | `base64 -i AuthKey_XXXXXXXX.p8 \| pbcopy`. | +| `NOTARY_KEY_ID` | The API key's Key ID. | +| `NOTARY_ISSUER` | The App Store Connect Issuer ID. | + +> Exporting the `.p12`: in **Keychain Access**, expand the certificate so its +> private key shows, select **both** rows, right-click → *Export 2 items…* → +> Personal Information Exchange (.p12). + +### Cut a release + +``` +git tag v0.2.0 +git push origin v0.2.0 +``` + +The workflow builds, notarizes, and attaches `Nib-0.2.0.dmg` to a new +`v0.2.0` Release with auto-generated notes. The version string is derived from +the tag (`v0.2.0` → `0.2.0`), so no need to hand-edit `Info.plist`. + +--- + +## How versioning works + +`scripts/build-app.sh` stamps the bundle from git at build time and never mutates +the committed `packaging/Info.plist`: + +- `CFBundleShortVersionString` ← latest tag with the `v` stripped (`VERSION` env overrides). +- `CFBundleVersion` ← commit count (`BUILD_NUM` env overrides). + +## Notes + +- The app is signed with the **Hardened Runtime** (required for notarization) and + an empty `packaging/entitlements.plist` — Nib needs no special entitlements + (HID + CGEvent access is granted at runtime via TCC, not entitlements) and must + **not** be sandboxed, which would block raw HID access. +- Local dev installs (`INSTALL.md`) still use the self-signed `modfox-codesign` + identity — that's fine for a single machine. Developer ID + notarization is only + needed for the artifacts you hand to other people. diff --git a/driver/packaging/entitlements.plist b/driver/packaging/entitlements.plist new file mode 100644 index 0000000..d56c453 --- /dev/null +++ b/driver/packaging/entitlements.plist @@ -0,0 +1,15 @@ + + + + + + + diff --git a/driver/scripts/build-app.sh b/driver/scripts/build-app.sh new file mode 100755 index 0000000..7e470bf --- /dev/null +++ b/driver/scripts/build-app.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Build the release binary and assemble an *unsigned* Nib.app bundle. +# Shared by local dev and the release workflow; signing is done separately +# (scripts/release.sh) so this step needs no certificates. +# +# Usage: scripts/build-app.sh [OUTPUT_DIR] (default OUTPUT_DIR=build) +# Env: +# VERSION CFBundleShortVersionString (default: latest git tag, sans "v") +# BUILD_NUM CFBundleVersion (default: git commit count) +# +set -euo pipefail + +cd "$(dirname "$0")/.." # -> driver/ + +OUT="${1:-build}" +APP="$OUT/Nib.app" + +VERSION="${VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo 0.0.0)}" +BUILD_NUM="${BUILD_NUM:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}" + +echo "==> Building Nib.app (version=$VERSION build=$BUILD_NUM)" + +# Universal binary so the DMG runs on both Apple Silicon and Intel. +ARCHS=(--arch arm64 --arch x86_64) +swift build -c release "${ARCHS[@]}" +BIN="$(swift build -c release "${ARCHS[@]}" --show-bin-path)/nib" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/nib" +cp packaging/Info.plist "$APP/Contents/Info.plist" +cp packaging/Nib.icns "$APP/Contents/Resources/Nib.icns" + +# Stamp the version into the copied plist (never mutate the committed template). +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUM" "$APP/Contents/Info.plist" + +echo "==> Assembled $APP" diff --git a/driver/scripts/release.sh b/driver/scripts/release.sh new file mode 100755 index 0000000..fe352a3 --- /dev/null +++ b/driver/scripts/release.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Sign (Developer ID + Hardened Runtime), package as a DMG, notarize, and staple. +# Produces a notarized build/Nib-.dmg ready to attach to a GitHub Release. +# +# Assumes scripts/build-app.sh has already produced build/Nib.app. +# +# Required env: +# SIGNING_IDENTITY "Developer ID Application: Your Name (TEAMID)" +# +# Notarization credentials — provide ONE of: +# NOTARY_PROFILE name of a stored `notarytool store-credentials` profile (local) +# -- or -- (CI) +# NOTARY_KEY_PATH path to the App Store Connect API key .p8 +# NOTARY_KEY_ID the key's Key ID +# NOTARY_ISSUER the App Store Connect issuer UUID +# +set -euo pipefail + +cd "$(dirname "$0")/.." # -> driver/ + +OUT="${1:-build}" +APP="$OUT/Nib.app" +[[ -d "$APP" ]] || { echo "error: $APP not found — run scripts/build-app.sh first" >&2; exit 1; } + +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" +DMG="$OUT/Nib-$VERSION.dmg" + +: "${SIGNING_IDENTITY:?set SIGNING_IDENTITY to your Developer ID Application identity}" + +# --- notarytool credential args (profile locally, API key in CI) ------------- +notary_args=() +if [[ -n "${NOTARY_PROFILE:-}" ]]; then + notary_args=(--keychain-profile "$NOTARY_PROFILE") +else + : "${NOTARY_KEY_PATH:?set NOTARY_PROFILE or the NOTARY_KEY_* trio}" + : "${NOTARY_KEY_ID:?}" + : "${NOTARY_ISSUER:?}" + notary_args=(--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER") +fi + +echo "==> Codesign app (Hardened Runtime, secure timestamp)" +codesign --force --options runtime --timestamp \ + --entitlements packaging/entitlements.plist \ + --sign "$SIGNING_IDENTITY" \ + "$APP" +codesign --verify --strict --verbose=2 "$APP" + +echo "==> Notarize the app, then staple the ticket into the bundle" +# Staple the .app itself so it launches offline once copied out of the DMG. +APP_ZIP="$OUT/Nib.app.zip" +ditto -c -k --keepParent "$APP" "$APP_ZIP" +xcrun notarytool submit "$APP_ZIP" "${notary_args[@]}" --wait +xcrun stapler staple "$APP" +rm -f "$APP_ZIP" + +echo "==> Build DMG from the stapled app" +STAGE="$(mktemp -d)" +cp -R "$APP" "$STAGE/Nib.app" +ln -s /Applications "$STAGE/Applications" # drag-to-install affordance +rm -f "$DMG" +hdiutil create -volname "Nib" -srcfolder "$STAGE" -ov -format UDZO "$DMG" +rm -rf "$STAGE" + +echo "==> Sign, notarize, and staple the DMG" +codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG" +xcrun notarytool submit "$DMG" "${notary_args[@]}" --wait +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" + +echo "==> Gatekeeper assessment" +spctl -a -t open --context context:primary-signature -v "$DMG" || true + +echo "" +echo "Done: $DMG"