Skip to content

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.

git tag v0.2.0
git push origin v0.2.0

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.

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:

gh run rerun <run-id> --failed

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):

gh release download v<version> -p latest-mac.yml -D /tmp && cat /tmp/latest-mac.yml

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-action step after the build. electron-builder signs the installer, then hashes it into latest.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.
  • publisherName must 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.mjs pins that the steps and the hook are still there.
  • "Signed" is asked of signtool verify /pa /tw, not Get-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:

  1. backend-api/main.py — bump BACKEND_VERSION
  2. src/shared/constants.ts — bump EXPECTED_BACKEND_VERSION to match
  3. package.json — bump version

Then tag and push:

git tag vX.Y.Z
git push origin vX.Y.Z

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.