Magic Apps

Documentation

macmagic.app

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:

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.

  1. Fetch origin/main, then assert the checked-out commit is an ancestor of it. A tag pointing off main is refused rather than built.
  2. Derive the version (What triggers a release) and stamp it into apps/<app>/package.json. For Magimail that is also where the three .appex build scripts read it from; see ../shared/releases-and-updates.md's Version numbers are currently 0.0.0.
  3. Run apps/<app>/scripts/build-app.sh --dist: compile every native target, sign them, and package the .dmg, the updater .zip and their blockmaps. Without --dist the script packages only the unpacked directory, which is what a local install uses.
  4. Submit the DMG to xcrun notarytool submit --wait and 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.
  5. Create and push the annotated tag, unless a tag push triggered the run.
  6. 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.
  7. 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.