Cross-Platform Desktop App CI with GitHub Actions
Desktop app CI is harder than web app CI. You need real operating systems — not just Linux containers — because your app runs on Windows, macOS, and Linux with platform-specific behavior at every layer: native menus, file dialogs, WebView implementations, code signing, and installer formats. GitHub Actions provides hosted runners for all three platforms, making it possible to run a full cross-platform test and release pipeline without managing build machines.
This guide covers the complete CI setup for both Electron and Tauri applications: unit tests, E2E tests with display server configuration, build artifact generation, code signing, and release automation.
The CI Strategy
A solid desktop CI pipeline has three stages:
- Fast tests (unit + lint): Run on every push, all platforms in parallel, fails fast
- E2E tests: Run on PRs and main branch pushes, requires display server setup on Linux
- Build + sign + release: Run on version tags, produces installable artifacts
Each stage should be a separate workflow or job group so failures are precisely located.
Basic Workflow Structure
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
name: Unit Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20.x'
with:
token: ${{ secrets.CODECOV_TOKEN }}The fail-fast: false setting is important for cross-platform matrices — you want to see failures on all platforms, not just the first one. A bug that only manifests on Windows will be invisible if fail-fast: true stops the matrix when Ubuntu fails first.
Linux Display Server Configuration
Linux CI runners don't have a display server. Electron and WebDriver-based Tauri tests need one. Use xvfb-run:
e2e-tests:
name: E2E Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install Linux display dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
xvfb \
libgtk-3-0 \
libnotify-dev \
libnss3 \
libxss1 \
libasound2 \
libxtst6 \
xauth \
libgbm-dev
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" npm run test:e2e
- name: Run E2E tests (macOS/Windows)
if: runner.os != 'Linux'
run: npm run test:e2e
- name: Upload test artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-artifacts-${{ matrix.os }}
path: |
test-results/
playwright-report/
retention-days: 7The xvfb-run --auto-servernum flag automatically picks an available display number, preventing conflicts if multiple jobs run on the same runner. The 1920x1080x24 resolution matches what most tests expect.
Tauri-Specific CI Setup
Tauri adds Rust to the build chain, which requires additional setup:
tauri-e2e-tests:
name: Tauri E2E (${{ matrix.platform }})
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-latest
rust-target: x86_64-unknown-linux-gnu
- platform: windows-latest
rust-target: x86_64-pc-windows-msvc
- platform: macos-latest
rust-target: aarch64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
webkit2gtk-driver \
xvfb
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Install tauri-driver
run: cargo install tauri-driver
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install Node dependencies
run: npm ci
- name: Build Tauri app (debug)
run: npm run tauri build -- --debug
- name: Run WebDriver tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum npm run test:e2e
- name: Run WebDriver tests (macOS/Windows)
if: runner.os != 'Linux'
run: npm run test:e2eThe Rust compilation step is the biggest CI bottleneck for Tauri apps. swatinem/rust-cache caches the compiled dependencies in the target/ directory, reducing subsequent builds from 5-10 minutes to under a minute.
Caching Strategies
Aggressive caching is essential for desktop app CI performance:
steps:
- uses: actions/checkout@v4
# Node modules cache
- name: Cache Node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Electron binary cache
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/AppData/Local/electron/Cache
~/Library/Caches/electron
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}
# Playwright browsers cache
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromiumThe Electron binary cache is especially impactful — Electron is 100+ MB and downloading it on every run adds 30-60 seconds even on fast runners.
macOS Code Signing
macOS requires code signing to avoid Gatekeeper quarantine warnings in testing. For development builds, use ad-hoc signing:
- name: Sign macOS app (ad-hoc for CI)
if: runner.os == 'macOS'
run: codesign --sign - --force --deep dist/mac/YourApp.appFor production releases, you need a Developer ID certificate:
- name: Import macOS signing certificate
if: runner.os == 'macOS' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create a temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import the certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 \
-k build.keychain \
-P "$MACOS_CERTIFICATE_PWD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
- name: Build and sign (release)
if: startsWith(github.ref, 'refs/tags/')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run build:releaseStore the base64-encoded .p12 certificate in GitHub Secrets, never in the repository.
Windows Code Signing
Windows signing uses a certificate file and Authenticode:
- name: Sign Windows installer
if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/')
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
$cert = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
[IO.File]::WriteAllBytes("cert.p12", $cert)
& "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" sign `
/f cert.p12 `
/p $env:WINDOWS_CERTIFICATE_PASSWORD `
/tr http://timestamp.digicert.com `
/td SHA256 `
/fd SHA256 `
dist\your-app-setup.exeRelease Workflow
Separate the release workflow to trigger only on version tags:
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
build-release:
name: Build Release (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
artifact-name: linux-artifacts
build-cmd: npm run build:linux
- os: windows-latest
artifact-name: windows-artifacts
build-cmd: npm run build:windows
- os: macos-latest
artifact-name: macos-artifacts
build-cmd: npm run build:mac
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
# Platform-specific signing setup here...
- name: Build release artifacts
run: ${{ matrix.build-cmd }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: dist/
create-release:
name: Create GitHub Release
needs: build-release
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-artifacts/**/*
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') }}Test Parallelism and Sharding
For large E2E suites, use Playwright's built-in sharding to split tests across multiple runners:
e2e-sharded:
name: E2E Shard ${{ matrix.shard }}/${{ matrix.total-shards }}
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
total-shards: [4]
steps:
# ... setup steps ...
- name: Run E2E tests (shard)
run: |
xvfb-run --auto-servernum npx playwright test \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--reporter=blob
- name: Upload blob report
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
merge-reports:
name: Merge E2E Reports
needs: e2e-sharded
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports/
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Environment Variables and Secrets Management
Desktop app CI often needs environment-specific configuration:
- name: Configure test environment
run: |
cat > .env.test << EOF
NODE_ENV=test
ELECTRON_DISABLE_SECURITY_WARNINGS=true
ELECTRON_ENABLE_LOGGING=true
EOF
shell: bashFor sensitive values like API keys used in E2E tests, use GitHub Secrets and only expose them to the jobs that need them:
- name: Run integration tests
env:
TEST_API_KEY: ${{ secrets.TEST_API_KEY }}
TEST_SERVER_URL: ${{ secrets.TEST_SERVER_URL }}
run: npm run test:integrationMonitoring CI Health
Track CI metrics over time to catch degradation early. Key metrics for desktop CI:
- Unit test duration: Should be under 2 minutes. If it grows past that, add parallelism or investigate slow tests.
- E2E test duration: Acceptable range is 5-15 minutes. Beyond 15 minutes, implement sharding.
- Build cache hit rate: Check the Actions cache hit/miss ratio in the Actions tab. Below 80% indicates the cache key is too granular.
- Flaky test rate: Track which tests fail intermittently. Desktop E2E tests are prone to timing issues; add explicit waits rather than fighting the flakiness.
Conclusion
Cross-platform desktop CI is achievable on GitHub Actions with careful attention to display server setup, caching, and platform-specific signing. The workflow patterns here give you a foundation that runs fast on every push (unit tests), provides confidence on PRs (E2E across all platforms), and produces signed release artifacts on version tags. The investment in proper CI pays for itself quickly — desktop apps have more ways to break on specific platforms than web apps, and catching those regressions in CI before they reach users is the only reliable strategy.