CI and releases
check-swift in ci.yml, package-macos in build.yml and all of release.yml run on the
self-hosted-mac label rather than GitHub-hosted macOS runners, which bill at ten times the rate on
a private repository. Nothing on that label starts until someone starts the runner, and GitHub queues
the job silently for six hours rather than failing.
Start the runner
More than one machine may be registered. The checks and commands are in the
start-self-hosted-runner skill, which
also covers registering a runner on a new machine. Start it before triggering CI or a release.
What a release machine needs
Running ci.yml and build.yml needs the tools in that skill, and full Xcode rather than just the
Command Line Tools: xcodebuild ships only with Xcode, and the Share and widget extensions build
from an .xcodeproj. Creating releases needs three more things in the machine's login keychain: the
Developer ID Application certificate, a stored notarytool keychain profile, and the AWS CLI, which
uploads to R2 over its S3 API. Those stay out of GitHub secrets, so codesign and notarytool read
the same keychain a developer would. The R2 token is the exception, because no keychain on the runner
can hold it for CI. See ../shared/releases-and-updates.md.
A machine with the tools and Xcode but none of the release extras builds and tests fine, and fails
release.yml at signing.
What triggers a release
Two triggers, and both end up in the same script:
- A release tag. The tag names both the app and the version, and the workflow builds the commit it points at.
workflow_dispatch. Pick the app (magimail,magicalorboth) and the bump (auto,patch,minorormajor). Withautothe version comes from the Conventional Commit prefixes since that app's previous tag: a!orBREAKING CHANGEis major, afeatis minor, anything else is patch. The workflow creates and pushes the tag itself once the build has succeeded, so a tag never names a release that failed to build.
Run it by hand with gh workflow run:
gh workflow run release.yml -f app=magimail -f bump=minor
gh workflow run release.yml -f app=both -f bump=patch
The dry_run input builds and notarises but creates no tag, uploads nothing and publishes no
release, so it rehearses the whole path without shipping. The faster check is local and takes
seconds: ./apps/magimail/scripts/release.sh --dry-run validates the git state and the version
calculation, then stops.
Tags are magimail-v1.2.0 and magical-v0.3.0. The apps version independently, so a bare v1.2.0
would not say which one it released, and the auto derivation walks back to the previous tag of the
same app rather than to whichever app released last. Releasing both runs the two serially, never in
parallel: they share the runner and its checkout, and a parallel run would stamp one app's version
into the tree while the other was building.
Only one release runs at a time, the concurrency group release. Two concurrent runs would race on
latest-mac.yml, and the loser would leave the manifest pointing at a version whose artefacts were
still uploading.
What the release does
tools/release.sh is the whole pipeline. Each app has a small apps/<app>/scripts/release.sh beside
its build-app.sh that sets three names and calls into it, the same arrangement
../shared/building-and-signing.md describes for
tools/signing.sh. The workflow installs dependencies and runs one of them, so the logic stays in
bash and runs unchanged from a developer shell.
- Fetch
origin/main, then assert the checked-out commit is an ancestor of it. A tag pointing off main is refused rather than built. - Derive the version (What triggers a release) and stamp it into
apps/<app>/package.json. For Magimail that is also where the three.appexbuild scripts read it from; see../shared/releases-and-updates.md's Version numbers are currently0.0.0. - Run
apps/<app>/scripts/build-app.sh --dist: compile every native target, sign them, and package the.dmg, the updater.zipand their blockmaps. Without--distthe script packages only the unpacked directory, which is what a local install uses. - Submit the DMG to
xcrun notarytool submit --waitand staple the ticket. A failure here stops the release: an un-notarised DMG installs cleanly on the Mac that built it and is refused by Gatekeeper everywhere else, so it must never reach the bucket. - Create and push the annotated tag, unless a tag push triggered the run.
- Upload to the bucket under this app's prefix, with the cache headers in
../shared/releases-and-updates.md's Caching for manifests and artefacts. - Create the GitHub Release with
gh release create, attaching the DMG and the download links.
Configuration comes from two repository variables, R2_ACCOUNT_ID and R2_BUCKET, shared by both
apps. Running locally, the same settings come from a gitignored .env.release; see
.env.release.example.
The update URL is not among them. It is publish.url in each app's electron-builder.json, the copy
the build bakes into the bundle as app-update.yml, and release.sh reads it back from there. A
second copy in the environment would let a release advertise its downloads on one host while the
installed app polled another.
The R2 API token is the only release material held as a GitHub secret, as R2_ACCESS_KEY_ID and
R2_SECRET_ACCESS_KEY. It is scoped to object read and write on that bucket and nothing else, so the
worst a leak does is overwrite a release that can be rebuilt from its tag.
Other workflows
ci.yml runs lint, typecheck and tests on GitHub-hosted Ubuntu runners, and the Swift checks
(check-swift) on self-hosted-mac. build.yml packages the unpacked bundle on every push to
main, for whichever apps changed; it answers whether the native targets still assemble, without a
tag or an upload. A dry_run release is the heavier check, because it also notarises.
build.yml resolves the list of apps before the matrix rather than by skipping steps inside it. A
matrix leg that skips every step still reports a green check nobody ran, which reads as "Magical
packaged fine" when Magical was never touched. A change under packages/** or tools/** rebuilds
both apps, because it lands in both bundles.
.github/workflows/verify-tag.yml stays on ubuntu-latest for the same reason as this page's first
paragraph: it makes the "is this tag on main?" check from the release script's first step, and reports
in seconds whether or not the Mac runner is awake.