Skip to content

Commit 762dad9

Browse files
committed
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 <mudler@localai.io>
1 parent 7978312 commit 762dad9

8 files changed

Lines changed: 181 additions & 18 deletions

File tree

.github/workflows/release.yaml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ jobs:
2424
args: release --clean
2525
env:
2626
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
MACOS_SIGN_P12: ${{ secrets.MACOS_CERTIFICATE }}
28+
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
29+
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
30+
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
31+
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
2732
launcher-build-darwin:
2833
runs-on: macos-latest
2934
steps:
@@ -35,9 +40,19 @@ jobs:
3540
uses: actions/setup-go@v5
3641
with:
3742
go-version: 1.23
38-
- name: Build launcher for macOS ARM64
39-
run: |
40-
make build-launcher-darwin
43+
- name: Import signing certificate
44+
env:
45+
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
46+
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
47+
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.MACOS_CI_KEYCHAIN_PWD }}
48+
run: bash contrib/macos/sign-and-notarize.sh import-cert
49+
- name: Build, sign and notarize the DMG
50+
env:
51+
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
52+
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
53+
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
54+
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
55+
run: make release-launcher-darwin
4156
- name: Upload DMG to Release
4257
uses: softprops/action-gh-release@v3
4358
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,6 @@ core/http/react-ui/test-results/
9494

9595
# SDD / brainstorm scratch (agent-driven development)
9696
.superpowers/
97+
98+
# Local Apple signing material (never commit)
99+
.certs/

.goreleaser.yaml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ source:
99
enabled: true
1010
name_template: '{{ .ProjectName }}-{{ .Tag }}-source'
1111
builds:
12-
- main: ./cmd/local-ai
12+
- id: local-ai
13+
main: ./cmd/local-ai
1314
env:
1415
- CGO_ENABLED=0
1516
ldflags:
@@ -35,3 +36,19 @@ snapshot:
3536
version_template: "{{ .Tag }}-next"
3637
changelog:
3738
use: github-native
39+
# Sign + notarize the macOS server binary via the quill backend (runs on Linux,
40+
# no macOS runner needed). Disabled automatically when MACOS_SIGN_P12 is unset
41+
# (forks / PRs), so those builds stay unsigned and green.
42+
notarize:
43+
macos:
44+
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
45+
ids:
46+
- local-ai
47+
sign:
48+
certificate: "{{.Env.MACOS_SIGN_P12}}"
49+
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
50+
notarize:
51+
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
52+
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
53+
key: "{{.Env.MACOS_NOTARY_KEY}}"
54+
wait: true

Makefile

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,13 +1449,32 @@ docs: docs/static/gallery.html
14491449
########################################################
14501450

14511451
## fyne cross-platform build
1452-
build-launcher-darwin: build-launcher
1453-
go run github.com/tiagomelo/macos-dmg-creator/cmd/createdmg@latest \
1454-
--appName "LocalAI" \
1455-
--appBinaryPath "$(LAUNCHER_BINARY_NAME)" \
1456-
--bundleIdentifier "com.localai.launcher" \
1457-
--iconPath "core/http/static/logo.png" \
1458-
--outputDir "dist/"
1452+
# Build LocalAI.app from the launcher via fyne (metadata read from cmd/launcher/FyneApp.toml).
1453+
# Signing happens via contrib/macos/sign-and-notarize.sh, which is a no-op when the signing
1454+
# secrets are unset, so unsigned local/fork builds keep working.
1455+
build-launcher-darwin:
1456+
rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app
1457+
mkdir -p dist
1458+
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)
1459+
mv cmd/launcher/LocalAI.app dist/LocalAI.app
1460+
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
1461+
1462+
# Wrap the (signed) app into a drag-to-Applications DMG via hdiutil, then sign the DMG.
1463+
dmg-launcher-darwin: build-launcher-darwin
1464+
rm -rf dist/dmg dist/LocalAI.dmg
1465+
mkdir -p dist/dmg
1466+
cp -R dist/LocalAI.app dist/dmg/LocalAI.app
1467+
ln -s /Applications dist/dmg/Applications
1468+
hdiutil create -volname "LocalAI" -srcfolder dist/dmg -ov -format UDZO dist/LocalAI.dmg
1469+
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.dmg
1470+
1471+
# Submit the DMG to Apple notarization and staple the ticket (no-op without notary secrets).
1472+
notarize-launcher-darwin: dmg-launcher-darwin
1473+
bash contrib/macos/sign-and-notarize.sh notarize dist/LocalAI.dmg
1474+
1475+
# Single entrypoint for CI: build -> sign app -> dmg -> sign dmg -> notarize -> staple.
1476+
release-launcher-darwin: notarize-launcher-darwin
1477+
@echo "dist/LocalAI.dmg is ready"
14591478

14601479
build-launcher-linux:
1461-
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
1480+
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

cmd/launcher/FyneApp.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Website = "https://localai.io"
2+
3+
[Details]
4+
Icon = "../../core/http/static/logo.png"
5+
Name = "LocalAI"
6+
ID = "com.localai.launcher"
7+
Version = "0.0.0"
8+
Build = 1
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.network.client</key>
6+
<true/>
7+
<key>com.apple.security.network.server</key>
8+
<true/>
9+
<key>com.apple.security.cs.allow-jit</key>
10+
<true/>
11+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
12+
<true/>
13+
</dict>
14+
</plist>

