Skip to contents

A desktop installer has to be built on the OS it targets: a .dmg on macOS, an .exe on Windows, and an .AppImage on Linux. That is four builds for full coverage, and most teams do not have all four machines on a desk. GitHub Actions rents them by the minute, runs them in parallel, and hands back the installers as artifacts. One push, four builds, no hardware juggling.

A git push node on the left fans out to four runner rows (macos-latest, macos-15-intel, windows-latest, ubuntu-latest), each producing a platform-specific installer, which fan back in to a Release job on the right that runs only on tag pushes.
The build matrix: one push fans out across platform runners, each producing an installer; a tag push adds a release job that attaches them all.

Why automate

Doing this by hand is slow and hard to reproduce. CI fixes four specific things at once:

Problem What CI gives you
You need macOS, Windows, and Linux hardware Hosted runners for each
Local builds drift with your laptop’s state Fresh, versioned environments every run
Uploading binaries to a Release page by hand Artifacts and releases produced by a workflow step
Platform-specific regressions slip through The matrix runs in parallel and surfaces them on every push

Before you start

You need:

  1. A GitHub repo containing your Shiny app.
  2. The app in a subdirectory, app/ by default.
  3. Optionally, a _shinyelectron.yml alongside the app.

A typical layout:

my-shiny-project/
├── .github/
│   └── workflows/
│       └── build-electron.yml
├── app/
│   ├── app.R
│   └── ...
├── _shinyelectron.yml
└── README.md

Use the bundled workflow

shinyelectron ships a ready-to-run workflow at inst/templates/github-actions-build.yml. It leans on the coatless-actions/shiny-to-electron action, which sets up R and Node.js, installs shinyelectron, runs export(), and uploads the installer. That keeps the workflow itself short:

# GitHub Actions workflow for building shinyelectron apps
#
# Builds your Shiny app as an Electron desktop installer for macOS, Windows,
# and Linux with the coatless-actions/shiny-to-electron action, then attaches
# the installers to a GitHub Release on version tags.
#
# Usage:
# 1. Copy this file to .github/workflows/build-electron.yml in your repository.
# 2. Set `appdir` (path to your Shiny app) and `app-name` in the build step.
# 3. Push to build; push a tag like v1.0.0 to also cut a release.
#
# https://r-pkg.thecoatlessprofessor.com/shinyelectron/articles/github-actions.html

name: Build Electron App

on:
  push:
    branches: [main, master]
    tags: ['v*']
  pull_request:
    branches: [main, master]
  workflow_dispatch:

