diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0213ccfe..9f20fe61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,9 +201,61 @@ jobs: zip code-assistant-${{ matrix.name }}.zip "code-assistant${{ matrix.extension }}" fi + - name: Set up macOS code signing + if: matrix.os == 'macos-latest' + shell: bash + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: | + # Skip silently when no certificate is configured: the bundle script + # then falls back to ad-hoc signing and the release still builds. + if [ -z "$MACOS_CERT_P12_BASE64" ]; then + echo "No MACOS_CERT_P12_BASE64 secret set; skipping code-signing setup." + exit 0 + fi + + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + + # Materialize the base64-encoded .p12 certificate. + echo -n "$MACOS_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + + # Create a dedicated, ephemeral keychain and make it the default so + # codesign can find the identity non-interactively. + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import the Developer ID Application certificate + private key. + security import "$CERT_PATH" \ + -P "$MACOS_CERT_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Allow codesign/productsign to use the key without a UI prompt. + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Put our keychain in the search list so codesign sees the identity. + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + security default-keychain -s "$KEYCHAIN_PATH" + + echo "Available signing identities:" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + rm -f "$CERT_PATH" + - name: Build macOS .app bundle if: matrix.os == 'macos-latest' shell: bash + env: + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }} + MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }} + MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }} run: | # Map matrix target -> bundle script arch argument case "${{ matrix.target }}" in diff --git a/crates/code_assistant/assets/Entitlements.plist b/crates/code_assistant/assets/Entitlements.plist new file mode 100644 index 00000000..f81a8ce3 --- /dev/null +++ b/crates/code_assistant/assets/Entitlements.plist @@ -0,0 +1,25 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 00000000..7c5feece --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,94 @@ +# macOS Code Signing & Notarization + +The macOS `.app` bundle is produced by `scripts/bundle-macos.sh`. The script is +usable in three ways, driven entirely by environment variables — it has no +CI-specific logic: + +| Scenario | What happens | +| --- | --- | +| No env vars set (default, e.g. a local build) | **Ad-hoc** signature only. No Apple account needed. Not notarized. | +| `MACOS_SIGN_IDENTITY` set | Signs with your **Developer ID Application** identity, hardened runtime + entitlements. | +| Signing identity **and** all notary vars set | Additionally **notarizes** with `xcrun notarytool` and **staples** the ticket. | + +All signing uses only tools shipping with macOS / Xcode (`codesign`, +`xcrun notarytool`, `xcrun stapler`) — no third-party actions or CLIs. + +## Local usage + +### Plain build (no signing) + +```bash +./scripts/bundle-macos.sh # host arch, ad-hoc signed +``` + +### Signed build with your own certificate + +If you have a *Developer ID Application* certificate in your login keychain: + +```bash +# Find the exact identity string: +security find-identity -v -p codesigning + +export MACOS_SIGN_IDENTITY="Developer ID Application: Jane Doe (TEAMID1234)" +./scripts/bundle-macos.sh +``` + +### Signed + notarized build + +Add the notary credentials (an app-specific password created at +): + +```bash +export MACOS_SIGN_IDENTITY="Developer ID Application: Jane Doe (TEAMID1234)" +export MACOS_NOTARY_APPLE_ID="jane@example.com" +export MACOS_NOTARY_PASSWORD="abcd-efgh-ijkl-mnop" # app-specific password +export MACOS_NOTARY_TEAM_ID="TEAMID1234" +./scripts/bundle-macos.sh universal +``` + +## CI usage + +The `Release` workflow (`.github/workflows/release.yml`) does the same thing +automatically on the macOS build jobs. It imports the certificate into an +ephemeral keychain and then calls the exact same bundle script. When the secrets +are **not** configured, the workflow falls back to ad-hoc signing and the +release still succeeds. + +### Required GitHub secrets + +Configure these under **Settings → Secrets and variables → Actions**: + +| Secret | Purpose | +| --- | --- | +| `MACOS_CERT_P12_BASE64` | Your *Developer ID Application* certificate **and private key** exported as a `.p12`, then base64-encoded. | +| `MACOS_CERT_PASSWORD` | The password protecting that `.p12` file. | +| `MACOS_KEYCHAIN_PASSWORD` | Any throwaway password used for the temporary keychain created on the runner. | +| `MACOS_SIGN_IDENTITY` | The identity name, e.g. `Developer ID Application: Jane Doe (TEAMID1234)`. | +| `MACOS_NOTARY_APPLE_ID` | Apple ID email used for notarization. | +| `MACOS_NOTARY_PASSWORD` | App-specific password for that Apple ID. | +| `MACOS_NOTARY_TEAM_ID` | Apple Developer Team ID (10 characters). | + +If only the signing secrets (first four) are set, the app is signed but not +notarized. If none are set, the build is ad-hoc signed. + +### Producing the certificate secrets + +1. In Keychain Access, export your *Developer ID Application* certificate + (including its private key) as a `.p12` file and set a password. +2. Base64-encode it for storage in a secret: + + ```bash + base64 -i DeveloperID.p12 | pbcopy # now paste into MACOS_CERT_P12_BASE64 + ``` + +3. Put the `.p12` password into `MACOS_CERT_PASSWORD`. + +## Entitlements + +Signing under the hardened runtime uses +`crates/code_assistant/assets/Entitlements.plist`. Because the app embeds gpui +(Zed's UI framework), the entitlements allow JIT / unsigned executable memory, +which the hardened runtime otherwise blocks at launch. The set mirrors the +hardened-runtime keys used by Zed itself and deliberately avoids the weaker keys +(library-validation / dyld-environment / executable-page-protection) that Zed +also leaves off and that Apple's notary service scrutinizes. diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index c363cf81..a6c62b33 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -2,7 +2,7 @@ # Build a macOS .app bundle for Code Assistant. # # Uses the static Info.plist from assets/ and replaces only the version. -# Only relies on tools that ship with macOS (plutil, codesign). +# Only relies on tools that ship with macOS / Xcode (plutil, codesign, xcrun). # # Usage: # ./scripts/bundle-macos.sh # build for the host arch @@ -10,6 +10,17 @@ # # ARCH can be: aarch64, x86_64, universal # +# Code signing & notarization (all optional, via environment variables): +# MACOS_SIGN_IDENTITY Developer ID Application identity to sign with, +# e.g. "Developer ID Application: Jane Doe (TEAMID)". +# If unset, the bundle is ad-hoc signed (as before). +# +# Notarization runs only when ALL of these are set (and signing happened +# with a real identity): +# MACOS_NOTARY_APPLE_ID Apple ID email used for notarization. +# MACOS_NOTARY_PASSWORD App-specific password for that Apple ID. +# MACOS_NOTARY_TEAM_ID Apple Developer Team ID. +# # Output: # target/macos-bundle/Code Assistant.app # target/macos-bundle/Code-Assistant--.zip @@ -32,7 +43,7 @@ for arg in "$@"; do aarch64|arm64) ARCH="aarch64" ;; x86_64|intel) ARCH="x86_64" ;; universal) ARCH="universal" ;; - -h|--help) sed -n '2,16p' "$0"; exit 0 ;; + -h|--help) sed -n '2,32p' "$0"; exit 0 ;; *) echo "error: unknown argument: $arg" >&2; exit 1 ;; esac done @@ -134,10 +145,33 @@ printf 'APPL????' > "$CONTENTS/PkgInfo" touch "$APP_DIR" # --------------------------------------------------------------------------- -# Ad-hoc code signature +# Code signature # --------------------------------------------------------------------------- -if command -v codesign >/dev/null 2>&1; then - echo "==> Ad-hoc signing" +# When MACOS_SIGN_IDENTITY is provided we sign with a Developer ID identity, +# enabling the hardened runtime and applying our entitlements (required for +# notarization). Otherwise we fall back to an ad-hoc signature, which is enough +# for local use but cannot be notarized or distributed without Gatekeeper +# warnings. +ENTITLEMENTS="$ASSETS_DIR/Entitlements.plist" + +if ! command -v codesign >/dev/null 2>&1; then + echo "==> codesign not available; skipping signature" +elif [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + echo "==> Signing with Developer ID: $MACOS_SIGN_IDENTITY" + # Sign the inner executable first, then the bundle (deep), all under the + # hardened runtime and with a secure timestamp (required by notarization). + codesign --force --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS" \ + --sign "$MACOS_SIGN_IDENTITY" \ + "$CONTENTS/MacOS/$EXECUTABLE_NAME" + codesign --force --deep --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS" \ + --sign "$MACOS_SIGN_IDENTITY" \ + "$APP_DIR" + echo "==> Verifying signature" + codesign --verify --deep --strict --verbose=2 "$APP_DIR" +else + echo "==> No MACOS_SIGN_IDENTITY set; ad-hoc signing" codesign --force --deep --sign - "$APP_DIR" >/dev/null 2>&1 || true fi @@ -145,8 +179,40 @@ fi # Zip for distribution # --------------------------------------------------------------------------- ZIP_PATH="$OUT_DIR/Code-Assistant-$VERSION-$ARCH.zip" -rm -f "$ZIP_PATH" -( cd "$OUT_DIR" && zip -qry "$ZIP_PATH" "Code Assistant.app" ) +make_zip() { + rm -f "$ZIP_PATH" + # ditto preserves the bundle structure and extended attributes; notarytool + # requires a ditto/zip archive of the .app. + ( cd "$OUT_DIR" && ditto -c -k --keepParent "Code Assistant.app" "$ZIP_PATH" ) +} +make_zip + +# --------------------------------------------------------------------------- +# Notarization + stapling +# --------------------------------------------------------------------------- +# Only attempt notarization when we signed with a real identity and all notary +# credentials are present. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" && + -n "${MACOS_NOTARY_APPLE_ID:-}" && + -n "${MACOS_NOTARY_PASSWORD:-}" && + -n "${MACOS_NOTARY_TEAM_ID:-}" ]]; then + echo "==> Submitting to Apple notary service (this can take a few minutes)" + xcrun notarytool submit "$ZIP_PATH" \ + --apple-id "$MACOS_NOTARY_APPLE_ID" \ + --password "$MACOS_NOTARY_PASSWORD" \ + --team-id "$MACOS_NOTARY_TEAM_ID" \ + --wait + + echo "==> Stapling notarization ticket to the .app" + xcrun stapler staple "$APP_DIR" + xcrun stapler validate "$APP_DIR" + + # Re-zip so the distributed archive contains the stapled bundle. + echo "==> Re-packaging stapled bundle" + make_zip +else + echo "==> Skipping notarization (identity and/or notary credentials not set)" +fi echo echo "==> Done:"