Setting Up Automatic CI/CD for Desktop App Build
Desktop app on Electron, Tauri or Qt — not a web service: each release must build for Windows (x64, arm64), macOS (Intel, Apple Silicon), Linux (deb, rpm, AppImage). Doing manually means eight-ten builds by hand, potential signing errors and tedious publishing. CI/CD removes mechanical work.
Pipeline Architecture
Typical cross-platform desktop app pipeline:
push to main / tag v*
│
├── lint & unit tests (ubuntu-latest, fast)
│
├── build:windows (windows-latest)
├── build:macos (macos-latest, Intel + arm64)
└── build:linux (ubuntu-latest)
│
└── sign & notarize (macOS)
│
└── draft GitHub Release / upload to S3
│
└── notify (Slack/Telegram)
GitHub Actions: Matrix Build
# .github/workflows/build.yml
name: Build Desktop App
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
platform: win
arch: x64
- os: macos-latest
platform: mac
arch: x64
- os: macos-latest
platform: mac
arch: arm64
- os: ubuntu-22.04
platform: linux
arch: x64
runs-on: ${{ matrix.os }}
name: Build ${{ matrix.platform }}-${{ matrix.arch }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build & package (Electron)
env:
CSC_LINK: ${{ secrets.APPLE_CERT_P12 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WIN_CERT_P12 }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CERT_PASSWORD }}
run: |
npx electron-builder \
--${{ matrix.platform }} \
--${{ matrix.arch }} \
--publish never
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.platform }}-${{ matrix.arch }}
path: dist/
retention-days: 7
Code Signing
Without signing, Windows shows SmartScreen "File downloaded from internet", macOS blocks Gatekeeper.
Windows: Code Signing certificate (EV or OV) from DigiCert/Sectigo/Certum. EV gives instant SmartScreen reputation.
macOS: Developer ID Application certificate from Apple Developer Program ($99/year) + notarization.
Secrets stored in GitHub Secrets, never in repo:
# Add certificate to GitHub Secrets
base64 -i certificate.p12 | pbcopy
# Paste into APPLE_CERT_P12 via GitHub UI
macOS notarization takes 1–5 min — explains extra wait-for-notarization step.
Tauri: Electron Alternative
For Tauri (Rust backend), build via official tauri-action:
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'App v__VERSION__'
releaseBody: 'See CHANGELOG for details'
releaseDraft: true
Tauri has built-in update (updater) — signed update.json and binaries auto-published in release.
Auto-Update
Electron + electron-updater (part of electron-builder):
// main.js
const { autoUpdater } = require('electron-updater');
autoUpdater.setFeedURL({
provider: 'github',
owner: 'your-org',
repo: 'your-app',
private: false
});
app.whenReady().then(() => {
autoUpdater.checkForUpdatesAndNotify();
});
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});
electron-builder publishes latest.yml / latest-mac.yml / latest-linux.yml in GitHub Releases. App checks these on startup.
Versioning
Version from package.json. On tag v1.2.3, CI auto-sets version:
# In workflow — before build
- name: Set version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
npm version $VERSION --no-git-tag-version
Dependency Caching
Electron project node_modules weighs 500 MB+. Cache via actions/setup-node with cache: 'npm' cuts install from 3–4 min to 30–60 sec on cache hit.
For Rust/Tauri — cache Cargo:
- uses: Swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
Release Publishing
publish:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist-all/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: |
dist-all/**/*.exe
dist-all/**/*.dmg
dist-all/**/*.AppImage
dist-all/**/*.deb
dist-all/**/*.rpm
Draft release allows checking artifacts before public publishing.
Build Times
| Platform | No Cache | With Cache |
|---|---|---|
| Windows x64 | 8–12 min | 4–6 min |
| macOS x64 + notarize | 10–15 min | 6–8 min |
| macOS arm64 | 10–14 min | 6–7 min |
| Linux x64 | 5–8 min | 2–4 min |
Full pipeline (parallel) takes 12–18 minutes. Tauri slightly longer due to Rust compilation.
Setup Timeline
Basic pipeline without signing — 1 day. Windows signing + macOS notarization + auto-updater — another 1–2 days (most time getting and configuring certificates).







