Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save softmarshmallow/9749a661e3e81321d995e5af2a000c0c to your computer and use it in GitHub Desktop.

Select an option

Save softmarshmallow/9749a661e3e81321d995e5af2a000c0c to your computer and use it in GitHub Desktop.
Shipping a signed, notarized, auto-updating macOS app with Electron Forge + GitHub Actions

Shipping a signed, notarized, auto-updating macOS app with Electron Forge + GitHub Actions

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.


The mental model (read this first)

Three independent things, often conflated:

  1. Code signing — proves the app hasn't been tampered with, with your identity. Uses a Developer ID Application certificate.
  2. 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.
  3. 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.


0. Prerequisites

  • 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.

1. Create the Developer ID Application certificate

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.

2. Export the certificate as a .p12 (for CI)

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 secret

3. Get your exact signing identity string

security 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.

4. Create an App Store Connect API key (for notarization)

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 .p8 immediately — 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).


5. Electron Forge config

@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 appleApiKey is a path: @electron/notarize (used under the hood) hands the .p8 file path to notarytool. In CI you'll write the secret out to a file and point this env var at it (Step 7).

6. Wire the in-app updater

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.org only serves public repos. If your source is private, create a separate public "releases" repo and publish builds there (that's the your-app-releases in the snippets). Your source stays private; only the artifacts are public.
  • Cross-repo publishing needs its own token. The Actions-provided GITHUB_TOKEN is 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 via actions/create-github-app-token) with Contents: write on the releases repo.

7. The GitHub Actions release workflow

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

Secrets to set on the repo

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_P12 being present, so a partial secret set arms CI to run and fail confusingly.

8. Verify it actually worked

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.


Gotchas worth tattooing on your arm

  • secrets is not allowed in a step if:. The workflow won't parse; workflow_dispatch 422s. Map secrets to job-level env booleans and test those.
  • security set-key-partition-list is mandatory in CI. Skip it and codesign silently 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 appBundleId and 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). notarytool waits for you; just don't set an aggressive job timeout.
  • update.electronjs.org is public-repo only. Private source → publish artifacts to a separate public releases repo, and remember the Actions GITHUB_TOKEN can'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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment