GenerateSaaS

Releasing

Build, sign, notarize, and publish the desktop app from GitHub Actions - automatically after CI passes on main, or manually - with storage and signing secrets and an electron-updater feed.

Releasing the desktop app

.github/workflows/desktop-release.yml builds, signs (when signing secrets are set), notarizes, and publishes the app. It runs automatically after CI passes on main, and can also be run manually from the GitHub Actions tab.

Once triggered, the workflow:

Skips cleanly (green, no build) unless your storage upload credentials are complete and the desktop app or a package it bundles changed since the last desktop-v* tag.
Computes the next version from your conventional commits since that tag (the first release uses apps/electron/package.json).
Builds the per-OS installers across a matrix (Linux always; Windows and macOS when their signing secrets are set, or when unsigned builds are opted in), signing and notarizing where secrets allow.
Uploads the installers, the electron-updater feed (latest*.yml), and the per-installer .blockmap files to your public storage bucket under the feed URL's path prefix.
Tags desktop-v<version> and creates a GitHub release.

How the commit type maps to the version bump:

CommitBumps
feat:minor
feat!: / BREAKING CHANGEmajor
anything elsepatch
  • Releases are always manual. .github/workflows/desktop-release.yml runs on workflow_dispatch only, so a release happens when you start one from the Actions tab and never because something merged. Cutting one builds and publishes signed installers from your repository and spends your GitHub Actions minutes, which is not a thing a merge should decide. If you want it CI-chained anyway, add a workflow_run block to the workflow yourself; generatesaas update walks a workflow you have edited as a 3-way merge rather than replacing it.
  • Build cap: 30 minutes per platform (macOS notarization waits on Apple's notary service).

The workflow skips cleanly until you configure publishing. To turn it on, set these repository secrets:

SecretPurpose
STORAGE_PUBLIC_BUCKET, STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEYYour app's public storage account, reused from the app. Installers and the update feed publish here. All five are required together - releases skip until the full set is present.
MAC_CSC_LINK, MAC_CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDmacOS signing (the code-signing cert as a base64 CSC_LINK + its password) and notarization. macOS builds when all five are set (signed + notarized) or when DESKTOP_ALLOW_UNSIGNED is true (unsigned - see below).
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SIGNING_ENDPOINT, AZURE_SIGNING_ACCOUNT_NAME, AZURE_SIGNING_PROFILE_NAMEWindows signing (Azure Trusted Signing, optional): the credentials plus the signing endpoint, account, and certificate profile (AZURE_SIGNING_PUBLISHER_NAME is optional on top). Windows builds when all six are set or when DESKTOP_ALLOW_UNSIGNED is true.

Publish to STORAGE_PUBLIC_BUCKET, never STORAGE_PRIVATE_BUCKET. Installers and the update feed must be publicly readable (electron-updater fetches them anonymously), and the public bucket is already provisioned for exactly that - so private user uploads, which live in the separate private bucket, stay private. The installed app itself never holds storage credentials; it only talks to your API.

When AI ships, the bundled companion's vendored Node runtime is signed and notarized together with the app: electron-builder signs it with the app's hardened runtime and entitlements (its mac.binaries list), and a post-pack check verifies it carries the hardened-runtime flag and your Developer ID before the release proceeds. There is no separately-signed companion artifact.

Point config.desktop.autoUpdate.url at the public URL where the feed lives (your STORAGE_PUBLIC_URL + the same path prefix, or a custom domain). electron-builder bakes that single URL into the packaged app, and the release derives the upload prefix from its path segment - so the feed, the installers, and the stable download links always land where the app looks.

What lands in the bucket

Under the feed prefix, each release publishes:

  • The versioned installers plus their .blockmap files (for delta downloads).
  • latest-mac.yml / latest.yml / latest-linux.yml - the update feeds electron-updater polls per OS.
  • Stable, always-latest download aliases named from config.desktop.protocol: <protocol>.dmg, <protocol>-Setup.exe, <protocol>.AppImage.

The build runs electron-builder --publish never; CI uploads dist/ with the AWS CLI (electron-builder's own S3 publisher is incompatible with Cloudflare R2).

Unsigned builds (opt-in)

Each platform ships signed when its signing secrets are set and is otherwise skipped, so a release never produces an unsigned installer by accident.

PlatformSigningDefault when unsigned
macOSApple Developer ID + notarizationskipped
WindowsAzure Trusted Signingskipped
Linuxnone herealways builds (AppImage, deb)

To preview or test Windows or macOS before you have certificates, set the repository variable DESKTOP_ALLOW_UNSIGNED to true; the release then also builds the unsigned installers.

First-open warnings on unsigned builds:

PlatformPromptHow to open
macOSGatekeeper warningright-click then Open, or System Settings then Privacy & Security then "Open Anyway"
WindowsSmartScreen prompt"More info" then "Run anyway"

Unsigned macOS builds cannot auto-update, so treat them as testing-only. Switching macOS to a real signed build later is purely adding its signing secrets - no code change.

The version is injected at build time from your tag history, so apps/electron/package.json is not edited on each release. To build installers manually instead, use the per-platform build described in Development and builds.

On this page