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"
)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.
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:
- A GitHub repo containing your Shiny app.
- The app in a subdirectory,
app/by default. - Optionally, a
_shinyelectron.ymlalongside 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:
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 pushTag a version to cut a release:
git tag v1.0.0
git push origin v1.0.0Tags 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:
[](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: MyAppNarrower 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: bundledIcons 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-devshinylive 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.0Job 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
- Getting Started: local development workflow.
-
Configuration: customize with
_shinyelectron.yml. - Code Signing: sign and notarize for distribution.
- Troubleshooting: diagnose build issues.