Releasing¶
Tag a version and push; the workflow at .github/workflows/release.yml
builds the backend and packages the app on four runners in parallel —
macOS Apple Silicon and macOS Intel (both signed + notarized), Windows,
and Linux — and uploads every artifact to a single GitHub Release.
The two macOS runners exist because the Python backend (PyInstaller +
libhelios) must be compiled natively on each architecture — an
arm64-built backend inside an x64 app is dead on arrival on Intel Macs.
Each macOS job packages only its own architecture, and a final
merge-latest-mac job merges the two latest-mac.yml auto-updater
manifests so in-app updates work on both. The Intel job runs on
macos-15-intel, GitHub's last x86_64 image — it retires in August
2027, at which point Intel macOS builds end.
Publishing and download links¶
The release is published directly (not left as a draft):
build.publish.releaseType in package.json is release, which is
required for the in-app "Check for Updates" (electron-updater) to detect
it. Expect these artifacts: two macOS .dmg + .zip pairs (arm64 and
x64), one Windows .exe, one Linux .AppImage, plus updater metadata
(latest*.yml, .blockmap).
Publishing flags the release "Latest" — which is what makes
https://github.com/PlantSimulationLab/Phytograph/releases/latest (the
link the lab website points at) resolve to it.
Storage pruning (automatic)¶
Each release ships roughly 3.6 GB of installers, and unlike Actions artifacts — which self-expire after 30 days — release assets never expire. Four releases were enough to consume the organization's entire 20 GB shared-storage quota. Worse, once over quota GitHub meters the overage daily regardless of activity, so storage warnings can arrive during a week when no workflow ran at all.
The prune-old-releases job in release.yml runs last on every real
release and keeps the installers for the newest two releases (the
current one plus a rollback target), deleting the large binaries
(.dmg, .zip, .exe, .AppImage) from anything older. Older
releases keep their tag, release notes, and the small updater metadata
(latest*.yml, .blockmap — about 3 MB), so their pages stay
meaningful and electron-updater manifests remain readable.
Prereleases and drafts are never pruned and never consume a "keep" slot.
Signing bring-up runs (signing_test) and rehearsals (dry_run) skip
pruning entirely, since they don't publish. The job is continue-on-error — reclaiming storage must
never fail a release that already built successfully; a missed prune is
picked up by the next release.
Run it manually against the live repo:
npm run prune:releases:dry # show what would be deleted, change nothing
npm run prune:releases # apply the default keep=2 policy
node scripts/prune-release-assets.mjs --dry-run --keep=3 # custom retention
Deleting a release asset is irreversible — GitHub keeps no copy, and because these binaries are signed and notarized, recreating one means re-running the full release pipeline against the old tag. Always dry-run first when changing the policy.
When one platform fails¶
The four platform builds run as a matrix with fail-fast: false, so one
failure leaves the other three published. Re-run just the broken one:
That re-runs only the failed job, leaves the successful jobs' already-uploaded
assets untouched, and re-triggers the dependent merge-latest-mac.
Then check latest-mac.yml before calling the release done. The merge job
is if: always(), so on a partial macOS release it still runs — and
electron-builder will already have uploaded a single-arch manifest from
whichever macOS job succeeded. Download it and confirm it lists all four macOS
files (arm64 zip + dmg, x64 zip + dmg):
An arm64-only manifest means Intel users' auto-updater finds no matching asset. The merge script is guarded (it refuses to clobber unless both manifests are present), but that guard leaves the single-arch file in place rather than fixing it.
Transient failures are common enough to expect: v0.70.0's Intel job failed
twice — once on ECONNRESET from the npm registry, once on an Apple notary
timeout (NSURLErrorDomain Code=-1001) — then passed unchanged on the third
attempt. scripts/notarize.cjs now retries transport failures up to three
times with backoff, while failing immediately on a genuine notarization
verdict (status: Invalid/Rejected), so a rejected bundle still fails fast
instead of burning three upload cycles. That policy is pinned by
scripts/notarize.test.mjs.
Verify a release by its artifacts, not by job status: assets present, the manifest contents above, and the job's own Verify macOS notarization and Smoke-test packaged app steps.
Required GitHub Secrets¶
| Secret | Purpose |
|---|---|
APPLE_CERTIFICATE |
base64 of the .p12 Developer ID cert |
APPLE_CERTIFICATE_PASSWORD |
password for the .p12 |
APPLE_SIGNING_IDENTITY |
e.g. Developer ID Application: Your Name (TEAMID) |
APPLE_ID |
Apple ID email used for notarization |
APPLE_PASSWORD |
app-specific password for notarization |
APPLE_TEAM_ID |
10-character Apple Team ID |
AZURE_TENANT_ID |
Entra directory (tenant) ID for Windows signing |
AZURE_CLIENT_ID |
Entra app registration (client) ID |
AZURE_CLIENT_SECRET |
Client secret for that app registration |
Windows code signing¶
Windows installers are signed by Azure Artifact Signing (formerly "Azure
Trusted Signing"). Signing is optional — with the AZURE_* secrets unset the
build still succeeds and produces an unsigned installer, which is what local
npm run package:win does.
The configuration is injected by scripts/run-electron-builder.mjs
(azureSigningArgs()), not written in package.json, because it must be
conditional: electron-builder's Azure path throws when the credentials are
absent. The signing account itself (endpoint, account, certificate profile,
publisher, timestamp server) is defined once in scripts/azure-sign-config.mjs
and read by both signers described below.
Three things about this setup are easy to break:
- Signing the installer must stay inside electron-builder. Do not move it
to a separate
azure/artifact-signing-actionstep after the build. electron-builder signs the installer, then hashes it intolatest.yml; signing afterwards changes the hash and electron-updater rejects every update as corrupt. - Timestamps are passed explicitly. electron-builder 25.1.8 sends no timestamp parameters to Azure (#8626). Azure's certificates live only 72 hours, so an untimestamped signature expires within days. Upgrading to electron-builder v26+ makes this native and the override can be dropped.
publisherNamemust match the certificate CN exactly. electron-updater compares it against the downloaded installer; unset means verification is silently skipped, wrong means every update is rejected. The "Verify Windows signature" workflow step asserts both the timestamp and the CN.
Native libraries are signed separately¶
electron-builder signs .exe files and nothing else. The backend sidecar is a
PyInstaller bundle holding several hundred .pyd and .dll files, Electron
adds DLLs of its own beside Phytograph.exe, and Windows application control
(Smart App Control on Windows 11, WDAC or AppLocker on a managed machine)
judges each one as it is loaded. Up to v0.94.0 they shipped unsigned: the
signed phytograph_backend.exe started, then died at import pandas with
"An Application Control policy has blocked this file" on every launch
(issue #6).
scripts/sign-win-natives.ps1 closes that. It finds every PE image in the
trees it is given (by reading the file header, not by extension), signs the
ones with no valid signature in one batched Invoke-TrustedSigning call, and
re-reads each to confirm. It runs three times in a release:
| Where | Over | Why there |
|---|---|---|
| Sign bundled native libraries (Windows) step | resources/phytograph_backend/, resources/potree_converter/ |
Before electron-builder, so the backend smoke test that follows launches the signed bundle. |
scripts/after-pack-win-sign.cjs (electron-builder afterPack) |
the whole unpacked app | The only moment Electron's own DLLs exist and the installer does not. Also the last check before anything is published. |
| Verify Windows signature step | the whole unpacked app, -VerifyOnly -IncludeExe |
Reads what actually shipped, executables included. |
All of it happens before the installer is built, so the installer's hash chain is computed over signed bytes.
- Do not replace it with
win.signDlls/win.signExts. electron-builder makes one Azure round trip per file, which for this bundle does not fit in the build step's timeout. Setting them as well signs everything twice. - Files a vendor already signed are left alone, timestamped or not, and keep the vendor's name. Re-signing would replace a better-known publisher with ours, and some redistributable licenses grant copying but not modifying. Every run prints which files were left under whose signature; that list is what an IT administrator has to allow besides our publisher.
- A file with no valid embedded signature gets ours, whoever wrote it. That includes copies of Windows system DLLs that PyInstaller pulls in: Windows vouches for those through a catalog, which stays on the build machine. The script lists them separately so this is visible.
- Nothing on the runner can detect a regression by running the app. The
build machine enforces no such policy, so the smoke tests load unsigned
libraries without complaint. The signature of every packaged file is
therefore read, and
scripts/win-native-signing.test.mjspins that the steps and the hook are still there. - "Signed" is asked of
signtool verify /pa /tw, notGet-AuthenticodeSignature. For a file Windows also knows through a catalog the PowerShell cmdlet reports the catalog's signer and ignores the signature embedded in the file. Only the embedded signature ships. - Signing resumes after a failed request. signtool signs the list in order and abandons the rest at the first file Azure fails; on the first rehearsal one failed request left 354 of 372 files unsigned. The script goes again on whatever is still unsigned, raises a warning on the run for each pass that was cut short, and gives up when two passes in a row sign nothing.
- Measured cost (rehearsal, 2026-10-08): 460 libraries in the sidecars, 372 signed by us in 10 minutes, plus Electron's 5 from the hook. The other 88 stay under Microsoft, Python Software Foundation and Intel signatures.
Rehearse a release without publishing¶
Run the Release workflow by hand with dry_run ticked. It does
everything a tag does (native builds, the platform gate, signing, notarization,
every verification and smoke test) with --publish never, and skips the
manifest merge and storage pruning. Use it after changing anything in the
signing or packaging steps: a tag is otherwise the first time the real bundle
meets a release-only step, and with fail-fast: false a Windows job that fails
on the tag leaves a published release with no Windows installer.
signing_test is the faster, narrower tool: it stubs the sidecars and skips
the native build, so it proves the signing path in about five minutes but says
nothing about the real bundle. Both sign real files and count against the
Azure signing quota.
Signing satisfies Smart App Control. An organization's own policy can still
refuse any publisher it has not allowed, which no build can fix; the app
recognizes that failure at startup (classifyBackendFailure in
src/main/backend.ts) and says so instead of offering a Reload.
The client secret expires
Unlike the OIDC-based Azure integrations, electron-builder authenticates
with EnvironmentCredential and needs a client secret, which expires
(~24 months, failing with AADSTS7000222). The release workflow fails
loudly rather than shipping an unsigned installer — regenerate the secret
in the Entra app registration and update AZURE_CLIENT_SECRET.
Use an app-specific password
APPLE_PASSWORD should be an app-specific password generated at
appleid.apple.com, not your real Apple ID
password.
Version bumping¶
When backend changes require users to receive a new build, all three of these must move together — the supervisor refuses to start mismatched versions:
backend-api/main.py— bumpBACKEND_VERSIONsrc/shared/constants.ts— bumpEXPECTED_BACKEND_VERSIONto matchpackage.json— bumpversion
Then tag and push:
See Version Lock for why this contract exists.
Commit conventions¶
Do not sign commits with AI co-author trailers. No
Co-Authored-By: Claude …, no "Generated with Claude Code" lines in PR
descriptions, no model attribution of any kind. Commits should appear
authored solely by the human committer.