From 762dad91c0b054e220665a4dfd92181d375bc45e Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 25 Jun 2026 14:25:05 +0000 Subject: [PATCH] feat(macos): sign and notarize the DMG, app, and server binary Produce a Gatekeeper-clean macOS distribution with no user workaround: - Launcher DMG + the LocalAI.app inside it are built via fyne, codesigned with the Developer ID under the hardened runtime, then the DMG is signed, notarized (notarytool) and stapled. Replaces macos-dmg-creator (which had no signing hook) with fyne package + hdiutil so we control the .app before packaging. - The bare local-ai darwin server binary is signed + notarized via GoReleaser's native notarize block (quill backend, runs on Linux). - All signing is gated on secrets being present, so forks/PRs/local builds stay unsigned and green (contrib/macos/sign-and-notarize.sh no-ops). - Add hardened-runtime entitlements and FyneApp.toml for deterministic packaging; update macOS install docs to drop the quarantine workaround. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto --- .github/workflows/release.yaml | 21 ++++++-- .gitignore | 3 ++ .goreleaser.yaml | 19 ++++++- Makefile | 35 +++++++++--- cmd/launcher/FyneApp.toml | 8 +++ contrib/macos/Launcher.entitlements | 14 +++++ contrib/macos/sign-and-notarize.sh | 84 +++++++++++++++++++++++++++++ docs/content/installation/macos.md | 15 +++--- 8 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 cmd/launcher/FyneApp.toml create mode 100644 contrib/macos/Launcher.entitlements create mode 100755 contrib/macos/sign-and-notarize.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 614c1de3e..b1b585c4b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,6 +24,11 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MACOS_SIGN_P12: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} launcher-build-darwin: runs-on: macos-latest steps: @@ -35,9 +40,19 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.23 - - name: Build launcher for macOS ARM64 - run: | - make build-launcher-darwin + - name: Import signing certificate + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.MACOS_CI_KEYCHAIN_PWD }} + run: bash contrib/macos/sign-and-notarize.sh import-cert + - name: Build, sign and notarize the DMG + env: + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: make release-launcher-darwin - name: Upload DMG to Release uses: softprops/action-gh-release@v3 with: diff --git a/.gitignore b/.gitignore index 177c79cba..91582c006 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ core/http/react-ui/test-results/ # SDD / brainstorm scratch (agent-driven development) .superpowers/ + +# Local Apple signing material (never commit) +.certs/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 71c9c96e4..88d6e9ecd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,8 @@ source: enabled: true name_template: '{{ .ProjectName }}-{{ .Tag }}-source' builds: - - main: ./cmd/local-ai + - id: local-ai + main: ./cmd/local-ai env: - CGO_ENABLED=0 ldflags: @@ -35,3 +36,19 @@ snapshot: version_template: "{{ .Tag }}-next" changelog: use: github-native +# Sign + notarize the macOS server binary via the quill backend (runs on Linux, +# no macOS runner needed). Disabled automatically when MACOS_SIGN_P12 is unset +# (forks / PRs), so those builds stay unsigned and green. +notarize: + macos: + - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + ids: + - local-ai + sign: + certificate: "{{.Env.MACOS_SIGN_P12}}" + password: "{{.Env.MACOS_SIGN_PASSWORD}}" + notarize: + issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" + key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" + key: "{{.Env.MACOS_NOTARY_KEY}}" + wait: true diff --git a/Makefile b/Makefile index be0711b47..7ec8293bb 100644 --- a/Makefile +++ b/Makefile @@ -1449,13 +1449,32 @@ docs: docs/static/gallery.html ######################################################## ## fyne cross-platform build -build-launcher-darwin: build-launcher - go run github.com/tiagomelo/macos-dmg-creator/cmd/createdmg@latest \ - --appName "LocalAI" \ - --appBinaryPath "$(LAUNCHER_BINARY_NAME)" \ - --bundleIdentifier "com.localai.launcher" \ - --iconPath "core/http/static/logo.png" \ - --outputDir "dist/" +# Build LocalAI.app from the launcher via fyne (metadata read from cmd/launcher/FyneApp.toml). +# Signing happens via contrib/macos/sign-and-notarize.sh, which is a no-op when the signing +# secrets are unset, so unsigned local/fork builds keep working. +build-launcher-darwin: + rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app + mkdir -p dist + cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME) + mv cmd/launcher/LocalAI.app dist/LocalAI.app + bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app + +# Wrap the (signed) app into a drag-to-Applications DMG via hdiutil, then sign the DMG. +dmg-launcher-darwin: build-launcher-darwin + rm -rf dist/dmg dist/LocalAI.dmg + mkdir -p dist/dmg + cp -R dist/LocalAI.app dist/dmg/LocalAI.app + ln -s /Applications dist/dmg/Applications + hdiutil create -volname "LocalAI" -srcfolder dist/dmg -ov -format UDZO dist/LocalAI.dmg + bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.dmg + +# Submit the DMG to Apple notarization and staple the ticket (no-op without notary secrets). +notarize-launcher-darwin: dmg-launcher-darwin + bash contrib/macos/sign-and-notarize.sh notarize dist/LocalAI.dmg + +# Single entrypoint for CI: build -> sign app -> dmg -> sign dmg -> notarize -> staple. +release-launcher-darwin: notarize-launcher-darwin + @echo "dist/LocalAI.dmg is ready" build-launcher-linux: - cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv launcher.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz + cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz diff --git a/cmd/launcher/FyneApp.toml b/cmd/launcher/FyneApp.toml new file mode 100644 index 000000000..cb0fc38b9 --- /dev/null +++ b/cmd/launcher/FyneApp.toml @@ -0,0 +1,8 @@ +Website = "https://localai.io" + +[Details] +Icon = "../../core/http/static/logo.png" +Name = "LocalAI" +ID = "com.localai.launcher" +Version = "0.0.0" +Build = 1 diff --git a/contrib/macos/Launcher.entitlements b/contrib/macos/Launcher.entitlements new file mode 100644 index 000000000..a46f95113 --- /dev/null +++ b/contrib/macos/Launcher.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/contrib/macos/sign-and-notarize.sh b/contrib/macos/sign-and-notarize.sh new file mode 100755 index 000000000..73497c769 --- /dev/null +++ b/contrib/macos/sign-and-notarize.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Code-sign and notarize macOS artifacts for LocalAI. +# Every sub-command is a no-op (exit 0) when its required secret is unset, +# so unsigned builds (forks, local dev, PRs) keep working. +set -euo pipefail + +ENTITLEMENTS="contrib/macos/Launcher.entitlements" +KEYCHAIN="localai-ci.keychain-db" + +cmd_import_cert() { + if [ -z "${MACOS_CERTIFICATE:-}" ]; then + echo "[sign] MACOS_CERTIFICATE unset: skipping cert import (unsigned build)" + return 0 + fi + local certfile keychain_pwd default_keychain + certfile="$(mktemp).p12" + keychain_pwd="${MACOS_CI_KEYCHAIN_PWD:?MACOS_CI_KEYCHAIN_PWD required when signing}" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$certfile" + security create-keychain -p "$keychain_pwd" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$keychain_pwd" "$KEYCHAIN" + security import "$certfile" -k "$KEYCHAIN" -P "${MACOS_CERTIFICATE_PWD:?}" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_pwd" "$KEYCHAIN" >/dev/null + default_keychain="$(security default-keychain | tr -d ' "')" + security list-keychains -d user -s "$KEYCHAIN" "$default_keychain" + rm -f "$certfile" + echo "[sign] certificate imported into $KEYCHAIN" +} + +cmd_sign() { + local target="$1" + if [ -z "${MACOS_SIGN_IDENTITY:-}" ]; then + echo "[sign] MACOS_SIGN_IDENTITY unset: skipping codesign of $target" + return 0 + fi + case "$target" in + *.app) + # Hardened runtime + entitlements are required for notarizing the app bundle. + codesign --deep --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$MACOS_SIGN_IDENTITY" "$target" + ;; + *) + # A disk image carries no entitlements/runtime; just sign the container. + codesign --force --timestamp --sign "$MACOS_SIGN_IDENTITY" "$target" + ;; + esac + codesign --verify --strict --verbose=2 "$target" + echo "[sign] signed $target" +} + +cmd_notarize() { + local dmg="$1" + if [ -z "${MACOS_NOTARY_KEY:-}" ]; then + echo "[notarize] MACOS_NOTARY_KEY unset: skipping notarization of $dmg" + return 0 + fi + local keyfile + keyfile="$(mktemp).p8" + echo "$MACOS_NOTARY_KEY" | base64 --decode > "$keyfile" + xcrun notarytool submit "$dmg" \ + --key "$keyfile" \ + --key-id "${MACOS_NOTARY_KEY_ID:?}" \ + --issuer "${MACOS_NOTARY_ISSUER_ID:?}" \ + --wait + rm -f "$keyfile" + xcrun stapler staple "$dmg" + xcrun stapler validate "$dmg" + echo "[notarize] notarized and stapled $dmg" +} + +main() { + local sub="${1:-}"; shift || true + case "$sub" in + import-cert) cmd_import_cert ;; + sign) cmd_sign "$@" ;; + notarize) cmd_notarize "$@" ;; + *) echo "usage: $0 {import-cert|sign |notarize }" >&2; exit 2 ;; + esac +} + +main "$@" diff --git a/docs/content/installation/macos.md b/docs/content/installation/macos.md index fc254cedb..dfda42df2 100644 --- a/docs/content/installation/macos.md +++ b/docs/content/installation/macos.md @@ -22,13 +22,16 @@ Download the latest DMG from GitHub releases: 3. Drag the LocalAI application to your Applications folder 4. Launch LocalAI from your Applications folder -## Known Issues +## Verification -> **Note**: The DMGs are not signed by Apple and may show as quarantined. -> -> **Workaround**: See [this issue](https://github.com/mudler/LocalAI/issues/6268) for details on how to bypass the quarantine. -> -> **Fix tracking**: The signing issue is being tracked in [this issue](https://github.com/mudler/LocalAI/issues/6244). +The `LocalAI.dmg` (and the app inside it) and the `local-ai` server binary are +signed with an Apple Developer ID and notarized by Apple, so they launch with no +quarantine prompt or workaround. To inspect the signature yourself: + +```bash +spctl --assess --type open --context context:primary-signature -v /Applications/LocalAI.app +codesign --verify --deep --strict --verbose=2 /Applications/LocalAI.app +``` ## Next Steps