contrib/macos/sign-and-notarize.sh

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env bash
2+
# Code-sign and notarize macOS artifacts for LocalAI.
3+
# Every sub-command is a no-op (exit 0) when its required secret is unset,
4+
# so unsigned builds (forks, local dev, PRs) keep working.
5+
set -euo pipefail
6+
7+
ENTITLEMENTS="contrib/macos/Launcher.entitlements"
8+
KEYCHAIN="localai-ci.keychain-db"
9+
10+
cmd_import_cert() {
11+
if [ -z "${MACOS_CERTIFICATE:-}" ]; then
12+
echo "[sign] MACOS_CERTIFICATE unset: skipping cert import (unsigned build)"
13+
return 0
14+
fi
15+
local certfile keychain_pwd default_keychain
16+
certfile="$(mktemp).p12"
17+
keychain_pwd="${MACOS_CI_KEYCHAIN_PWD:?MACOS_CI_KEYCHAIN_PWD required when signing}"
18+
echo "$MACOS_CERTIFICATE" | base64 --decode > "$certfile"
19+
security create-keychain -p "$keychain_pwd" "$KEYCHAIN"
20+
security set-keychain-settings -lut 21600 "$KEYCHAIN"
21+
security unlock-keychain -p "$keychain_pwd" "$KEYCHAIN"
22+
security import "$certfile" -k "$KEYCHAIN" -P "${MACOS_CERTIFICATE_PWD:?}" \
23+
-T /usr/bin/codesign -T /usr/bin/security
24+
security set-key-partition-list -S apple-tool:,apple:,codesign: \
25+
-s -k "$keychain_pwd" "$KEYCHAIN" >/dev/null
26+
default_keychain="$(security default-keychain | tr -d ' "')"
27+
security list-keychains -d user -s "$KEYCHAIN" "$default_keychain"
28+
rm -f "$certfile"
29+
echo "[sign] certificate imported into $KEYCHAIN"
30+
}
31+
32+
cmd_sign() {
33+
local target="$1"
34+
if [ -z "${MACOS_SIGN_IDENTITY:-}" ]; then
35+
echo "[sign] MACOS_SIGN_IDENTITY unset: skipping codesign of $target"
36+
return 0
37+
fi
38+
case "$target" in
39+
*.app)
40+
# Hardened runtime + entitlements are required for notarizing the app bundle.
41+
codesign --deep --force --options runtime --timestamp \
42+
--entitlements "$ENTITLEMENTS" \
43+
--sign "$MACOS_SIGN_IDENTITY" "$target"
44+
;;
45+
*)
46+
# A disk image carries no entitlements/runtime; just sign the container.
47+
codesign --force --timestamp --sign "$MACOS_SIGN_IDENTITY" "$target"
48+
;;
49+
esac
50+
codesign --verify --strict --verbose=2 "$target"
51+
echo "[sign] signed $target"
52+
}
53+
54+
cmd_notarize() {
55+
local dmg="$1"
56+
if [ -z "${MACOS_NOTARY_KEY:-}" ]; then
57+
echo "[notarize] MACOS_NOTARY_KEY unset: skipping notarization of $dmg"
58+
return 0
59+
fi
60+
local keyfile
61+
keyfile="$(mktemp).p8"
62+
echo "$MACOS_NOTARY_KEY" | base64 --decode > "$keyfile"
63+
xcrun notarytool submit "$dmg" \
64+
--key "$keyfile" \
65+
--key-id "${MACOS_NOTARY_KEY_ID:?}" \
66+
--issuer "${MACOS_NOTARY_ISSUER_ID:?}" \
67+
--wait
68+
rm -f "$keyfile"
69+
xcrun stapler staple "$dmg"
70+
xcrun stapler validate "$dmg"
71+
echo "[notarize] notarized and stapled $dmg"
72+
}
73+
74+
main() {
75+
local sub="${1:-}"; shift || true
76+
case "$sub" in
77+
import-cert) cmd_import_cert ;;
78+
sign) cmd_sign "$@" ;;
79+
notarize) cmd_notarize "$@" ;;
80+
*) echo "usage: $0 {import-cert|sign <path>|notarize <dmg>}" >&2; exit 2 ;;
81+
esac
82+
}
83+
84+
main "$@"

docs/content/installation/macos.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ Download the latest DMG from GitHub releases:
2222
3. Drag the LocalAI application to your Applications folder
2323
4. Launch LocalAI from your Applications folder
2424

25-
## Known Issues
25+
## Verification
2626

27-
> **Note**: The DMGs are not signed by Apple and may show as quarantined.
28-
>
29-
> **Workaround**: See [this issue](https://github.com/mudler/LocalAI/issues/6268) for details on how to bypass the quarantine.
30-
>
31-
> **Fix tracking**: The signing issue is being tracked in [this issue](https://github.com/mudler/LocalAI/issues/6244).
27+
The `LocalAI.dmg` (and the app inside it) and the `local-ai` server binary are
28+
signed with an Apple Developer ID and notarized by Apple, so they launch with no
29+
quarantine prompt or workaround. To inspect the signature yourself:
30+
31+
```bash
32+
spctl --assess --type open --context context:primary-signature -v /Applications/LocalAI.app
33+
codesign --verify --deep --strict --verbose=2 /Applications/LocalAI.app
34+
```
3235

3336
## Next Steps
3437

0 commit comments

Comments
 (0)