feat(desktop): wire macOS Developer ID signing + notarization

Add gated code-signing + notarization for the macOS desktop DMG so it can
clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are
rejected after 2026-09-01).

- desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated
  on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly
  as before, so local dev and PR CI are unaffected.
- create-release.yml: import a Developer ID cert into a throwaway keychain on
  the macOS leg and export the signing/notary env. Soft-gated on the
  MAC_CERTIFICATE_P12 secret — no secret => unsigned build.
- BUILDING.md: document the six MAC_* secrets, how to generate them, and flip
  the unsigned-cask fallback note to reflect the wiring is now in place
  (pending Apple credentials).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
This commit is contained in:
Claude
2026-06-17 23:06:07 +00:00
parent 4d7b88aa3f
commit c81dbb0127
3 changed files with 108 additions and 5 deletions
+44
View File
@@ -101,8 +101,52 @@ jobs:
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# macOS only: import the Developer ID Application cert into a throwaway
# keychain so jpackage's codesign pass can find it. Soft-gated — if the
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
# provisioned) the DMG is built UNSIGNED, exactly as before. notarytool
# runs as part of the gradle task when the identity env is exported below.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
env:
CERT_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
CERT_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "${CERT_P12:-}" ]]; then
echo "::notice::MAC_CERTIFICATE_P12 not set — building UNSIGNED DMG."
echo "signing=false" >> "$GITHUB_OUTPUT"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db"
KEYCHAIN_PWD="$(openssl rand -base64 24)"
CERT_PATH="$RUNNER_TEMP/developer_id.p12"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
echo "$CERT_P12" | base64 --decode > "$CERT_PATH"
security import "$CERT_PATH" -P "$CERT_PASSWORD" \
-k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign
# Let codesign use the private key without an interactive UI prompt.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
# Prepend our keychain to the user search list so codesign sees it.
security list-keychains -d user -s "$KEYCHAIN" \
$(security list-keychains -d user | sed -e 's/[\"[:space:]]//g')
rm -f "$CERT_PATH"
echo "signing=true" >> "$GITHUB_OUTPUT"
- name: Build desktop artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
env:
# Empty on non-macOS legs and on the macOS leg when no cert is
# configured — the gradle macOS{} block skips signing when the
# identity is blank.
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_SIGN_IDENTITY || '' }}
AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
with:
max_attempts: 2
timeout_minutes: 15
+34 -5
View File
@@ -250,13 +250,26 @@ provided automatically; everything else you set yourself.)
| `SONATYPE_PASSWORD` | Maven Central user token password | Same |
| `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) |
| `SIGNING_PASSWORD` | Passphrase for that GPG key | Same |
| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** |
| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain |
| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with |
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **two distinct signing identities** people often conflate:
Note the **three distinct signing identities** people often conflate:
`SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` +
`SIGNING_PASSWORD` is the **GPG key** for Maven Central. They are unrelated.
`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` +
`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS
desktop DMG. They are unrelated — each comes from a different authority.
The macOS desktop signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is
unset the release workflow still builds the DMG, just **unsigned** (the previous
behavior). Provision all six to switch signing + notarization on. Obtaining them
requires Apple Developer Program membership ($99/yr).
Generating the values:
@@ -269,6 +282,14 @@ base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY
# GPG key → armored private key for SIGNING_PRIVATE_KEY
gpg --full-generate-key # create the key (once)
gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY
# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12
security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.
```
`SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from
@@ -480,9 +501,17 @@ for the deprecation date. When it hits:
Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask`
on 2026-09-01. Before that date:
**Option A**: Commit budget to Apple Developer Program ($99/yr), add
`signing { sign.set(true) }` + `notarization {}` blocks to
`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI.
**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }`
+ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on
the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of
`create-release.yml` imports a Developer ID cert into a throwaway keychain and
exports the signing/notary env. It all stays a **no-op until the six
`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI
needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it
on: join the Apple Developer Program ($99/yr), create a *Developer ID
Application* certificate, generate an app-specific password, and set the six
secrets. The first signed+notarized DMG is best validated with a
`workflow_dispatch` dry-run before a real tag.
**Option B**: Pivot to a private Homebrew tap:
+30
View File
@@ -135,6 +135,36 @@ compose.desktop {
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
// --- Developer ID code signing + notarization ---
// Required for Homebrew's main cask (unsigned casks rejected after
// 2026-09-01) and to clear macOS Gatekeeper without the right-click
// dance. Gated on the signing-identity env var so local dev builds
// and PR CI keep producing plain UNSIGNED DMGs exactly as before —
// signing only kicks in when the release workflow exports these
// (which it does only when the Apple secrets are present):
//
// AMETHYST_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)"
// AMETHYST_NOTARY_APPLE_ID Apple ID email of the notary account
// AMETHYST_NOTARY_PASSWORD app-specific password for that Apple ID
// AMETHYST_NOTARY_TEAM_ID 10-char Apple Developer Team ID
//
// The Developer ID Application certificate must already be in the
// build host's keychain (CI imports it from a base64 .p12 secret).
// Compose ships default hardened-runtime entitlements that permit
// the JVM's JIT, so no custom entitlements file is needed.
val macSignIdentity = System.getenv("AMETHYST_MAC_SIGN_IDENTITY")
if (!macSignIdentity.isNullOrBlank()) {
signing {
sign.set(true)
identity.set(macSignIdentity)
}
notarization {
appleID.set(System.getenv("AMETHYST_NOTARY_APPLE_ID"))
password.set(System.getenv("AMETHYST_NOTARY_PASSWORD"))
teamID.set(System.getenv("AMETHYST_NOTARY_TEAM_ID"))
}
}
}
windows {