Steven's Knowledge

CI/CD

Building, signing, and releasing Electron across macOS, Windows, and Linux — the multi-OS GitHub Actions matrix, caching, code signing secrets, and auto-update publishing

CI/CD

Shipping an Electron app means producing signed installers for three operating systems, and the hard constraint is that each OS's installer must be built on that OS. Everything in your pipeline follows from that: a build matrix across native runners, signing secrets injected per platform, and a publish step that emits auto-update metadata.

Why a Multi-OS Matrix Is Required

You cannot reliably cross-build and sign from one OS to another, because signing and packaging use native, OS-specific tooling:

TargetMust build onSigning
macOS (.dmg, .app)macOS runnercodesign + notarization (Apple)
Windows (.exe, .nsis)Windows runnerAuthenticode (signtool)
Linux (AppImage, .deb, .rpm)Linux runneroptional (GPG for repos)

You cannot cross-build and sign macOS or Windows from Linux reliably. macOS notarization requires Apple's notarytool on macOS, and Authenticode signing needs Windows signtool (or a Windows-hosted cloud signer). Use native runners for each OS — a Linux-only pipeline can produce unsigned binaries at best, which will be blocked by Gatekeeper and SmartScreen.

Matrix skeleton

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}

fail-fast: false keeps the other OS builds running when one fails, so you see all three results.

Caching

Electron CI is download-heavy: dependencies, the Electron binary (~100 MB+), and electron-builder's own tooling. Cache all three to cut build times dramatically.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'pnpm' # caches the pnpm store automatically

- name: Cache Electron + builder downloads
  uses: actions/cache@v4
  with:
    path: |
      ~/.cache/electron
      ~/.cache/electron-builder
    key: ${{ runner.os }}-electron-${{ hashFiles('**/pnpm-lock.yaml') }}

Set ELECTRON_CACHE (and ELECTRON_BUILDER_CACHE on Windows where paths differ) so the cached directory is actually used:

env:
  ELECTRON_CACHE: ~/.cache/electron
  ELECTRON_BUILDER_CACHE: ~/.cache/electron-builder

Signing in CI

Never commit certificates. Inject them as base64-encoded GitHub secrets and let electron-builder pick them up from environment variables.

macOS — notarization

electron-builder reads the certificate from CSC_LINK (base64 .p12) and CSC_KEY_PASSWORD, and notarizes using either an App Store Connect API key or an Apple ID app-specific password.

env:
  CSC_LINK: ${{ secrets.MAC_CSC_LINK }}            # base64 of the .p12
  CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
  APPLE_ID: ${{ secrets.APPLE_ID }}
  APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
  APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
  # or, preferred: API key
  # APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER

Windows — Authenticode

For a standard certificate, supply CSC_LINK / CSC_KEY_PASSWORD (a .pfx). For an EV certificate, the private key lives on hardware — you cannot export it to a .pfx. Use a cloud HSM / dedicated signing service (Azure Trusted Signing, DigiCert KeyLocker, SSL.com eSigner) invoked from the Windows runner.

env:
  CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
  CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}

EV Windows signing needs a cloud HSM or hardware token. EV certificate keys are non-exportable by policy, so a base64 .pfx secret will not work. Configure electron-builder's signtoolOptions / a custom sign hook to call your cloud signer's CLI.

To keep PR CI fast, only run signing on release branches and tags — PRs just compile and test:

- name: Build (unsigned on PRs)
  if: github.event_name == 'pull_request'
  run: pnpm build && pnpm electron-builder --publish never
  env:
    CSC_IDENTITY_AUTO_DISCOVERY: false # skip signing entirely

Publishing and Auto-Update

electron-builder's --publish uploads artifacts to a provider and, crucially, generates the metadata files (latest.yml, latest-mac.yml, latest-linux.yml) that electron-updater reads to detect new versions.

- run: pnpm electron-builder --publish always
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for GitHub Releases

Configure the provider in electron-builder.yml:

# electron-builder.yml
publish:
  provider: github       # or s3, or generic (your own server)
  owner: acme
  repo: myapp

Common providers:

ProviderUse whenMetadata
githubopen releases via GitHub Releasesuploaded as release assets
s3private hosting on AWSobjects + latest*.yml in the bucket
genericyour own static host / CDNfiles served from a base URL

A safe release flow is draft then promote: publish as a draft release, verify the artifacts and update metadata, then mark the release as published so clients begin updating.

publish:
  provider: github
  releaseType: draft     # promote manually after verification

Versioning and Release Triggers

Use semver and trigger the release workflow on a tag push so releases are deliberate and traceable.

on:
  push:
    tags: ['v*.*.*']

Channels let you ship pre-releases without affecting stable users — electron-updater honors the channel in the version (1.4.0-beta.2) and the matching beta.yml metadata. Tag betas as pre-releases and keep stable users on the latest channel.

Linux Runners

Linux needs a virtual display for any test step (Electron requires a display server). Build steps that only package do not need it, but E2E/launch tests do.

- name: E2E (Linux)
  if: runner.os == 'Linux'
  run: xvfb-run --auto-servernum pnpm test:e2e

electron-builder produces AppImage, .deb, and .rpm from the Linux runner:

# electron-builder.yml
linux:
  target: [AppImage, deb, rpm]

Artifact Size and Build Time

  • Parallelize the matrix — the three OS jobs run concurrently, so total wall time is the slowest single OS, not the sum.
  • Cache aggressively (above) — uncached Electron downloads dominate cold builds.
  • Sign only on release — skip notarization and Authenticode on PRs; they are slow (notarization round-trips to Apple) and need secrets you should not expose to fork PRs.
  • Trim the bundle — exclude dev dependencies and source maps from the asar (files globs), use asarUnpack only for what must be on disk, and prune locales you do not ship.
  • Limit upload artifacts — keep installers, drop intermediate build output.

A Representative release.yml

name: Release
on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: write # to create GitHub Releases

jobs:
  release:
    strategy:
      fail-fast: false
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    env:
      ELECTRON_CACHE: ~/.cache/electron
      ELECTRON_BUILDER_CACHE: ~/.cache/electron-builder
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with: { version: 9 }

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Cache Electron downloads
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/electron
            ~/.cache/electron-builder
          key: ${{ runner.os }}-electron-${{ hashFiles('**/pnpm-lock.yaml') }}

      - run: pnpm install --frozen-lockfile

      - name: Test (headless on Linux)
        run: |
          if [ "${{ runner.os }}" = "Linux" ]; then
            xvfb-run --auto-servernum pnpm test
          else
            pnpm test
          fi
        shell: bash

      - name: Build and publish
        run: pnpm electron-builder --publish always
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # macOS signing + notarization
          CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          # Windows signing (standard cert; EV uses a cloud signer hook)
          WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
          WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}

This single workflow builds, signs, and publishes all three platforms on a tag push, with each OS handled by its own native runner and its own secrets.

On this page