Cross-Platform Desktop App CI with GitHub Actions

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:

  1. Fast tests (unit + lint): Run on every push, all platforms in parallel, fails fast
  2. E2E tests: Run on PRs and main branch pushes, requires display server setup on Linux
  3. 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: 7

The 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:e2e

The 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 chromium

The 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.app

For 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:release

Store 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.exe

Release 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: bash

For 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:integration

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

Start now free