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:
| Target | Must build on | Signing |
|---|---|---|
macOS (.dmg, .app) | macOS runner | codesign + notarization (Apple) |
Windows (.exe, .nsis) | Windows runner | Authenticode (signtool) |
Linux (AppImage, .deb, .rpm) | Linux runner | optional (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-builderSigning 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_ISSUERWindows — 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 entirelyPublishing 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 ReleasesConfigure the provider in electron-builder.yml:
# electron-builder.yml
publish:
provider: github # or s3, or generic (your own server)
owner: acme
repo: myappCommon providers:
| Provider | Use when | Metadata |
|---|---|---|
github | open releases via GitHub Releases | uploaded as release assets |
s3 | private hosting on AWS | objects + latest*.yml in the bucket |
generic | your own static host / CDN | files 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 verificationVersioning 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:e2eelectron-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 (
filesglobs), useasarUnpackonly 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.