Releases and updates
Status: Approved specification
A release is a signed, notarised Apple Silicon build that replaces itself in the background. This page covers how it is built, hosted and offered as an update.
Pipeline pieces
| Component | Implementation | What it does |
|---|---|---|
| Update engine | electron-updater |
Downloads and installs delta or full updates through the generic provider |
| Artefact hosting | Cloudflare R2 behind a custom domain | One bucket with a prefix per app; serving an object costs nothing |
| Packaging | release.yml on the self-hosted runner |
Runs on the Mac holding the certificate, so no signing secret is stored |
| Target | Apple Silicon only (arm64), macOS 14+ | Sonoma through Tahoe |
Shape of a release
graph TD
Trigger["Tag push <app>-v1.2.0<br/>or workflow_dispatch"] --> Runner
subgraph Runner["release.yml on the self-hosted macOS runner"]
direction TB
Version["Stamp version from the tag<br/>(package.json, .appex Info.plists)"]
Native["build-app.sh: six targets<br/>tsup · SwiftPM ×2 · node-gyp · xcodebuild ×2 · swiftc"]
SignEx["Each .appex signs itself<br/>Developer ID + --options runtime + --timestamp"]
Builder["electron-builder: seal the bundle<br/>hardenedRuntime, entitlements, signIgnore"]
Notary["notarytool submit --wait, then staple"]
Artifacts["dmg (download) + zip + latest-mac.yml (updater)"]
Version --> Native --> SignEx --> Builder --> Notary --> Artifacts
end
Artifacts -->|"upload"| Bucket[("R2: magic-apps-releases<br/>magimail/ and magical/")]
Bucket -->|"Custom domain"| CDN["Cloudflare edge: updates.macmagic.app"]
CDN -->|"GET latest-mac.yml"| Client["electron-updater in Main"]
Client -->|"newer version available"| Download["download zip → ShipIt swap"]
The update client never consults the licence: an install with no key takes every update it is
offered, because the check gates the launch instead; see licensing.md.
Distribution and updates
Bucket and custom domain
One Cloudflare R2 bucket, magic-apps-releases, holds both apps' artefacts, and each app writes
under its own prefix: magimail/ and magical/. A custom domain in front of the bucket serves it
from Cloudflare's edge. The bucket's r2.dev subdomain stays off, so the custom domain is the only
credential-free route in, and the S3 endpoint the release uploads to stays behind an API token.
The client uses electron-builder's generic provider. The publish block is baked into the
shipped bundle as Contents/Resources/app-update.yml: the provider pointed at
https://updates.macmagic.app/<app>/, with no credentials in the bundle. tools/release.sh uploads
the artefacts with the aws CLI pointed at R2's S3 endpoint. The generic provider reads
<url>/latest-mac.yml, a fixed path, so the app prefix has to appear in that URL and not only in the
object key; two apps published at the bucket root would share one manifest and each release would
offer the other app's build as an update.
Upload latest-mac.yml, the .zip, the .dmg and both .blockmap files. The updater reads the
manifest and the zip; the blockmaps carry the delta downloads.
Caching for manifests and artefacts
latest-mac.yml must never be stale, because it is the file that says a new version exists. It is
also small, plain text and looks eminently cacheable, which is the trap. Everything else carries its
version in its own filename and never changes. Cloudflare caches by file extension, and its default
list settles this with no rule to write:
| Path pattern | Cached at the edge | Why |
|---|---|---|
*.yml |
No | Not on the default list, so a hotfix is immediate |
*.zip, *.dmg |
Yes | On the default list, and the version is in the name |
*.blockmap |
No | Not on the default list; small, and read on a delta |
So there is no invalidation step and nothing to purge; the manifest is fetched from R2 on every
check. tools/release.sh sets --cache-control at upload anyway, 60 seconds on the manifest and
max-age=31536000, immutable on the binaries, so the split survives someone later adding a
cache-everything rule to the zone.
Cloudflare will not cache an object over 512 MB below the Enterprise plan. The update .zip is
roughly 150 MB, so that only becomes a constraint if the bundle triples.
Version numbers are currently 0.0.0, and nothing updates
Both package.json files declare version 0.0.0. electron-updater compares semver, so a customer
on 0.0.0 and a release at 0.0.0 never updates, and nothing reports an error because nothing is
wrong.
The release workflow's first step derives the version from the git tag and writes it into the app's
package.json before build-app.sh runs. The extension build scripts read it from there, so one
write reaches every bundle. macOS decides an extension has changed by a separate build number; see
process-model-and-ipc.md's Extension build number.
Extensions after an update
ShipIt swaps the whole .app atomically, so Contents/Resources/MagimailHelper,
libMagimailNative.dylib, magimail_addon.node and the .appex bundles are all replaced at once.
The system re-registers an extension only when it next scans the bundle, so verify these after the
first real update:
- Widgets briefly show their "Open Magimail to sync" placeholder while
chronodre-reads descriptors; the build number is what makes it do that at all. - The Focus Filter disappears from System Settings if
MagimailAppIntents.appexlands with a different team or a broken sandbox entitlement.signIgnoreprotects its signature through packaging; confirm the signature also survives the round trip through the updater zip, which archives and extracts the bundle a second time, something the local build never does. - The Share extension re-registers under
pluginkit. Check it still appears in the Share menu.
Clean shutdown on update
sendToHelper() (src/swift-helper.ts) respawns the Swift helper whenever its stdin is not
writable, so an uncoordinated SIGTERM can bring the helper back before quitAndInstall() has
finished swapping the bundle. Shut down in this order:
- Set a shutting-down flag in Main, so
sendToHelper()resolvesnullat once rather than spawning. - Stop the Atom poller so in-flight polling cannot push tray updates.
- Call
killHelper(). - Call
autoUpdater.quitAndInstall().
Apple silicon only
electron-builder.json targets arch: ["arm64"], and official builds are Apple Silicon only
(arm64, macOS 14+). No Intel (x86_64) build is produced: the native compilation targets require
Apple Silicon, and a universal bundle cannot be tested reliably without dedicated Intel hardware.
Output filenames keep the architecture suffix (e.g. Magimail-1.2.0-arm64.dmg), and download links
state the macOS 14+ Apple Silicon requirement plainly.
Before the first Developer ID build
The signing rules that make a bundle notarisable, and the entitlements it needs, are in
building-and-signing.md. The credentials to submit with are what is
missing. Two things to check:
-
Verify nested binaries are sealed. Notarisation refuses the bundle over any one of them and names the requirement rather than the file, so check the whole tree rather than a sample:
find /Applications/Magimail.app -type f \ \( -perm -u+x -o -name "*.dylib" -o -name "*.node" \) | while read -r f; do file "$f" | grep -q Mach-O || continue codesign -dvv "$f" 2>&1 | grep -q "flags=0x10000(runtime)" || echo "unhardened: $f" doneThe parentheses matter: without them
-type fbinds only to the first test and the walk reports directories. Addmac.binariesentries if electron-builder ever misses one. -
Re-check whether a Developer ID build needs an embedded provisioning profile. The widget cache uses an App Group, and that works on a development certificate with no profile at all, which
building-and-signing.mdrecords how that was established. Whether Developer ID signing plus notarisation changes it is untested. If it does, the build needsContents/embedded.provisionprofileand CI needs a further secret. Settle this before the first Developer ID build rather than at notarisation.
Release build pipeline
The release build runs in .github/workflows/release.yml on the self-hosted self-hosted-mac
runner, which is the Mac holding the Developer ID certificate, so no signing material is stored as a
GitHub secret. The triggers, the tag scheme, what each step does, the runner requirement and the
other workflows are in
../developer-guidelines/ci-and-releases.md.
Community and support
The repository is public, so anyone with Xcode, SwiftPM and pnpm can read the source and build it,
and a Google-side change can be watched being fixed in the open. The bug report form,
.github/ISSUE_TEMPLATE/bug_report.yml, asks for the macOS version, the Apple Silicon chip and the
error logs, so a report arrives with enough in it to work from. Help → Reveal Logs in Finder
opens the folder so a user can attach the whole set. What the log holds, and what is redacted out of
it, is in logging.md.