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:
desktop-v* tag.apps/electron/package.json).latest*.yml), and the per-installer .blockmap files to your public storage bucket under the feed URL's path prefix.desktop-v<version> and creates a GitHub release.How the commit type maps to the version bump:
| Commit | Bumps |
|---|---|
feat: | minor |
feat!: / BREAKING CHANGE | major |
| anything else | patch |
- Releases are always manual.
.github/workflows/desktop-release.ymlruns onworkflow_dispatchonly, 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 aworkflow_runblock to the workflow yourself;generatesaas updatewalks 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:
| Secret | Purpose |
|---|---|
STORAGE_PUBLIC_BUCKET, STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY | Your 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_ID | macOS 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_NAME | Windows 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
.blockmapfiles (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.
| Platform | Signing | Default when unsigned |
|---|---|---|
| macOS | Apple Developer ID + notarization | skipped |
| Windows | Azure Trusted Signing | skipped |
| Linux | none here | always 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:
| Platform | Prompt | How to open |
|---|---|---|
| macOS | Gatekeeper warning | right-click then Open, or System Settings then Privacy & Security then "Open Anyway" |
| Windows | SmartScreen 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.