From bb8fe424a657ff6865d4ac7a2b092c290f2de09e Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Fri, 17 Jul 2026 22:38:07 -0400 Subject: [PATCH] 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. --- .github/workflows/release.yml | 107 ++++++++++++++++-- INSTALL.md | 11 +- .../src-tauri/Entitlements.plist | 14 +++ apps/microbridge-ui/src-tauri/tauri.conf.json | 4 +- docs/macos-signing.md | 26 +++++ scripts/install-from-release.sh | 86 +++++++++----- 6 files changed, 205 insertions(+), 43 deletions(-) create mode 100644 apps/microbridge-ui/src-tauri/Entitlements.plist create mode 100644 docs/macos-signing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08926ff..ab8f327 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,25 +65,108 @@ jobs: node-version: "22" cache: npm cache-dependency-path: apps/microbridge-ui/package-lock.json - - name: Build Microbridge.app + + - name: Import Developer ID certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then + echo "APPLE_CERTIFICATE secret missing — UI build will be unsigned" + echo "SIGNED=0" >> "$GITHUB_ENV" + exit 0 + fi + echo "SIGNED=1" >> "$GITHUB_ENV" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security import "$CERT_PATH" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed -e 's/"//g') + IDENTITY="${{ secrets.APPLE_SIGNING_IDENTITY }}" + if [[ -z "$IDENTITY" ]]; then + IDENTITY="$(security find-identity -v -p codesigning build.keychain \ + | awk -F'"' '/Developer ID Application/{print $2; exit}')" + fi + test -n "$IDENTITY" + echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + security find-identity -v -p codesigning build.keychain + + - name: Prepare App Store Connect API key for notarization + if: env.SIGNED == '1' + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + test -n "${APPLE_API_KEY:-}" + test -n "${APPLE_API_ISSUER:-}" + test -n "${APPLE_API_KEY_P8:-}" + KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + printf '%s\n' "$APPLE_API_KEY_P8" > "$KEY_PATH" + chmod 600 "$KEY_PATH" + { + echo "APPLE_API_KEY=$APPLE_API_KEY" + echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" + echo "APPLE_API_KEY_PATH=$KEY_PATH" + echo "APPLE_TEAM_ID=${APPLE_TEAM_ID:-3NQG568C4Q}" + } >> "$GITHUB_ENV" + + - name: Build signed Microbridge.app + DMG working-directory: apps/microbridge-ui + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_API_KEY: ${{ env.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }} + APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} + APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }} run: | npm ci - npm run tauri build -- --bundles app --target ${{ matrix.target }} - - name: Package .app + if [[ "${SIGNED:-0}" == "1" ]]; then + npm run tauri build -- --bundles app,dmg --target ${{ matrix.target }} + else + npm run tauri build -- --bundles app --target ${{ matrix.target }} + fi + + - name: Package .app and DMG run: | - 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)" + set -euo pipefail + 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)" test -n "$APP" STAGE="microbridge-ui-${GITHUB_REF_NAME}-${{ matrix.target }}" - mkdir -p "staging/${STAGE}" + mkdir -p "staging/${STAGE}" "ui-out" cp -R "$APP" "staging/${STAGE}/" cp README.md LICENSE-MIT LICENSE-APACHE INSTALL.md "staging/${STAGE}/" - tar -C staging -czf "${STAGE}.tar.gz" "${STAGE}" - echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV" + tar -C staging -czf "ui-out/${STAGE}.tar.gz" "${STAGE}" + + DMG="$(find apps/microbridge-ui/src-tauri/target/${{ matrix.target }}/release/bundle/dmg \ + apps/microbridge-ui/src-tauri/target/release/bundle/dmg \ + -name '*.dmg' -type f 2>/dev/null | head -n1 || true)" + if [[ -n "${DMG:-}" ]]; then + DMG_OUT="ui-out/microbridge-ui-${GITHUB_REF_NAME}-${{ matrix.target }}.dmg" + cp "$DMG" "$DMG_OUT" + echo "Packed signed DMG: $DMG_OUT" + spctl --assess --type open --context context:primary-signature "$DMG_OUT" || true + codesign -dv --verbose=2 "$APP" || true + else + echo "No DMG produced (unsigned build or bundle skipped)" + fi + ls -la ui-out + - uses: actions/upload-artifact@v7 with: name: ui-${{ matrix.target }} - path: ${{ env.ASSET }} + path: ui-out/* publish: name: publish release @@ -97,7 +180,7 @@ jobs: - name: Collect assets run: | mkdir -p release-assets - find artifacts -name '*.tar.gz' -exec cp {} release-assets/ \; + find artifacts -type f \( -name '*.tar.gz' -o -name '*.dmg' \) -exec cp {} release-assets/ \; ls -la release-assets - uses: softprops/action-gh-release@v3 with: @@ -117,6 +200,12 @@ jobs: Upgrade later: `brew update && brew upgrade microbridge` + ### Direct download (signed + notarized DMG) + + Grab `microbridge-ui-${{ github.ref_name }}-aarch64-apple-darwin.dmg` (Apple Silicon) + or `…-x86_64-apple-darwin.dmg` (Intel) from the assets below, open it, and drag + Microbridge into Applications. Pair with the daemon archive or Homebrew formula. + Binary + app archive: `./scripts/install-from-release.sh ${{ github.ref_name }}` Full guide: [INSTALL.md](INSTALL.md). diff --git a/INSTALL.md b/INSTALL.md index f09a73a..89c7309 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -83,14 +83,19 @@ Sample unit: [`scripts/microbridge.service`](scripts/microbridge.service). ## Install from a GitHub Release (binaries) When a `v*` tag is published, CI attaches platform archives (daemon + -arch-specific `Microbridge.app` for `aarch64-apple-darwin` / -`x86_64-apple-darwin`): +arch-specific menu bar app). On macOS, releases also include a +**Developer ID–signed and notarized** DMG +(`microbridge-ui--.dmg`). ```sh -./scripts/install-from-release.sh # latest +./scripts/install-from-release.sh # latest (prefers DMG on macOS) ./scripts/install-from-release.sh v0.0.1 ``` +Or open the DMG from the GitHub Release page and drag Microbridge into +Applications, then install/start the daemon via Homebrew or the daemon +archive. + ## Layout after install | Path | Purpose | diff --git a/apps/microbridge-ui/src-tauri/Entitlements.plist b/apps/microbridge-ui/src-tauri/Entitlements.plist new file mode 100644 index 0000000..c0052ff --- /dev/null +++ b/apps/microbridge-ui/src-tauri/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + + diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 7afca30..1608a58 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -65,7 +65,9 @@ "icons/icon.png" ], "macOS": { - "minimumSystemVersion": "13.0" + "minimumSystemVersion": "13.0", + "hardenedRuntime": true, + "entitlements": "Entitlements.plist" } } } diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 0000000..0b91a41 --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,26 @@ +# macOS signing & notarization + +Direct-download DMGs are signed with **Developer ID Application** and +notarized via the App Store Connect API. This is **not** Mac App Store +distribution. + +## GitHub Actions secrets + +| Secret | Purpose | +|---|---| +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` (Developer ID Application + private key) | +| `APPLE_CERTIFICATE_PASSWORD` | Password for that `.p12` | +| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Vig Solutions LLC (3NQG568C4Q)` | +| `KEYCHAIN_PASSWORD` | Ephemeral CI keychain password | +| `APPLE_API_KEY` | App Store Connect API Key ID | +| `APPLE_API_ISSUER` | App Store Connect Issuer ID | +| `APPLE_API_KEY_P8` | Contents of the `.p8` private key file | +| `APPLE_TEAM_ID` | Team ID (`3NQG568C4Q`) | + +Release tags (`v*`) run `.github/workflows/release.yml`, which builds +`app` + `dmg` bundles with Tauri when those secrets are present. + +## Local assets (do not commit) + +Developer ID material lives outside the repo, typically under +`~/.asc/signing/developer-id/`. diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index f4f866f..2ee3a5f 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -102,28 +102,22 @@ EOF launchctl enable "gui/$(id -u)/${LABEL}" launchctl kickstart -k "gui/$(id -u)/${LABEL}" - UI_ASSET="microbridge-ui-${TAG}-${TARGET}.tar.gz" - UI_URL="https://github.com/${REPO}/releases/download/${TAG}/${UI_ASSET}" - # Backward-compatible fallback for older releases that shipped a single asset. - UI_FALLBACK="microbridge-ui-${TAG}-macos.tar.gz" - echo "==> Downloading menu bar app $UI_URL" - if curl -fsSL -o "$TMP/$UI_ASSET" "$UI_URL" \ - || curl -fsSL -o "$TMP/$UI_ASSET" "https://github.com/${REPO}/releases/download/${TAG}/${UI_FALLBACK}"; then - tar -xzf "$TMP/$UI_ASSET" -C "$TMP" - APP_SRC="$(find "$TMP" -name 'Microbridge.app' -type d | head -n1 || true)" - if [[ -n "$APP_SRC" ]]; then - DEST="$HOME/Applications/Microbridge.app" - MARKER="$DEST/.microbridge-release" - if [[ -d "$DEST" && ! -f "$MARKER" && "${MICROBRIDGE_FORCE_APP:-}" != "1" ]]; then - echo " warning: $DEST exists and is not release-managed — leave it" - echo " set MICROBRIDGE_FORCE_APP=1 to replace" - else - rm -rf "$DEST" - mkdir -p "$HOME/Applications" - cp -R "$APP_SRC" "$DEST" - echo "owned-by-release" >"$MARKER" - UI_PLIST="$HOME/Library/LaunchAgents/${UI_LABEL}.plist" - cat >"$UI_PLIST" </dev/null || true + echo "owned-by-release" >"$MARKER" + UI_PLIST="$HOME/Library/LaunchAgents/${UI_LABEL}.plist" + cat >"$UI_PLIST" < @@ -141,17 +135,49 @@ EOF EOF - launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$UI_PLIST" - launchctl enable "gui/$(id -u)/${UI_LABEL}" - launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/Applications/Microbridge.app" - echo " installed ~/Applications/Microbridge.app" + launchctl bootout "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$UI_PLIST" + launchctl enable "gui/$(id -u)/${UI_LABEL}" + launchctl kickstart -k "gui/$(id -u)/${UI_LABEL}" 2>/dev/null || open "$HOME/Applications/Microbridge.app" + echo " installed ~/Applications/Microbridge.app" + } + + DMG_ASSET="microbridge-ui-${TAG}-${TARGET}.dmg" + DMG_URL="https://github.com/${REPO}/releases/download/${TAG}/${DMG_ASSET}" + UI_ASSET="microbridge-ui-${TAG}-${TARGET}.tar.gz" + UI_URL="https://github.com/${REPO}/releases/download/${TAG}/${UI_ASSET}" + # Backward-compatible fallback for older releases that shipped a single asset. + UI_FALLBACK="microbridge-ui-${TAG}-macos.tar.gz" + + INSTALLED_UI=0 + echo "==> Trying signed DMG $DMG_URL" + if curl -fsSL -o "$TMP/$DMG_ASSET" "$DMG_URL"; then + MOUNT="$(mktemp -d "$TMP/dmg.XXXXXX")" + if hdiutil attach "$TMP/$DMG_ASSET" -mountpoint "$MOUNT" -nobrowse -quiet; then + APP_SRC="$(find "$MOUNT" -name 'Microbridge.app' -type d | head -n1 || true)" + if [[ -n "$APP_SRC" ]]; then + install_app_bundle "$APP_SRC" + INSTALLED_UI=1 + fi + hdiutil detach "$MOUNT" -quiet || true + fi + fi + + if [[ "$INSTALLED_UI" -eq 0 ]]; then + echo "==> Downloading menu bar app archive $UI_URL" + if curl -fsSL -o "$TMP/$UI_ASSET" "$UI_URL" \ + || curl -fsSL -o "$TMP/$UI_ASSET" "https://github.com/${REPO}/releases/download/${TAG}/${UI_FALLBACK}"; then + tar -xzf "$TMP/$UI_ASSET" -C "$TMP" + APP_SRC="$(find "$TMP" -name 'Microbridge.app' -type d | head -n1 || true)" + if [[ -n "$APP_SRC" ]]; then + install_app_bundle "$APP_SRC" + INSTALLED_UI=1 + else + echo " warning: archive had no Microbridge.app" fi else - echo " warning: archive had no Microbridge.app" + echo " warning: no UI asset for ${TAG} — install UI with ./scripts/install.sh or brew" fi - else - echo " warning: no UI asset for ${TAG} — install UI with ./scripts/install.sh or brew" fi fi