How we deploy our Electron app at Stashpad
At Stashpad, we use Electron to build our desktop app. Electron works great out of the box for most things and does a wonderful job abstracting away platform-specific APIs. But, when we started using some native node dependencies in Stashpad our deployment process quickly grew more complex. When we began shipping the app we had a cumbersome manual process. We knew that was something that we had to change for both ourselves as developers, and for our users so that we could ship rapidly.
We learned a great deal about the differences in distributing Stashpad to different platforms. Regardless of the language or framework you use, whether your app is web-based or a desktop app, there are always bumps in the road to continuous deployment.
Our original deployment workflow
For the first versions of Stashpad, we packaged the app for macOS and Windows. We only had a version for Intel chipped Macs, that would run on M1s using Rosetta (a tool for cross-compatibility provided by Apple on all M1s). Because of our native node dependencies (primarily realm
), we needed to build on each platform separately to create packaged versions that would run on each platform.
To start, we would log in to each machine and run a build script to kick off the electron-builder
packaging process. In turn, the Typescript code would be transpiled and packed into an installer, the installer files would be signed, and then uploaded to a GitHub release with the new version number. While the build script was simple to use, that meant we needed to communicate back and forth (sometimes remotely) to coordinate who was running which build and when so we could put together a release.
Once all of the packaged versions had been produced, users could go to our GitHub release page and download the newest version to run on their desktop. At that time Stashpad didn't even have automatic updates yet!
Our M1 users (including several Stashpad team members) were reporting performance issues on M1 Macs, so we knew that was something else we had to fix. A first stab we took at this problem was to produce a universal package that would install the correct version for the type of Apple chip on the computer. We discovered right away that having the native node dependencies meant we'd need to package the app separately for M1s and Intel-based Macs. So far we weren't accounting for Linux either, bringing the total number of builds we need to run separately up to four. Another manual step in building the release was the last thing we needed, so we set out to create a better process for our team.
The ideal deployment process
There were a few key criteria that we were shooting for when setting up our automated deployment system. We wanted to be able to kick off a release using a single script or command. That command would package for all 4 different systems, create a GitHub release, and upload all the required assets to serve our users. It was also important to establish a release server that would be able to provide auto-updates for our users (more on the server later).
Another goal we had was to create a beta version that we could use internally. On each pull request, we'd generate a new release that we could use in our daily work to help catch bugs before they made it out into the world. Everyone on our team would be on this beta channel and testing the latest main
branch code through normal use of the app in their daily work.
The main choices for packaging an Electron app are electron-builder
and electron-forge
. We evaluated both as a team and decided to use electron-builder
since the documentation was more thorough, there was auto-update support for Linux (via .AppImage
) out of the box, and there seemed to be more hooks into the process which we'd need to customize the signing process for each platform.
electron-builder
configuration
Electron Builder (opens in a new tab) requires some basic configuration for each platform. To see a full example derived from our configuration, check out this gist (opens in a new tab). Here is a breakdown of the configuration which is included in our package.json
file under the section "build"
.
Basic configuration
The common configuration (opens in a new tab) specifies a productName
, and an appId
. The product name determines the name of the app when it is installed on a computer. It can be modified later if you wish to rebrand your app. The appId
cannot be changed later or auto-updates will not recognize the new version of the app, so choose wisely before distributing your app to users. The asar
property specifies that the app's source code should be packaged into an archive (opens in a new tab), which can speed up the app startup and obscure the source code. Documentation on Electron's website has been removed, but there is a web archived version (opens in a new tab) that explains a bit more. Some native node modules need to be unpacked (opens in a new tab) to work correctly, specified here by the asarUnpack
glob pattern. Finally, we list files
required by the packaged application.
"build": {
"productName": "Stashpad",
"appId": "com.stashpad.app",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json",
],
}
macOS configuration
Below the common settings, we specify some macOS-specific settings to make sure the package can execute on macOS and a custom .dmg
screen that the user will see when they install Stashpad. The type
specifies that the package is for distribution and not development. The hardenedRuntime
protects (opens in a new tab) from some common types of exploits, and is recommended for Electron apps. Standard entitlements (opens in a new tab) for Electron apps to run on macOS are also used. gatekeeperAssess
is a setting (opens in a new tab) for the electron-osx-sign
library which is used by electron-builder
under the hood. The dmg
settings specify the location and background of the dmg window that appears when a user goes to install your app.
"afterSign": "scripts/notarize.js",
"mac": {
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"background": "assets/background.png",
"contents": [
{
"x": 122,
"y": 236
},
{
"x": 428,
"y": 236,
"type": "link",
"path": "/Applications"
}
],
"window": {
"width": 540,
"height": 420
}
},
Windows configuration
For Windows, we create an NSIS installer which is an .exe
that runs and installs the application and associated directories. For code-signing, some extra settings were needed. To use an EV (Extended Validation) certificate, we created a custom signing script that uses SignTool.exe
provided by the Windows SDK to access the certificate on a USB token and sign the packaged application. The publisherName
and signingHashAlgorithms
were also necessary to use the EV certificate. More on this later.
"win": {
"target": {
"target": "nsis",
"arch": [
"x64",
"ia32"
]
},
"signingHashAlgorithms": [
"sha256"
],
"publisherName": [
"Caeli, Inc.",
"Caeli, Inc"
],
"sign": "./customSign.js",
},
Linux configuration
For the Stashpad Linux app, we take advantage of the .AppImage
format which is a self-contained bundle of the application that will run on most Linux distributions. There is no installation required, the file can be executed directly to launch the application. AppImage also supports automatic updates. Here we specify the artifactName
so that the packaged version name is stable and users can create a symlink to it without needing to worry about it changing later. By default, the version number is included so specifying the name explicitly creates a stable name that users can rely on.
"appImage": {
"artifactName": "MyApp.AppImage"
},
"linux": {
"target": [
"AppImage"
],
"category": "Development"
},
See the gist (opens in a new tab) for an example of some optional configuration settings like creating an app-specific protocol. We also include some other resources like images in our packaged application.
Setting up GitHub workflows
Now that we've created an Electron Builder configuration that can run on each of the platforms, we can create some GitHub Actions to package the app automatically for us. In short, we need to bump the app version, package the application on 4 different systems, and then upload the correct artifacts to a GitHub release for distribution. We are uploading the artifacts to a GitHub release for easy connection to an update server. Most Electron update servers support serving the app packages from GitHub Releases so it is a solid choice. Another viable option is to store the artifacts in S3 or another static file store that is compatible with your update server.
Bumping the version
The way we've set up our GitHub Actions workflow requires two steps. The first step is to have a version bump workflow that is responsible solely for bumping the version inside the package.json
file and creating a git tag. The version bump flow runs on any push to the main
branch. It uses this GitHub action (opens in a new tab) to increment the version number in the package.json
file.
# version-bump.yml
name: version-bump
on:
push:
branches:
- main
jobs:
version-bump:
if: "!startsWith(github.event.head_commit.message, 'CI: bumps')"
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
# With a personal access token so another workflow can be triggered
token: ${{ secrets.PERSONAL_TOKEN }}
ref: ${{ github.ref }}
- name: Automated Version Bump
id: version-bump
uses: 'phips28/gh-action-bump-version@master'
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
PACKAGEJSON_DIR: 'build/app'
with:
# These phrases are purposefully complex to avoid accidental triggers
major-wording: 'major-sp-protected'
minor-wording: 'minor-sp-protected'
patch-wording: 'patch-sp-protected'
rc-wording: ''
# Defaulting to bump prerelease version, default is patch
default: prerelease
preid: 'rc'
tag-prefix: 'v'
target-branch: 'main'
commit-message: 'CI: bumps version to {{version}}'
A few things to note in this workflow are the use of a personal access token so that one workflow can start another workflow. When using the ${{ secrets.GITHUB_TOKEN }}
, a second workflow triggered by this one would fail to start. The secondary build/app/package.json
is also specified so that the one containing the native modules is responsible for holding the app version number. Finally, you may notice that we have configured the version bump action so that it defaults to incrementing the rc
version and requires a special string to bump the patch version. We only include the [level]-sp-protected
string in commits made by our releasing script.
Packaging and drafting a GitHub release
In a second workflow, we handle building the release and pushing it into a GitHub release. We need to build packaged versions on 4 different OSes and then publish them to GitHub. An intermediate step here is to upload the artifacts we'll need to include in the release to the Artifacts system in GitHub (opens in a new tab) which is the recommended way to move files and metadata between jobs in GitHub workflows. Since the packages are coming from four different runners, we can use GitHub Artifacts to move them to the final job which drafts the GitHub release.
For brevity, file paths and standard steps like checking out the repo and installing node have been excluded and replaced with [...]
.
# release.yml
name: release
on:
push:
tags:
- v*
jobs:
build-release:
environment: publish
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-latest, usb-machine, arm64]
steps:
[...]
- name: Package Mac/Linux
if: matrix.os != 'usb-machine'
env:
# These values are used for code signing and notarizing
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASS: ${{ secrets.APPLE_ID_PASS }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
yarn package
- name: Package Windows
if: matrix.os == 'usb-machine'
env:
# Windows signing is handled by a custom signing script, so no
# need to set ENV variables for Windows
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: yarn package
- name: Upload Artifacts
uses: actions/upload-artifact@v3
with:
name: sp-${{ matrix.os }}
path: |
[...]
draft-release:
needs: build-release
environment: publish
runs-on: ubuntu-latest
steps:
[...]
- name: Download Artifacts
uses: actions/download-artifact@v3
with:
path: temp/
- name: Merge Mac YAML Files
run: node ./scripts/github_actions/merge_mac_yml.js
- name: Get Version Number
id: version-number
run: |
echo "${{ github.ref }}"
ver=$(echo ${{ github.ref }} | cut -f2 -dv)
echo "Parsed version $ver"
echo "::set-output name=version::$ver"
- name: Replace Spaces with Hyphens (Windows)
run: |
mv [...]
- name: Internal Release
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: v${{ steps.version-number.outputs.version }}
draft: false
tag: ${{ github.ref }}
repo: 'sp-desktop'
allowUpdates: true
artifacts: >
[...]
- name: Public Release
if: "!contains(steps.version-number.outputs.version, 'rc')"
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: v${{ steps.version-number.outputs.version }}
draft: true
tag: ${{ github.ref }}
repo: 'sp-desktop-release'
allowUpdates: true
artifacts: >
[...]
Notably in this workflow, the strategy.matrix.os
defines arm64
and usb-machine
which are two arbitrary labels we've created and applied to our different self-hosted runners. The arm64
label is used for our M1 development laptops, and the usb-machine
label is for the Windows machine with the EV certificate USB token plugged into it. We also set some environment variables used by electron-builder
for signing and notarizing the macOS app.
Once we move to the draft-release
job which depends on all 4 instances of the build-release
job finishing, all the artifacts are downloaded. A couple of steps massage the file names and contents to get them ready for publishing. We merge the two separately generated latest-mac.yml
files into a single one that the electron-updater
package will understand when communicating with the update server. We also replace spaces in the file names generated by Windows for easier reference in a Linux system. Finally, we create the release in the appropriate GitHub repo depending on which type of release we're making.
macOS builds for x64
and arm64
As previously mentioned, we are running two separate jobs to package the application for the different types of macOS architectures. While some of the native modules that we use offer prebuilt binaries we could bundle into the app, not all of them do. In this case, we found that it was simplest to build on both types of machines. GitHub Actions does not currently offer a hosted M1 runner, so we needed to set up a self-hosted one to pick up the job.
Setting up a self hosted GitHub actions runner
GitHub has excellent instructions (opens in a new tab) for setting up a self-hosted runner. It requires only a few steps to download and install on your machine. It worked perfectly out of the box on macOS. We set up several of our development machines under the same arm64
label so that someone's machine was always ready to pick up the job. The runners are set up as a service on the machine so they are always listening and restart automatically after restarting the computer. When running a build in the background we haven't noticed any performance issues on our development machines. We'll have to repeat this process for our Windows builds shortly.
Apple certificates for code signing
To distribute an Electron application on macOS, you need a couple of certificates to be used by electron-builder
when signing the application. For distribution outside of the App Store, you need a Developer ID Application
certificate and a Developer ID Installer
certificate. These certificates can be obtained (opens in a new tab) from the Apple Developer site. The Developer ID Application
certificate can be base64 encoded for use in a CI environment (opens in a new tab) and set as the CSC_LINK
environment variable for electron-builder
to use. Don't forget the accompanying CSC_LINK_PASSWORD
as well. electron-builder
provides more instructions in their documentation (opens in a new tab).
Since the macOS builds are produced on different machines and unaware of each other, we need to merge the latest-mac.yml
files that each separate process produces. The files are small and simple. The one produced by the arm64
build lists the files created and when they were produced. There is also a SHA512 checksum of the file used to validate it when it's downloaded by electron-updater
. To combine the files, combine the files
and their metadata from both latest-mac.yml
versions. It doesn't matter which version the path
specifies, and the release date will be the same in both files. The files array will also include references to the .dmg.blockmap
files which should be included in the final version of your latest-mac.yml
file.
version: 1.0.17
files:
- url: Stashpad-1.0.17-arm64.dmg
sha512: a7y...
size: 104985324
[...]
path: Stashpad-1.0.17-arm64.dmg
releaseDate: '2022-11-10'
Windows build
Similar to macOS, Windows builds must be produced on a Windows machine. While there are some techniques (opens in a new tab) to build in a VM, they only apply if you're building manually outside of a CI environment like we were doing at first. When we first moved to building the Windows installer in a CI environment, we were using an OV (organization validation) certificate which is a .pfx
file that can be exported and used on any computer. The .pfx
file can be base64 encoded just like the macOS certificate. Then, you can consume it from any GitHub Action as an environment variable.
With the use of an OV certificate, which is the only type mentioned (opens in a new tab) by Electron Forge and recommended (opens in a new tab) by Electron Builder, there is a warning presented to the user both on download and then again on the execution of the .exe
installer. In both cases, the warning has a button hidden inside the dropdown that allows the user to proceed anyway. It seems any reasonable user would be scared off by the dual warnings - even more so by a company that is new and has not established a strong reputation among users yet. The warning is meant to go away after an unspecified number of downloads (~100-500), however, the count starts over for every new version of the app you sign. That means that the first 100 or more users who download each new version of the app will get an ominous warning.
To remove the warnings that users get when downloading your app signed with an OV certificate, you need to obtain an EV (extended validation) certificate. The extended validation part means a more extensive background check with your certificate authority. Once you've passed the background check, the certificate will be physically mailed to you in the form of a FIPS 140 compliant USB token. The USB drive must be plugged into your Windows machine to sign the application. Electron Builder has a setting (opens in a new tab) you can use if you want to do this step manually. Since our goal is automation, we had to find a way to work around it.
A self hosted GitHub actions runner for Windows
The instructions for installing the self-hosted runner are fairly similar for Windows (opens in a new tab). When installing the runner, a couple of settings need to be tweaked so that it can easily work with node and yarn installed on the machine. When you are setting up the runner and you are prompted with which Windows account to use, you can specify the NT AUTHORITY\SYSTEM
account instead of the NT AUTHORITY\NETWORK SERVICE
. While the SYSTEM
account has more priveleges (opens in a new tab) it makes for a simpler setup when installing the service. Please be sure to understand the consequences of elevating the privileges of the runner on the machine you're using. Consider what other information is on the machine, what network it is on, and what information could be accessed if it was compromised since the NT AUTHORITY\SYSTEM
account has full access to the computer.
Codesigning for Windows
The way that we've set up our Windows build is to run the job on a Windows machine that has the USB token plugged into it at all times. The runner is also a service so it is always listening for jobs to pick up, even after a restart. Since we're using the EV certificate (on a USB token), the runner can only be installed on one machine, though we've found the self-hosted service to be reliable with virtually no maintenance.
The EV certificate comes with a GUI tool that you're meant to use for entering the password each time you use the certificate to sign a file. To bypass the GUI tool altogether, you can use SignTool which is provided by the Windows SDK, as well as Visual Studio. Installing the community edition of Visual Studio is all you need. To hook into Electron Builder's signing process, you can provide a custom signing script. We've specified the script in the package.json file as the "customSign"
property.
// customSign.js
const password = process.ENV.CERT_PASSWORD
const fileName = 'cert.cer'
const csp = 'eToken Base Cryptographic Provider'
const token = `[SafeNet Token JC 0{{${password}}}]=Sectigo_20220822195425`
exports.default = async function (config) {
if (!password) {
console.log('No password available!')
return false
}
// sign file
const path = config.path
console.log(`Signing ${path}`)
execSync(
`signtool.exe sign /fd SHA256 /f ${fileName} /csp "${csp}" /k "${token}" "${path}"`,
{ stdio: 'inherit' },
)
}
We make sure to bail if the password is available in the environment since attempting to code-sign with the wrong password will lock you out of the token after a few tries. This solution is based on the accepted answer in this wonderful stack overflow thread (opens in a new tab) which explains how to get the other values you need here like the CSP and token name. It is possible to "export" the .cer
file from the GUI tool so you can get this extra metadata, though it will still need to be plugged into the computer to use it.
Linux build
Building for Linux is relatively fast and easy. We use the ubuntu-latest
runner provided by GitHub Actions to produce an .AppImage
which is self-contained and can be run on most Linux distributions. The main reason we chose to distribute our Linux version as an .AppImage
is that electron-updater
supports it for automatic updates which worked great out of the box. The only thing we needed to configure was the artifactName
, which we specified without a version number so that updates for existing users would have a stable name and they could create .desktop
icons for it or symlinks to the file - whichever works best for their workflow.
GitHub Releases and update server
Once all of the build artifacts are uploaded to a GitHub release, we can connect our update server to it. We used the popular update server Hazel (opens in a new tab) for a while but found that it did not work well with private repos and was geared towards the autoUpdater
built into Electron which does not support automatic updates on Linux. It has also not been maintained by Vercel, and since it works with their setup for Hyper (opens in a new tab), there is no need to make any additions to it.
Since Hazel is pretty small and easy to wrap your head around, we rewrote it with our preferred technologies, namely Typescript and Express. We also added support for using private repos and using it with electron-updater
. Our update server Chestnut (opens in a new tab) is open source and has been running reliably for us over the past months.
Note: After many years of separated development, Electron has recently adopted Electron Forge v6 as the official build tool (opens in a new tab) for Electron apps. We're excited about this development in the Electron space and look forward to continually evolving our build system to adapt to best practices for Electron apps.