Skip to content

Commit bb8fe42

Browse files
committed
feat: sign and notarize macOS DMG releases
Wire Developer ID + App Store Connect API notarization into the release workflow, ship arch-specific DMGs, and prefer them in the release installer.
1 parent 7a17958 commit bb8fe42

6 files changed

Lines changed: 205 additions & 43 deletions

File tree

.github/workflows/release.yml

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,25 +65,108 @@ jobs:
6565
node-version: "22"
6666
cache: npm
6767
cache-dependency-path: apps/microbridge-ui/package-lock.json
68-
- name: Build Microbridge.app
68+
69+
- name: Import Developer ID certificate
70+
env:
71+
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
72+
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
73+
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
74+
run: |
75+
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
76+
echo "APPLE_CERTIFICATE secret missing — UI build will be unsigned"
77+
echo "SIGNED=0" >> "$GITHUB_ENV"
78+
exit 0
79+
fi
80+
echo "SIGNED=1" >> "$GITHUB_ENV"
81+
CERT_PATH="$RUNNER_TEMP/certificate.p12"
82+
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
83+
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
84+
security default-keychain -s build.keychain
85+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
86+
security set-keychain-settings -t 3600 -u build.keychain
87+
security import "$CERT_PATH" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" \
88+
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
89+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
90+
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed -e 's/"//g')
91+
IDENTITY="${{ secrets.APPLE_SIGNING_IDENTITY }}"
92+
if [[ -z "$IDENTITY" ]]; then
93+
IDENTITY="$(security find-identity -v -p codesigning build.keychain \
94+
| awk -F'"' '/Developer ID Application/{print $2; exit}')"
95+
fi
96+
test -n "$IDENTITY"
97+
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
98+
security find-identity -v -p codesigning build.keychain
99+
100+
- name: Prepare App Store Connect API key for notarization
101+
if: env.SIGNED == '1'
102+
env:
103+
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
104+
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
105+
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
106+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
107+
run: |
108+
test -n "${APPLE_API_KEY:-}"
109+
test -n "${APPLE_API_ISSUER:-}"
110+
test -n "${APPLE_API_KEY_P8:-}"
111+
KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
112+
printf '%s\n' "$APPLE_API_KEY_P8" > "$KEY_PATH"
113+
chmod 600 "$KEY_PATH"
114+
{
115+
echo "APPLE_API_KEY=$APPLE_API_KEY"
116+
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER"
117+
echo "APPLE_API_KEY_PATH=$KEY_PATH"
118+
echo "APPLE_TEAM_ID=${APPLE_TEAM_ID:-3NQG568C4Q}"
119+
} >> "$GITHUB_ENV"
120+
121+
- name: Build signed Microbridge.app + DMG
69122
working-directory: apps/microbridge-ui
123+
env:
124+
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
125+
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
126+
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
127+
APPLE_API_KEY: ${{ env.APPLE_API_KEY }}
128+
APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }}
129+
APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }}
130+
APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }}
70131
run: |
71132
npm ci
72-
npm run tauri build -- --bundles app --target ${{ matrix.target }}
73-
- name: Package .app
133+
if [[ "${SIGNED:-0}" == "1" ]]; then
134+
npm run tauri build -- --bundles app,dmg --target ${{ matrix.target }}
135+
else
136+
npm run tauri build -- --bundles app --target ${{ matrix.target }}
137+
fi
138+
139+
- name: Package .app and DMG
74140
run: |
75-
APP="$(find apps/microbridge-ui/src-tauri/target/${{ matrix.target }}/release/bundle/macos apps/microbridge-ui/src-tauri/target/release/bundle/macos -name 'Microbridge.app' -type d 2>/dev/null | head -n1)"
141+
set -euo pipefail
142+
APP="$(find apps/microbridge-ui/src-tauri/target/${{ matrix.target }}/release/bundle/macos \
143+
apps/microbridge-ui/src-tauri/target/release/bundle/macos \
144+
-name 'Microbridge.app' -type d 2>/dev/null | head -n1)"
76145
test -n "$APP"
77146
STAGE="microbridge-ui-${GITHUB_REF_NAME}-${{ matrix.target }}"
78-
mkdir -p "staging/${STAGE}"
147+
mkdir -p "staging/${STAGE}" "ui-out"
79148
cp -R "$APP" "staging/${STAGE}/"
80149
cp README.md LICENSE-MIT LICENSE-APACHE INSTALL.md "staging/${STAGE}/"
81-
tar -C staging -czf "${STAGE}.tar.gz" "${STAGE}"
82-
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
150+
tar -C staging -czf "ui-out/${STAGE}.tar.gz" "${STAGE}"
151+
152+
DMG="$(find apps/microbridge-ui/src-tauri/target/${{ matrix.target }}/release/bundle/dmg \
153+
apps/microbridge-ui/src-tauri/target/release/bundle/dmg \
154+
-name '*.dmg' -type f 2>/dev/null | head -n1 || true)"
155+
if [[ -n "${DMG:-}" ]]; then
156+
DMG_OUT="ui-out/microbridge-ui-${GITHUB_REF_NAME}-${{ matrix.target }}.dmg"
157+
cp "$DMG" "$DMG_OUT"
158+
echo "Packed signed DMG: $DMG_OUT"
159+
spctl --assess --type open --context context:primary-signature "$DMG_OUT" || true
160+
codesign -dv --verbose=2 "$APP" || true
161+
else
162+
echo "No DMG produced (unsigned build or bundle skipped)"
163+
fi
164+
ls -la ui-out
165+
83166
- uses: actions/upload-artifact@v7
84167
with:
85168
name: ui-${{ matrix.target }}
86-
path: ${{ env.ASSET }}
169+
path: ui-out/*
87170

88171
publish:
89172
name: publish release
@@ -97,7 +180,7 @@ jobs:
97180
- name: Collect assets
98181
run: |
99182
mkdir -p release-assets
100-
find artifacts -name '*.tar.gz' -exec cp {} release-assets/ \;
183+
find artifacts -type f \( -name '*.tar.gz' -o -name '*.dmg' \) -exec cp {} release-assets/ \;
101184
ls -la release-assets
102185
- uses: softprops/action-gh-release@v3
103186
with:
@@ -117,6 +200,12 @@ jobs:
117200
118201
Upgrade later: `brew update && brew upgrade microbridge`
119202
203+
### Direct download (signed + notarized DMG)
204+
205+
Grab `microbridge-ui-${{ github.ref_name }}-aarch64-apple-darwin.dmg` (Apple Silicon)
206+
or `…-x86_64-apple-darwin.dmg` (Intel) from the assets below, open it, and drag
207+
Microbridge into Applications. Pair with the daemon archive or Homebrew formula.
208+
120209
Binary + app archive: `./scripts/install-from-release.sh ${{ github.ref_name }}`
121210
122211
Full guide: [INSTALL.md](INSTALL.md).

INSTALL.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,19 @@ Sample unit: [`scripts/microbridge.service`](scripts/microbridge.service).
8383
## Install from a GitHub Release (binaries)
8484

8585
When a `v*` tag is published, CI attaches platform archives (daemon +
86-
arch-specific `Microbridge.app` for `aarch64-apple-darwin` /
87-
`x86_64-apple-darwin`):
86+
arch-specific menu bar app). On macOS, releases also include a
87+
**Developer ID–signed and notarized** DMG
88+
(`microbridge-ui-<tag>-<arch>.dmg`).
8889

8990
```sh
90-
./scripts/install-from-release.sh # latest
91+
./scripts/install-from-release.sh # latest (prefers DMG on macOS)
9192
./scripts/install-from-release.sh v0.0.1
9293
```
9394

95+
Or open the DMG from the GitHub Release page and drag Microbridge into
96+
Applications, then install/start the daemon via Homebrew or the daemon
97+
archive.
98+
9499
## Layout after install
95100

96101
| Path | Purpose |
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.cs.allow-jit</key>
6+
<true/>
7+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8+
<true/>
9+
<key>com.apple.security.cs.disable-library-validation</key>
10+
<true/>
11+
<key>com.apple.security.network.client</key>
12+
<true/>
13+
</dict>
14+
</plist>

apps/microbridge-ui/src-tauri/tauri.conf.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@
6565
"icons/icon.png"
6666
],
6767
"macOS": {
68-
"minimumSystemVersion": "13.0"
68+
"minimumSystemVersion": "13.0",
69+
"hardenedRuntime": true,
70+
"entitlements": "Entitlements.plist"
6971
}
7072
}
7173
}

docs/macos-signing.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# macOS signing & notarization
2+
3+
Direct-download DMGs are signed with **Developer ID Application** and
4+
notarized via the App Store Connect API. This is **not** Mac App Store
5+
distribution.
6+
7+
## GitHub Actions secrets
8+
9+
| Secret | Purpose |
10+
|---|---|
11+
| `APPLE_CERTIFICATE` | Base64-encoded `.p12` (Developer ID Application + private key) |
12+
| `APPLE_CERTIFICATE_PASSWORD` | Password for that `.p12` |
13+
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Vig Solutions LLC (3NQG568C4Q)` |
14+
| `KEYCHAIN_PASSWORD` | Ephemeral CI keychain password |
15+
| `APPLE_API_KEY` | App Store Connect API Key ID |
16+
| `APPLE_API_ISSUER` | App Store Connect Issuer ID |
17+
| `APPLE_API_KEY_P8` | Contents of the `.p8` private key file |
18+
| `APPLE_TEAM_ID` | Team ID (`3NQG568C4Q`) |
19+
20+
Release tags (`v*`) run `.github/workflows/release.yml`, which builds
21+
`app` + `dmg` bundles with Tauri when those secrets are present.
22+
23+
## Local assets (do not commit)
24+
25+
Developer ID material lives outside the repo, typically under
26+
`~/.asc/signing/developer-id/`.

scripts/install-from-release.sh

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -102,28 +102,22 @@ EOF
102102
launchctl enable "gui/$(id -u)/${LABEL}"
103103
launchctl kickstart -k "gui/$(id -u)/${LABEL}"
104104

105-
UI_ASSET="microbridge-ui-${TAG}-${TARGET}.tar.gz"
106-
UI_URL="https://github.com/${REPO}/releases/download/${TAG}/${UI_ASSET}"
107-
# Backward-compatible fallback for older releases that shipped a single asset.
108-
UI_FALLBACK="microbridge-ui-${TAG}-macos.tar.gz"
109-
echo "==> Downloading menu bar app $UI_URL"
110-
if curl -fsSL -o "$TMP/$UI_ASSET" "$UI_URL" \
111-
|| curl -fsSL -o "$TMP/$UI_ASSET" "https://github.com/${REPO}/releases/download/${TAG}/${UI_FALLBACK}"; then
112-
tar -xzf "$TMP/$UI_ASSET" -C "$TMP"
113-
APP_SRC="$(find "$TMP" -name 'Microbridge.app' -type d | head -n1 || true)"
114-
if [[ -n "$APP_SRC" ]]; then
115-
DEST="$HOME/Applications/Microbridge.app"
116-
MARKER="$DEST/.microbridge-release"
117-
if [[ -d "$DEST" && ! -f "$MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then
118-
echo " warning: $DEST exists and is not release-managed — leave it"
119-
echo " set MICROBRIDGE_FORCE_APP=1 to replace"
120-
else
121-
rm -rf "$DEST"
122-
mkdir -p "$HOME/Applications"
123-
cp -R "$APP_SRC" "$DEST"
124-
echo "owned-by-release" >"$MARKER"
125-
UI_PLIST="$HOME/Library/LaunchAgents/${UI_LABEL}.plist"
126-
cat >"$UI_PLIST" <<EOF
105+
DEST="$HOME/Applications/Microbridge.app"
106+
MARKER="$DEST/.microbridge-release"
107+
install_app_bundle() {
108+
local APP_SRC="$1"
109+
if [[ -d "$DEST" && ! -f "$MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then
110+
echo " warning: $DEST exists and is not release-managed — leave it"
111+
echo " set MICROBRIDGE_FORCE_APP=1 to replace"
112+
return 0
113+
fi
114+
rm -rf "$DEST"
115+
mkdir -p "$HOME/Applications"
116+
cp -R "$APP_SRC" "$DEST"
117+
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
118+
echo "owned-by-release" >"$MARKER"
119+
UI_PLIST="$HOME/Library/LaunchAgents/${UI_LABEL}.plist"
120+
cat >"$UI_PLIST" <<EOF
127121
<?xml version="1.0" encoding="UTF-8"?>
128122
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
129123
<plist version="1.0">
@@ -141,17 +135,49 @@ EOF
141135
</dict>
142136
</plist>
143137
EOF
144-
launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true
145-
launchctl bootstrap "gui/$(id -u)" "$UI_PLIST"
146-
launchctl enable "gui/$(id -u)/${UI_LABEL}"
147-
launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/Applications/Microbridge.app"
148-
echo " installed ~/Applications/Microbridge.app"
138+
launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true
139+
launchctl bootstrap "gui/$(id -u)" "$UI_PLIST"
140+
launchctl enable "gui/$(id -u)/${UI_LABEL}"
141+
launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/Applications/Microbridge.app"
142+
echo " installed ~/Applications/Microbridge.app"
143+
}
144+
145+
DMG_ASSET="microbridge-ui-${TAG}-${TARGET}.dmg"
146+
DMG_URL="https://github.com/${REPO}/releases/download/${TAG}/${DMG_ASSET}"
147+
UI_ASSET="microbridge-ui-${TAG}-${TARGET}.tar.gz"
148+
UI_URL="https://github.com/${REPO}/releases/download/${TAG}/${UI_ASSET}"
149+
# Backward-compatible fallback for older releases that shipped a single asset.
150+
UI_FALLBACK="microbridge-ui-${TAG}-macos.tar.gz"
151+
152+
INSTALLED_UI=0
153+
echo "==> Trying signed DMG $DMG_URL"
154+
if curl -fsSL -o "$TMP/$DMG_ASSET" "$DMG_URL"; then
155+
MOUNT="$(mktemp -d "$TMP/dmg.XXXXXX")"
156+
if hdiutil attach "$TMP/$DMG_ASSET" -mountpoint "$MOUNT" -nobrowse -quiet; then
157+
APP_SRC="$(find "$MOUNT" -name 'Microbridge.app' -type d | head -n1 || true)"
158+
if [[ -n "$APP_SRC" ]]; then
159+
install_app_bundle "$APP_SRC"
160+
INSTALLED_UI=1
161+
fi
162+
hdiutil detach "$MOUNT" -quiet || true
163+
fi
164+
fi
165+
166+
if [[ "$INSTALLED_UI" -eq 0 ]]; then
167+
echo "==> Downloading menu bar app archive $UI_URL"
168+
if curl -fsSL -o "$TMP/$UI_ASSET" "$UI_URL" \
169+
|| curl -fsSL -o "$TMP/$UI_ASSET" "https://github.com/${REPO}/releases/download/${TAG}/${UI_FALLBACK}"; then
170+
tar -xzf "$TMP/$UI_ASSET" -C "$TMP"
171+
APP_SRC="$(find "$TMP" -name 'Microbridge.app' -type d | head -n1 || true)"
172+
if [[ -n "$APP_SRC" ]]; then
173+
install_app_bundle "$APP_SRC"
174+
INSTALLED_UI=1
175+
else
176+
echo " warning: archive had no Microbridge.app"
149177
fi
150178
else
151-
echo " warning: archive had no Microbridge.app"
179+
echo " warning: no UI asset for ${TAG} — install UI with ./scripts/install.sh or brew"
152180
fi
153-
else
154-
echo " warning: no UI asset for ${TAG} — install UI with ./scripts/install.sh or brew"
155181
fi
156182
fi
157183

0 commit comments

Comments
 (0)