jobs:
  build:
    name: Build (${{ matrix.platform }}-${{ matrix.arch }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: macos-latest,   platform: mac,   arch: arm64 }
          - { os: macos-15-intel, platform: mac,   arch: x64 }
          - { os: windows-latest, platform: win,   arch: x64 }
          - { os: ubuntu-latest,  platform: linux, arch: x64 }
    steps:
      - uses: actions/checkout@v7

      - name: Build the Electron app
        uses: coatless-actions/shiny-to-electron@v1
        with:
          appdir: app                       # Directory containing your Shiny app
          app-name: MyApp                   # Display name of the application
          platform: ${{ matrix.platform }}
          arch: ${{ matrix.arch }}
          # runtime-strategy: shinylive     # shinylive | bundled | system | auto-download | container
          # sign: 'false'                   # 'true' with signing secrets to sign and notarize

  release:
    name: Create Release
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Download all build artifacts
        uses: actions/download-artifact@v8
        with:
          path: artifacts

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: artifacts/**/*
          generate_release_notes: true
          prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') }}

Copy it into your repo:

template <- system.file(
  "templates", "github-actions-build.yml",
  package = "shinyelectron"
)

dir.create(".github/workflows", recursive = TRUE, showWarnings = FALSE)
file.copy(
  template,
  ".github/workflows/build-electron.yml"
)

Or grab it directly from GitHub.

Two jobs run: a build matrix across four platform runners, and a release job gated on tag pushes.

Configure it

Set two things in the build step’s with: block: appdir (the path to your Shiny app inside the repo) and app-name (the installer’s display name). Everything else has a sensible default. Uncomment runtime-strategy to pick a strategy other than shinylive, and sign to sign builds (see Signing in CI).

What the matrix builds

The matrix spreads installers across four runners. Each runner starts from a clean image:

Runner Platform Architecture Output
macos-latest macOS arm64 (Apple Silicon) .dmg
macos-15-intel macOS x64 (Intel) .dmg
windows-latest Windows x64 .exe
ubuntu-latest Ubuntu x64 .AppImage

CPU and RAM allocations come from GitHub’s hosted-runner specs, which evolve over time; check the GitHub-hosted runners documentation for current numbers.

Each runner does the same two things: check out the repo, then run the action, which sets up R and Node.js, installs shinyelectron, runs export() for that platform, and uploads the installer as a run artifact. On a tag push, the release job downloads every artifact and attaches them to a fresh GitHub Release.

Push and tag

Commit and push to fire the workflow on main or master:

git add .github/workflows/build-electron.yml
git commit -m "Add Electron build workflow"
git push

Tag a version to cut a release:

git tag v1.0.0
git push origin v1.0.0

Tags containing -alpha or -beta are marked as pre-releases automatically.

Status badge

Drop a badge in your README so contributors see build state at a glance:

[![Release](https://github.com/YOUR-USERNAME/YOUR-REPO/actions/workflows/build-electron.yml/badge.svg)](https://github.com/YOUR-USERNAME/YOUR-REPO/actions/workflows/build-electron.yml)

Customising

The action exposes an input for most things a project changes. Set them in the build step’s with: block; anything not listed there falls back to _shinyelectron.yml or the defaults.

App in a different folder

Point appdir at your app:

      - uses: coatless-actions/shiny-to-electron@v1
        with:
          appdir: src/shiny-app
          app-name: MyApp

Narrower platform list

Trim the matrix to what you ship. Each entry corresponds to one runner; remove the rest:

    strategy:
      matrix:
        include:
          - { os: macos-latest,   platform: mac, arch: arm64 }
          - { os: windows-latest, platform: win, arch: x64 }

Runtime strategy

The default is shinylive. Choose another with the runtime-strategy input, or set it in _shinyelectron.yml:

      - uses: coatless-actions/shiny-to-electron@v1
        with:
          appdir: app
          app-name: MyApp
          runtime-strategy: bundled

Icons and other options

The action does not take an icon input. Put project settings like the icon in a _shinyelectron.yml next to your app; export() reads it automatically. See the Configuration Guide for every option.

app:
  name: "My Shiny Dashboard"
  version: "1.0.0"

build:
  runtime_strategy: "shinylive"

Config file wins, action inputs override

A _shinyelectron.yml in the app directory is picked up automatically. Action inputs override its values when they are set, so you can keep shared settings in the config and vary only the CI-specific ones in the workflow.

Signing in CI

Signing uses the same electron-builder credentials as a local build, stored as GitHub Secrets. Add each under Settings, Secrets and variables, Actions, then pass them to the build job’s env and flip sign on:

  build:
    runs-on: ${{ matrix.os }}
    env:
      CSC_LINK: ${{ secrets.CSC_LINK }}                                  # base64 .p12 signing certificate
      CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
      APPLE_ID: ${{ secrets.APPLE_ID }}
      APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
      APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
    steps:
      - uses: actions/checkout@v7
      - uses: coatless-actions/shiny-to-electron@v1
        with:
          appdir: app
          app-name: MyApp
          platform: ${{ matrix.platform }}
          arch: ${{ matrix.arch }}
          sign: 'true'

With sign: 'true' and those variables present, macOS builds are signed with your Developer ID and notarized, taking the team id from APPLE_TEAM_ID. Leave the credentials out and macOS still falls back to an ad-hoc signature, so the app launches through the standard unidentified-developer prompt rather than reading as damaged.

Warning

Certificates come from Apple (macOS) and a commercial CA (Windows). Unsigned apps trigger Gatekeeper and SmartScreen warnings on end-user machines. Storing a signing key in CI means it is decrypted into the runner during the build, so weigh that against how the apps are distributed. See Code Signing and Distribution for the full setup.

Roll your own

If you need full control, custom steps, bespoke signing, or extra tooling, skip the action and drive shinyelectron::export() yourself. The action is a thin wrapper around exactly this recipe:

jobs:
  build:
    name: Build (${{ matrix.platform }}-${{ matrix.arch }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: macos-latest,   platform: mac,   arch: arm64 }
          - { os: macos-15-intel, platform: mac,   arch: x64 }
          - { os: windows-latest, platform: win,   arch: x64 }
          - { os: ubuntu-latest,  platform: linux, arch: x64 }
    steps:
      - uses: actions/checkout@v7

      - uses: r-lib/actions/setup-r@v2
        with:
          r-version: release
          use-public-rspm: true

      - uses: actions/setup-node@v6
        with:
          node-version: '22'

      - name: Install system dependencies (Linux)
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev

      - uses: r-lib/actions/setup-r-dependencies@v2
        with:
          extra-packages: |
            github::coatless-rpkg/shinyelectron
            any::shinylive
          needs: build

      - name: Install the app's R dependencies
        shell: Rscript {0}
        run: |
          # shinylive compiles the WebAssembly bundle from installed packages,
          # so the packages your app uses must be present before building.
          pkgs <- shinyelectron::app_dependencies("app", app_type = "r-shiny")
          pkgs <- setdiff(pkgs, rownames(installed.packages()))
          if (length(pkgs)) install.packages(pkgs)

      - name: Build the Electron app
        shell: Rscript {0}
        run: |
          library(shinyelectron)
          export(
            appdir  = "app",
            destdir = "build",
            app_name = "MyApp",
            platform = "${{ matrix.platform }}",
            arch     = "${{ matrix.arch }}",
            overwrite = TRUE,
            verbose   = TRUE
          )

      - uses: actions/upload-artifact@v7
        with:
          name: MyApp-${{ matrix.platform }}-${{ matrix.arch }}
          path: build/electron-app/dist/**

Drive signing from the same export(sign = TRUE) call with the CSC_* and APPLE_* variables in the step’s env, exactly as above. This is the path to reach for when you want to split building from signing, add caching, or run steps the action does not expose.

CI-specific troubleshooting

The general guide in Troubleshooting covers symptoms that show up on any machine. The items below are CI-only or turn up much more often on hosted runners than on a developer laptop.

appdir points at the wrong directory

A build fails with App directory 'app' not found when your Shiny code lives somewhere other than app/. Set the appdir input to the actual path.

Linux build fails on missing libraries

Hosted Ubuntu runners are minimal. If your R or Python dependencies need system packages that the build does not install, add them in your own workflow (the roll-your-own recipe above) before the build step:

- name: Install system dependencies (Linux)
  if: runner.os == 'Linux'
  run: |
    sudo apt-get update
    sudo apt-get install -y libcurl4-openssl-dev libxml2-dev

shinylive build stops with “there is no package called …”

shinylive::export() compiles the WebAssembly bundle from the packages installed on the runner, so a shinylive R app whose packages are not installed stops with, for example, there is no package called 'bsicons'. The action installs an app’s detected packages for you. In a hand-rolled workflow, add the step from the roll-your-own recipe above (shinyelectron::app_dependencies() then install.packages()). If detection misses a package your code loads dynamically, add it to that install.packages() call yourself.

Pin the shinyelectron version

By default the action installs shinyelectron from GitHub. Pin a tag or branch with the shinyelectron-source input so a build is reproducible:

      - uses: coatless-actions/shiny-to-electron@v1
        with:
          appdir: app
          shinyelectron-source: github::coatless-rpkg/shinyelectron@v0.2.0

Job hits the six-hour limit

GitHub-hosted runners cap individual jobs at six hours. If a build comes close, shrink the matrix or split the build into separate workflows that run in parallel.

Next steps