A complete, copy-pasteable guide to getting a macOS Electron app to the point where:
- It launches with no Gatekeeper warning (signed + notarized).
- It updates itself silently in the background (Squirrel.Mac), then "Restart to update".
- The whole release is built and published by CI from a tag/dispatch.
This is the setup I wish I'd had in one place. It assumes Electron Forge v7 (@electron-forge/*) and GitHub Actions, but the Apple bits apply to any macOS distribution-outside-the-App-Store flow.
Three independent things, often conflated:
- Code signing — proves the app hasn't been tampered with, with your identity. Uses a Developer ID Application certificate.
- Notarization — you upload the signed app to Apple; they scan it and issue a "ticket". You staple the ticket into the app. This is what removes the "Apple cannot check it for malware" dialog.
- Auto-update — Squirrel.Mac (built into Electron's
autoUpdater) periodically asks a feed "is there a newer build?", downloads the.zip, and swaps it in on relaunch.
Squirrel.Mac will only silently apply an update if the new build's code signature is consistent with the installed one. So your signing identity must be stable forever — pick it once and never change it.
- Apple Developer Program membership (paid, $99/yr). A free Apple account cannot issue a Developer ID certificate. Organization enrollment additionally needs a D-U-N-S number and can take a few days to be approved — start early.
- A Mac with Xcode (or at least the command line tools) for creating the certificate and local testing.
Easiest path — Xcode:
Xcode → Settings → Accounts → (select your team) → Manage Certificates → + → Developer ID Application
Pick Developer ID Application specifically. Not "Apple Development" (local debug only), not "Apple Distribution" (Mac App Store), not "Developer ID Installer" (that's for .pkg). Xcode generates the keypair locally and drops the cert in your login keychain.
No Xcode? Make a CSR in Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority ("Saved to disk"), upload it at developer.apple.com → Certificates, download the .cer, double-click to import.
Your CI runner needs the cert + private key as a portable file:
Keychain Access → My Certificates → expand
Developer ID Application: …, confirm a private key is nested under it → right-click → Export →.p12
Set an export password — you'll store it as a CI secret. Then base64-encode it (GitHub secrets are text):
base64 -i certificate.p12 | pbcopy # now paste into the CI secretsecurity find-identity -v -p codesigning
# 1) ABCD... "Developer ID Application: Your Name (TEAMID0000)"Copy the full quoted string — Developer ID Application: Your Name (TEAMID0000). That TEAMID0000 is your Team ID.
The modern notarization auth is an API key, not an Apple ID + app-specific password.
App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys → Generate, role Developer.
- Download the
.p8immediately — Apple lets you download it exactly once. - Note the Key ID (10 chars).
- Note the Issuer ID (a UUID at the top of the page — it's not in the
.p8, and it's the one people most often grab from the wrong place).
Keep the .p8 outside your repo (e.g. ~/.secrets/, chmod 600).
@electron-forge/* reads osxSign / osxNotarize from packagerConfig. Gate them on env vars so an unsigned build still works locally and in PR CI — they "light up" automatically once the secrets exist.
// forge.config.ts
import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDMG } from "@electron-forge/maker-dmg";
import path from "node:path";
const appleId = process.env.APPLE_SIGNING_IDENTITY;
const osxSign = appleId
? {
identity: appleId,
optionsForFile: () => ({
hardenedRuntime: true, // REQUIRED for notarization
entitlements: path.join(__dirname, "build", "entitlements.mac.plist"),
"entitlements-inherit": path.join(__dirname, "build", "entitlements.mac.plist"),
"gatekeeper-assess": false,
}),
}
: undefined;
const osxNotarize =
process.env.APPLE_API_KEY && process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER
? {
appleApiKey: process.env.APPLE_API_KEY, // PATH to the .p8 file
appleApiKeyId: process.env.APPLE_API_KEY_ID,
appleApiIssuer: process.env.APPLE_API_ISSUER,
}
: undefined;
const config: ForgeConfig = {
packagerConfig: {
// Pin this. Forever. Changing it strands every installed user's
// auto-update (a different bundle id == a different app to Squirrel).
appBundleId: "com.example.myapp",
osxSign,
osxNotarize,
},
// Squirrel.Mac consumes the ZIP for auto-update; the DMG is the
// human-download fallback.
makers: [new MakerZIP({}, ["darwin"]), new MakerDMG({}, ["darwin"])],
publishers: [
{
name: "@electron-forge/publisher-github",
config: {
repository: { owner: "your-org", name: "your-app-releases" },
authToken: process.env.GITHUB_TOKEN,
draft: false, // a real release so the feed picks it up
prerelease: false,
},
},
],
};
export default config;build/entitlements.mac.plist — the standard hardened-runtime set for Electron:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
</dict>
</plist>Why
appleApiKeyis a path:@electron/notarize(used under the hood) hands the.p8file path tonotarytool. In CI you'll write the secret out to a file and point this env var at it (Step 7).
Electron's built-in autoUpdater speaks Squirrel.Mac. The simplest robust feed is the free update.electronjs.org service, which reads a public GitHub repo's releases.
// updater.ts
import { app, autoUpdater } from "electron";
export function startUpdater() {
if (!app.isPackaged) return; // no-op in dev
const feed = `https://update.electronjs.org/your-org/your-app-releases/${process.platform}-${process.arch}/${app.getVersion()}`;
autoUpdater.setFeedURL({ url: feed });
autoUpdater.on("update-downloaded", () => {
// Prompt the user, then:
autoUpdater.quitAndInstall();
});
autoUpdater.on("error", (e) => console.error("update error", e));
autoUpdater.checkForUpdates();
setInterval(() => autoUpdater.checkForUpdates(), 6 * 60 * 60 * 1000);
}Two non-obvious requirements:
update.electronjs.orgonly serves public repos. If your source is private, create a separate public "releases" repo and publish builds there (that's theyour-app-releasesin the snippets). Your source stays private; only the artifacts are public.- Cross-repo publishing needs its own token. The Actions-provided
GITHUB_TOKENis scoped to the repo the workflow runs in and cannot write to a different repo. Use a fine-grained PAT (or, better, an org-owned GitHub App token viaactions/create-github-app-token) with Contents: write on the releases repo.
This is the part with the most sharp edges. Full working workflow:
# .github/workflows/release.yml
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (blank = use package.json)"
required: false
default: ""
permissions:
contents: write
jobs:
release:
runs-on: macos-latest
timeout-minutes: 30
# GOTCHA: you CANNOT reference `secrets.*` in a step-level `if:`.
# The workflow won't even parse and `workflow_dispatch` returns HTTP 422.
# Surface presence as job-level env booleans — `env` IS allowed in `if:`.
env:
HAS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' }}
HAS_NOTARIZE: ${{ secrets.APPLE_API_KEY_P8 != '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Import signing certificate into a temporary keychain
if: ${{ env.HAS_SIGNING == 'true' }}
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain"
KCPASS="$(uuidgen)"
CERT="$RUNNER_TEMP/cert.p12"
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERT"
security create-keychain -p "$KCPASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KCPASS" "$KEYCHAIN"
security import "$CERT" -P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" \
$(security list-keychains -d user | xargs)
# Without this, codesign blocks on a GUI "allow access?" prompt
# and the runner hangs until timeout.
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KCPASS" "$KEYCHAIN" >/dev/null
- name: Stage App Store Connect API key
if: ${{ env.HAS_NOTARIZE == 'true' }}
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: |
DIR="$RUNNER_TEMP/keys"; mkdir -p "$DIR"
echo "$APPLE_API_KEY_P8" > "$DIR/AuthKey_${APPLE_API_KEY_ID}.p8"
echo "APPLE_API_KEY=$DIR/AuthKey_${APPLE_API_KEY_ID}.p8" >> "$GITHUB_ENV"
- name: Build & publish
env:
GITHUB_TOKEN: ${{ secrets.RELEASES_REPO_TOKEN }} # cross-repo PAT/App token
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# APPLE_API_KEY (the .p8 path) was exported to $GITHUB_ENV above
run: npx electron-forge publish| Secret | What it is |
|---|---|
APPLE_CERTIFICATE_P12 |
base64 -i certificate.p12 output |
APPLE_CERTIFICATE_PASSWORD |
the .p12 export password |
APPLE_SIGNING_IDENTITY |
Developer ID Application: Your Name (TEAMID0000) |
APPLE_API_KEY_P8 |
full contents of the .p8 file |
APPLE_API_KEY_ID |
10-char Key ID |
APPLE_API_ISSUER |
Issuer UUID |
RELEASES_REPO_TOKEN |
cross-repo token with Contents:write on the public releases repo |
Set them all together. The cert-import step is gated on
APPLE_CERTIFICATE_P12being present, so a partial secret set arms CI to run and fail confusingly.
Don't trust a green build — verify the artifact. Download the published .zip, unzip, and:
APP="My App.app"
# Signature valid + intact?
codesign --verify --deep --strict --verbose=2 "$APP"
# -> "valid on disk" + "satisfies its Designated Requirement"
# Right identity / full chain?
codesign -dvvv "$APP" 2>&1 | grep -E '^Authority|^TeamIdentifier|^Identifier'
# -> Developer ID Application -> Developer ID CA -> Apple Root CA
# THE decisive check — is it notarized?
spctl -a -vvv -t install "$APP"
# WANT: "accepted" + "source=Notarized Developer ID"
# (Before notarization you'd see: "source=Unnotarized Developer ID")
# Is the notarization ticket stapled (works offline)?
xcrun stapler validate "$APP"
# -> "The validate action worked!"And verify the feed the way the running app will hit it — query as an old version vs the current one:
BASE=https://update.electronjs.org/your-org/your-app-releases/darwin-arm64
curl -s -w "\n%{http_code}\n" "$BASE/0.0.0" # old client
# -> 200 + {"url": ".../YourApp-darwin-arm64-1.2.3.zip", "name": "v1.2.3"}
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/1.2.3" # current client
# -> 204 (no update — correct)If the old version gets 200 + the right zip and the current one gets 204, Squirrel.Mac will do the right thing.
secretsis not allowed in a stepif:. The workflow won't parse;workflow_dispatch422s. Map secrets to job-levelenvbooleans and test those.security set-key-partition-listis mandatory in CI. Skip it andcodesignsilently waits on a GUI prompt no one can click → the job hangs to timeout.- Hardened runtime is required for notarization. No
hardenedRuntime: true→ notarization rejects the upload. - Unsigned → signed is a one-time manual hop. If you previously shipped unsigned builds, Squirrel.Mac won't silently cross the signature boundary into your first signed build. Those users download it once by hand; every signed→signed update after is silent. Build a manual "Update available" fallback for that first hop.
- Pin
appBundleIdand never change the signing identity. Both are part of Squirrel's "is this the same app?" check. Change either and installed users are stranded on their current version forever. - Notarization is asynchronous (typically 1–5 min, sometimes longer).
notarytoolwaits for you; just don't set an aggressive job timeout. update.electronjs.orgis public-repo only. Private source → publish artifacts to a separate public releases repo, and remember the ActionsGITHUB_TOKENcan't write cross-repo (you need a PAT or GitHub App token).- The Issuer ID is not in the
.p8. It's a per-account UUID on the App Store Connect Integrations page. Don't grab the value from the Users tab.
That's the whole pipeline. Once the secrets are in place, every release is one workflow_dispatch away, and your users just quietly stay on the latest version.