Pear Docs

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:

  1. A hello-pear-electron-shaped repository whose npm run make produces distributables (see Desktop release npm scripts). The template installs with --ignore-scripts, so the workflow below also runs its postinstall:all and postinstall:mac scripts: they fetch the Electron binary, and on macOS rebuild macos-alias and fs-xattr.
  2. Signing credentials stored as repository secrets:
    • macOS, Developer ID build (the .dmg and the .app): a base64-encoded Developer ID Application certificate (.p12) and its password, the code-signing identity (for example Developer 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 .pfx and its password (cert_pfx).
  3. The pear:// link this build should track as its channel/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_method

    • appstore_connect—requires macos_api_key_base64, macos_api_key_id, macos_api_issuer.
    • apple_id_password—requires macos_apple_id, macos_apple_password, macos_apple_team_id.

    Either way you also pass macos_development_certificate_base64, macos_development_password, and macos_codesign_identity. The action imports the certificate into a temporary keychain and stores notarytool credentials in a keychain profile named notary—the CI equivalent of setting MAC_CODESIGN_IDENTITY and KEYCHAIN_PROFILE. The action's input names call the Developer ID certificate the development certificate. The macos_distribution_* inputs belong to the Mac App Store build, not to this one.

  • windows_signing_method

    • cert_sha1—requires windows_cert_sha1 (a thumbprint already on the runner).
    • cert_pfx—requires windows_cert_pfx_base64 and windows_cert_password; the action imports the .pfx and derives the thumbprint.

    This drives WINDOWS_CERT_SHA1 and points WINDOWS_SIGN_HOOK at the action's bundled hook, which signs with signtool using that thumbprint. Keep the Publisher in build/AppxManifest.xml equal to the certificate CN or 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.

InputWhat to pass
scriptmake:mas.
macos_mas_pkgThe .pkg that make:mas writes under out/make: <productName>-<version>-<arch>.pkg in the template.
macos_distribution_certificate_base64, macos_distribution_passwordThe Apple Distribution certificate as a base64-encoded .p12, and its password.
macos_distribution_identityBecomes MAS_DISTRIBUTION_IDENTITY, the identity that signs the app. The template's job passes Apple Distribution.
macos_installer_distribution_certificate_base64, macos_installer_distribution_passwordThe installer distribution certificate as a base64-encoded .p12, and its password.
macos_installer_distribution_identityBecomes MAS_INSTALLER_DISTRIBUTION_IDENTITY, the identity that signs the .pkg. The template's job passes 3rd Party Mac Developer Installer.
macos_distribution_provisioning_profile_base64A 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_base64 and macos_p12_password are now macos_development_certificate_base64 and macos_development_password.
  • linux_set_up_snapcraft is gone. The action sets up Snapcraft when linux_snap is set.
  • windows_signing_method takes cert_sha1 or cert_pfx.
  • The artifact-links output is gone. Name the files to upload with macos_dmg, macos_app, windows_msix, linux_appimage, and the other file inputs.
  • The action no longer sets up Node.js or runs npm ci. Add node-base-extended and run-npm-script steps before it.

See also

Last updated on

Was this helpful?

On this page