Build and sign desktop apps with GitHub Actions
Build, code-sign, and notarize a Pear Electron app on macOS, Windows, and Linux from GitHub Actions with the make-pear-app action, build its Mac App Store package, then hand the artifacts to pear build or pear-ci.
This is the CI automation of Build desktop distributables. Instead of running npm run make by hand on a Mac, a Windows box, and a Linux box and wiring signing credentials into each, you run one job per OS that uses the make-pear-app action on hosted runners.
make-pear-app runs one of your npm scripts—make by default, the same forge.config.js the manual guide uses—and sets CHANNEL, UPGRADE_KEY, and the signing environment variables that file reads. On macOS it imports your certificates into a temporary keychain and stores notarytool credentials in it. On Windows it imports a .pfx certificate when you ask for one and points WINDOWS_SIGN_HOOK at a signing hook the action bundles. On Linux it installs Snapcraft and LXD when you ask for a Snap. Then it uploads the files you name as workflow artifacts. It doesn't install Node.js or your dependencies; earlier steps do that.
Need the pear CLI? Install it from install.pears.com, or prefix any command below with npx. See Install & upgrade for details.
Before you begin
You need:
- A
hello-pear-electron-shaped repository whosenpm run makeproduces distributables (see Desktop release npm scripts). The template installs with--ignore-scripts, so the workflow below also runs itspostinstall:allandpostinstall:macscripts: they fetch the Electron binary, and on macOS rebuildmacos-aliasandfs-xattr. - Signing credentials stored as repository secrets:
- macOS, Developer ID build (the
.dmgand the.app): a base64-encoded Developer ID Application certificate (.p12) and its password, the code-signing identity (for exampleDeveloper ID Application: Name (TEAMID)), and notarization credentials—either an App Store Connect API key (appstore_connect) or an Apple ID and app-specific password (apple_id_password). - macOS, Mac App Store build (optional): an Apple Distribution certificate and an installer distribution certificate (both
.p12) with their passwords, a provisioning profile, and the two signing identities. See Build for the Mac App Store. - Windows: either a certificate SHA-1 thumbprint (
cert_sha1) or a base64-encoded.pfxand its password (cert_pfx).
- macOS, Developer ID build (the
- The
pear://link this build should track as itschannel/upgrade_key(see Set the upgrade link).
Add the workflow
Create .github/workflows/build.yml. A prebuild job reads the app name and version from package.json. One job per OS then sets up Node.js, installs dependencies, and runs make-pear-app. Each OS gets its own job because the files npm run make writes have OS-specific names, and every file you want back as an artifact is named in an input.
name: Build desktop app
on:
workflow_dispatch:
push:
tags: ['v*']
jobs:
prebuild:
runs-on: ubuntu-latest
outputs:
package-name: ${{ steps.meta.outputs.package_name }}
package-name-lower: ${{ steps.meta.outputs.package_name_lower }}
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: meta
run: |
name=$(jq -r '.name' package.json)
product_name=$(jq -r '.productName // empty' package.json)
package_name="${product_name:-$name}"
package_name_lower=$(printf '%s' "$package_name" | tr '[:upper:]' '[:lower:]')
{
echo "package_name=$package_name"
echo "package_name_lower=$package_name_lower"
echo "version=$(jq -r '.version' package.json)"
} >> "$GITHUB_OUTPUT"
build-macos:
needs: [prebuild]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: holepunchto/actions/node-base-extended@v1
with:
allow-git: root
clean-install: true
version: 20
- uses: holepunchto/actions/run-npm-script@v1
with:
script: postinstall:mac
- uses: holepunchto/actions/make-pear-app@v1
with:
channel: production
upgrade_key: ${{ vars.PEAR_UPGRADE_KEY }}
macos_dmg: ${{ format('{0}-{1}-arm64.dmg', needs.prebuild.outputs.package-name, needs.prebuild.outputs.version) }}
macos_app: ${{ format('{0}-{1}-arm64.app.zip', needs.prebuild.outputs.package-name, needs.prebuild.outputs.version) }}
# Developer ID Application certificate, signing, and notarization
macos_development_certificate_base64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
macos_development_password: ${{ secrets.MACOS_P12_PASSWORD }}
macos_codesign_identity: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
macos_notarization_method: appstore_connect
macos_api_key_base64: ${{ secrets.MACOS_API_KEY_BASE64 }}
macos_api_key_id: ${{ secrets.MACOS_API_KEY_ID }}
macos_api_issuer: ${{ secrets.MACOS_API_ISSUER }}
build-windows:
needs: [prebuild]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: holepunchto/actions/node-base-extended@v1
with:
allow-git: root
clean-install: true
version: 20
- uses: holepunchto/actions/run-npm-script@v1
with:
script: postinstall:all
- uses: holepunchto/actions/make-pear-app@v1
with:
channel: production
upgrade_key: ${{ vars.PEAR_UPGRADE_KEY }}
windows_msix: ${{ format('{0}.msix', needs.prebuild.outputs.package-name) }}
# Windows signing
windows_signing_method: cert_pfx
windows_cert_pfx_base64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
windows_cert_password: ${{ secrets.WINDOWS_CERT_PASSWORD }}
build-linux:
needs: [prebuild]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: holepunchto/actions/node-base-extended@v1
with:
allow-git: root
clean-install: true
version: 20
- uses: holepunchto/actions/run-npm-script@v1
with:
script: postinstall:all
- uses: holepunchto/actions/make-pear-app@v1
with:
channel: production
upgrade_key: ${{ vars.PEAR_UPGRADE_KEY }}
linux_appimage: ${{ format('{0}-{1}-x64.AppImage', needs.prebuild.outputs.package-name, needs.prebuild.outputs.version) }}
linux_snap: ${{ format('{0}_{1}_amd64.snap', needs.prebuild.outputs.package-name-lower, needs.prebuild.outputs.version) }}
linux_flatpak: ${{ format('{0}_{1}_x64_flatpak.tar.gz', needs.prebuild.outputs.package-name-lower, needs.prebuild.outputs.version) }}The setup steps before make-pear-app do the work the action leaves to you. node-base-extended installs Node.js 20 and Socket Firewall, then runs npm ci with install scripts off; allow-git: root lets the template's git dependency through. run-npm-script then runs postinstall:mac or postinstall:all to do the install steps that --ignore-scripts skipped.
Every file input names a file that npm run make writes under out/make, and the names above are the ones the template produces. The prebuild job reads productName and version from package.json so they follow each release. The action uploads each named file as a workflow artifact, named after the file unless you also pass the matching *_artifact_name input, and fails the job when the file isn't there. The template also builds x64 on macOS (macos-26-intel) and arm64 on Linux (ubuntu-24.04-arm) in separate jobs, so pear build gets one build per architecture.
Each job passes only its own OS's inputs; the action skips the steps that belong to other OSes. It doesn't check up front that every input a signing method needs is set. Windows signing without a thumbprint stops in the signing hook with WINDOWS_CERT_SHA1 not set. An empty macos_codesign_identity makes forge.config.js skip signing and notarization, so the job succeeds with an unsigned app.
Choose the signing methods
make-pear-app branches on two method inputs that mirror the manual environment variables in Build desktop distributables:
-
macos_notarization_methodappstore_connect—requiresmacos_api_key_base64,macos_api_key_id,macos_api_issuer.apple_id_password—requiresmacos_apple_id,macos_apple_password,macos_apple_team_id.
Either way you also pass
macos_development_certificate_base64,macos_development_password, andmacos_codesign_identity. The action imports the certificate into a temporary keychain and stores notarytool credentials in a keychain profile namednotary—the CI equivalent of settingMAC_CODESIGN_IDENTITYandKEYCHAIN_PROFILE. The action's input names call the Developer ID certificate the development certificate. Themacos_distribution_*inputs belong to the Mac App Store build, not to this one. -
windows_signing_methodcert_sha1—requireswindows_cert_sha1(a thumbprint already on the runner).cert_pfx—requireswindows_cert_pfx_base64andwindows_cert_password; the action imports the.pfxand derives the thumbprint.
This drives
WINDOWS_CERT_SHA1and pointsWINDOWS_SIGN_HOOKat the action's bundled hook, which signs withsigntoolusing that thumbprint. Keep thePublisherinbuild/AppxManifest.xmlequal to the certificateCNor MSIX OTA updates break—see the Windows section.
Write the Windows method as cert_sha1 or cert_pfx. The make-pear-app README and hello-pear-electron's build-release.yml show windows_cert_pfx, but the action compares against the exact string cert_pfx before it imports the certificate and reads its thumbprint. With windows_cert_pfx it skips both, WINDOWS_CERT_SHA1 stays empty, and the signing hook rejects the build.
On Linux, the action installs Snapcraft and LXD only when you set linux_snap. The template's forge.config.js includes a Snap maker, and Electron Forge stops when snapcraft and lxd are missing, so a template-shaped repository needs linux_snap set—or the Snap maker removed—to build on a hosted runner.
Build for the Mac App Store
The template's build-macos-mas job builds a Mac App Store package next to the Developer ID build. It runs the make:mas script instead of make and passes the distribution inputs: the macos_distribution_* and macos_installer_distribution_* names are for this build.
| Input | What to pass |
|---|---|
script | make:mas. |
macos_mas_pkg | The .pkg that make:mas writes under out/make: <productName>-<version>-<arch>.pkg in the template. |
macos_distribution_certificate_base64, macos_distribution_password | The Apple Distribution certificate as a base64-encoded .p12, and its password. |
macos_distribution_identity | Becomes MAS_DISTRIBUTION_IDENTITY, the identity that signs the app. The template's job passes Apple Distribution. |
macos_installer_distribution_certificate_base64, macos_installer_distribution_password | The installer distribution certificate as a base64-encoded .p12, and its password. |
macos_installer_distribution_identity | Becomes MAS_INSTALLER_DISTRIBUTION_IDENTITY, the identity that signs the .pkg. The template's job passes 3rd Party Mac Developer Installer. |
macos_distribution_provisioning_profile_base64 | A base64-encoded provisioning profile. The action decodes it, copies it to ~/Library/MobileDevice/Provisioning Profiles/app.mas.provisionprofile, and passes the decoded file's path as MAS_PROVISIONING_PROFILE. |
Add a job like this one to the workflow above:
build-macos-mas:
needs: [prebuild]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: holepunchto/actions/node-base-extended@v1
with:
allow-git: root
clean-install: true
version: 20
- uses: holepunchto/actions/run-npm-script@v1
with:
script: postinstall:mac
- uses: holepunchto/actions/make-pear-app@v1
with:
script: make:mas
channel: production
upgrade_key: ${{ vars.PEAR_UPGRADE_KEY }}
macos_mas_pkg: ${{ format('{0}-{1}-arm64.pkg', needs.prebuild.outputs.package-name, needs.prebuild.outputs.version) }}
macos_distribution_certificate_base64: ${{ secrets.MACOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
macos_distribution_password: ${{ secrets.MACOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
macos_distribution_identity: 'Apple Distribution'
macos_installer_distribution_certificate_base64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }}
macos_installer_distribution_password: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }}
macos_installer_distribution_identity: '3rd Party Mac Developer Installer'
macos_distribution_provisioning_profile_base64: ${{ secrets.MACOS_PROVISIONING_PROFILE_BASE64 }}The job passes no notarization inputs, because the Mac App Store branch of forge.config.js doesn't notarize. The .pkg becomes a workflow artifact; nothing in the template uploads it to App Store Connect. For what the build needs and how a store install behaves, see Mac App Store.
Use the artifacts
make-pear-app has no outputs. It uploads each file you named as a workflow artifact. Download them in a follow-up job, run pear build to assemble the multi-architecture deployment directory, then pear stage it as in Deploy your application.
pear build takes the .app bundle, the .AppImage, and the .msix for each architecture. Upload them with macos_app, linux_appimage, and windows_msix. The action zips the .app bundle with ditto and uses the macos_app value as the zip's file name, so unzip it before you pass it to pear build.
To make publishing fully hands-off, hand the deployment directory to pear-ci: make-pear-app produces the signed build, and pear-ci stages it to a stable pear:// link on every push.
Update an older workflow
If your workflow follows an earlier version of this guide, change these:
macos_certificate_base64andmacos_p12_passwordare nowmacos_development_certificate_base64andmacos_development_password.linux_set_up_snapcraftis gone. The action sets up Snapcraft whenlinux_snapis set.windows_signing_methodtakescert_sha1orcert_pfx.- The
artifact-linksoutput is gone. Name the files to upload withmacos_dmg,macos_app,windows_msix,linux_appimage, and the other file inputs. - The action no longer sets up Node.js or runs
npm ci. Addnode-base-extendedandrun-npm-scriptsteps before it.
See also
- Build desktop distributables—the manual per-OS make, signing, and notarization this automates.
- Submit to app stores—what the Mac App Store build needs and produces, and the Linux store flows.
- Publish with GitHub Actions—stage the resulting build with
pear-ci. - Holepunch GitHub Actions—every shared action, inputs, and outputs.
- Deploy your application—covers
pear build,pear stage, and release lines. - Desktop release npm scripts—the
npm run makeandnpm run make:masentry points the action calls.
Last updated on