
Photo by Oleg Solodkov on Unsplash
I wrote Release Manager because I needed something to build, sign, notarise, package, and ship every Jorvik app from one window. I wrote it in Swift because Swift is what I write Mac apps in, and Release Manager is a Mac app. I wrote it as one big pipeline because pipelines are sequential and Swift is good at sequential. By the time it was working end-to-end it was about a thousand lines long, and I felt entirely justified.
A year and change later it was 1,600 lines and I wasn’t justified anymore.
I want to be careful about how I describe what changed, because it’s tempting to write this as “the old code was bad and I made it good,” and that isn’t honest. The old code was fine. It worked. It shipped real software for a year. The thing that changed is that I came to understand which parts of the job belonged in Swift and which parts I had only put there because Swift was the language I happened to be holding.
The Release Manager pipeline started simple. Eleven stages, sequenced in order: preflight checks, build, verify the build, sign the bundle, verify the signature, submit to Apple’s notary service, staple the ticket, verify notarisation, package as .zip (or .pkg, for things like the screen savers), sign the zip with Sparkle’s EdDSA key, push to GitHub. Each stage was a Swift function. They called Process with a list of arguments, captured stdout via Pipe, parsed the result, and either passed or failed.
This was clean architecture for the original suite, which was almost entirely menu-bar utilities built with one of two patterns. xcodebuild for the apps that lived in Xcode projects, swift build for the ones using Swift Package Manager. The pipeline branched on buildSystem at one point, ran the right shell-out, and continued.
Then I started adding apps that didn’t fit the pattern.
The first wobble was apps that were neither full Xcode projects nor SPM packages — little single-file utilities I’d written with a direct swiftc invocation in a build.sh. Adding swiftc as a third build mode meant a new branch in the build stage. Then I started embedding Sparkle.framework for auto-update, which meant the sign stage had to walk the framework’s nested code in dependency order — XPC services, the updater app, the framework binary itself — and codesign each leaf before sealing the outer bundle. That’s a recursive shell helper in any sensible world. In Release Manager it became a hundred lines of Swift that walked Contents/Frameworks/ with file enumerators.
Then I added a screen saver. ASCII Saver isn’t a single bundle — it’s a .saver plus a helper .app that handles camera access, distributed as a multi-component installer. That meant the package stage had to know about productbuild, Distribution.xml, multiple inner packages combined into an outer package, with the helper pre-signed differently from the main bundle. More Swift, more branches, more hardcoded if app.id == "ASCIISaver" paths I told myself I’d clean up later.
Then Reverie came along. Reverie is a screensaver too — a meditative roulette-curve thing that I’d been wanting to build for ages — and on its first release through Release Manager it surfaced six distinct bugs in five distinct places.
I want to walk through these because they’re what convinced me. Each one, looked at in isolation, was a small thing. Looked at together, they were a pattern.
The build stage for swiftc apps was missing -emit-library for .saver bundles. (Screen savers are dylibs, not executables, because legacyScreenSaver loads them.) The package stage had a hard-coded reference to ASCIISaver-Installer.pkg in the asset upload path that I’d forgotten about. The install location was hard-coded to /Applications even for .saver bundles, which actually go to /Library/Screen Savers. The swiftc source-discovery method had a wildcard that didn’t recurse sub-directories, missing Reverie’s Sources/ layout. Process’s FileHandle.nullDevice for stdin wasn’t actually propagating to grandchild processes when I shelled out via a wrapper script, causing notarytool to hang waiting for input. And the inner .saver inside the produced pkg was being signed with the ad-hoc certificate by the first nested build, then the outer pkg was getting Developer ID Installer signing — so the package signature was right but the bundle inside it was wrong, and the user’s install would fail Gatekeeper.
I sat with that list for a while. None of these were architecture problems. They were details — little factual things about how codesign and notarytool and pkgbuild actually work, accumulated as Swift conditionals because every time I’d met one I’d added a branch. But the cumulative weight was an architecture problem. The reason these bugs kept appearing wasn’t that I was a careless coder. It was that I was reimplementing, in Swift, a domain that already had a perfectly good native vocabulary.
Here’s the thing I should have admitted years earlier: codesign and xcrun notarytool and pkgbuild and ditto and stapler are shell tools. Their inputs and outputs are designed for a shell. Their stdin handling is designed for a shell. Their exit codes are designed for a shell. Their timeout semantics, their environment variable conventions, their output formats — all of it assumes you’re calling them from a shell, not from a process spawning library inside a host language.
Calling them from Swift via Process/Pipe isn’t wrong, exactly. It works. But it’s a layer of indirection between the tool and the natural way to use the tool, and every layer of indirection is a place where bugs can live. The stdin-not-propagating-to-grandchildren bug, in particular, isn’t something a shell user would ever even meet. They’d redirect stdin from /dev/null once at the top of the script and the matter would be settled. In Swift, I’d been carefully constructing FileHandle.nullDevice objects and passing them as standardInput, which works for the immediate child but doesn’t inherit the way an honest < /dev/null redirect would, and Apple’s notarytool happens to launch a daemon that needs to inherit a real stdin descriptor or it hangs forever. I’d burned a literal day on that one.
There’s a version of this argument that goes “use the right tool for the job, ya numpty” and feels obvious in retrospect. There’s another version that’s subtler and worth dwelling on: I’d been treating “Swift is the native macOS language” as if it meant “Swift is the right tool for everything you do on macOS.” Those aren’t the same statement. Swift is wonderful for building the user-facing app — the window, the buttons, the catalogue, the audit reports, the settings. It is not particularly suited to orchestrating a sequence of shell-shaped tasks where every task already has a battle-tested shell idiom. I had been using it for both because it was the language I’d started in.
Once I’d framed the problem that way, the design wrote itself.
Release Manager would stay a Swift app. It would keep the user interface, the catalogue of apps and their configurations, the preflight checks (which need consistent reporting in the UI), the verification stages (which earn their keep precisely by being independent of the build), the EdDSA signing of the produced zip (which yields structured metadata that goes back into the catalogue), and the GitHub release upload. All of that is real Swift work, with state and UI and structured outputs that benefit from a real type system.
The build, signing, notarisation, stapling, and packaging would move out. Out into a Make include — one shared file called release.mk — that every project in the suite would include from its own minimal Makefile. The project Makefile would declare identity (bundle name, build system, frameworks, entitlements, embedded frameworks like Sparkle) and that’s it. release.mk would do the work, in shell, where every tool involved natively lives.
This was a non-trivial commitment. I had twenty-three apps in the catalogue. Each would need a Makefile. The shared release.mk would need to handle three build systems, single-target and multi-target installers, with-and-without embedded Sparkle, optional install-name renaming (JorvikReleaseManager.app → Jorvik Release Manager.app at install time, that kind of thing), alsoShipPkg dual-shipping, helper apps with their own entitlements. None of that complexity was new — it all already existed, in Release Manager, in Swift — but I’d be retranslating it to shell. With migration mistakes likely.
So I phased it. Phase 1: build the shared infrastructure with Reverie as the test subject — the simplest case, single-target, no embedded frameworks, no install-name rename. If release.mk could ship Reverie cleanly through the terminal end-to-end — produce a signed, notarised, stapled .pkg — the foundation was sound.
Phase 1.5: validate against MenuTidy. MenuTidy is a swiftc app with embedded Sparkle and alsoShipPkg-driven dual-ship. It was the next-hardest case, and I deliberately put it before any Release Manager wiring. The Sparkle leaves-first signing recursion is the part of the recipe with the most opinionated detail; if it didn’t work for MenuTidy there was no point continuing. I ran it in shadow-mode — the existing Swift pipeline in Release Manager and the new gmake release target, side by side — and diff’d the produced bundles modulo timestamps and signature randomness. They matched. That was the moment I knew the rest was just typing.
Phase 2: add the dispatcher to Release Manager, gate it on a per-app boolean. Apps with the flag use the new path; everything else stays on the legacy Swift code, untouched. This let me migrate one app at a time without holding the entire suite’s release cadence hostage to the rewrite.
Phase 3: roll through the suite. Twelve menu-bar utilities first, then the news reader, then the calendar app, then the Xcode-based internal tools, then ASCII Saver as the final boss. Each migration took thirty minutes and surfaced precisely zero new bugs in release.mk after MenuTidy. That itself was a quiet vindication: the cases I’d been adding hardcoded Swift branches for had all been varieties of the same handful of orthogonal questions (build system × framework embedding × package format × multi-target). Once those were composable variables in a shared file, nothing was special anymore.
ASCII Saver did need new shape in release.mk, because its multi-component productbuild flow with the camera-agent helper had no analogue in the simpler cases. I added a HELPER_TARGETS variable that’s a list of colon-delimited records (one per helper: target name, product name, entitlements file, package identifier, package filename) and let the Make include loop over them in build, stamp, sign, notarise, staple, and pkgbuild. That was the only real extension. The fact that I needed to add it for one app and zero others did make me wonder, briefly, whether ASCII Saver was just structurally weird — but the answer is that screen savers with helpers are a real pattern (any future saver that needs camera or microphone access will need the same shape), and the variable now exists for the next one.
Phase 4: delete the legacy Swift code. This is the part I’d been looking forward to.
Phase 4 took out roughly 1,100 lines from PipelineEngine.swift — buildXcode, buildSPM, buildSwiftc, buildMake, the entire executeSign framework-recursion loop, executeNotarise, executeStaple, executePackage, packageZip, packagePkg, buildSingleAppPkg, the shared notariseAndStaple helper that had been factored out at one point. Plus the orphaned helpers that had only ever been called from those (bundleInstallRoot, SwiftcSourceResolution, the swiftc source drift detector). And then a follow-up pass took out the catalogue fields the Swift code had been the only consumer of (xcodeProject, xcodeScheme, xcodeTarget, swiftcSourceFiles, swiftcFrameworks) along with the catalogue editor sections that used them.
Release Manager went from about 1,600 lines of pipeline code to about 770. The shared release.mk is around 430 lines. The per-project Makefiles are 15 to 30 lines each. Net: less code, more capability.
But honestly the line count isn’t the part I care about. The part I care about is what kind of code it is.
The code that’s left in Release Manager is the code that earns its keep by being Swift. The catalogue is a typed model with structured persistence. The verification stages run independent inspections of the produced artefacts and catch lies the build path might tell — if release.mk somehow shipped a bundle with the wrong bundle ID, Verify Build would reject it before anything got pushed to GitHub. The EdDSA signing produces structured metadata that flows back into the catalogue, where the appcast generator reads it. The release stage prompts the user to confirm GitHub repo creation if needed, integrates with gh’s output for asset uploads, writes back to the catalogue. All of that benefits from a real type system, async/await, and SwiftUI. None of it would be better in shell.
The code that left is the code that was always shell-shaped. It’s now in a place where it can use shell idioms freely. set -eu -o pipefail. .DELETE_ON_ERROR. Quoting paths once, at the recipe level, instead of escaping them inside Swift string interpolations. Real < /dev/null redirects that work the way they’re documented to work. for FW in $(EMBEDDED_FRAMEWORKS); do ... done instead of a SwiftUI-style enumerator over a Sequence<String>.
It feels appropriate, in a way that’s hard to describe without sounding mystical about it. The right tool for the job, sitting in the place where it’s most natural to read.
The line-count thing is satisfying. The structural thing is more important.
When I find a bug or an edge case in release.mk now, I fix it in release.mk, push, and it’s live across all twenty-three apps on the next build. No Release Manager rebuild. No notarisation. No reinstall. No fan-out to twenty-three repositories. One commit to the shared Make include, and every project benefits.
This sounds obvious when stated. In the old monolithic-Swift architecture, every release-pipeline fix required a Release Manager rebuild and reinstall, because the fix lived inside the Mac app. If the fix was the kind of thing that surfaces during a release attempt — which most are — you’d be in the awkward position of trying to ship a fixed Release Manager with the broken Release Manager. I’d become accustomed to the dance: notice the bug, fix it, ship a Release Manager point release, install it, retry the original release. Half my release-related bugs took a multi-rebuild loop to land.
That’s gone now. I felt the shape of the change most acutely when I was migrating ASCII Saver and discovered four separate edge cases in release.mk — nested helper signing, bare Mach-O signing for ActiveSpace’s switch_helper, stale _BuildOutput cleanup, install-name copy-not-rename. All four were release.mk patches, pushed within fifteen minutes of each other. None of them required Release Manager to be touched. They just worked, on the next build, for every app at once.
I think this is the structural lesson of the whole exercise. Not “Swift is the wrong tool for orchestration,” though I do believe that. The deeper one: give the rapidly-iterating part of a system the freedom to iterate. The build pipeline is the part of any release toolchain that picks up the most edge cases over time, because every new app you add brings new edge cases. Putting it inside the Mac app you ship through that very pipeline created an iteration loop that was at minimum a Release Manager rebuild long. Putting it in a Make include cloned alongside every project shrunk that loop to whatever git push takes.
I think I would have arrived here faster if I’d been less attached to the idea of Release Manager as “a Mac app.” The user-facing parts — the catalogue browser, the per-app status, the verify stages, the audit dashboard, the Build & Release / Finalise two-button workflow — absolutely are a Mac app. The pipeline plumbing isn’t. It just lived inside the Mac app because it had nowhere else to go, and once it was there, it was easier to keep adding to it than to extract it.
The temptation to keep adding to a monolith is strong, especially when the monolith is yours. Every new branch you add feels like progress — another case handled, another app supported — even when each branch is making the whole structure more brittle. The signal that you’ve crossed the line isn’t any single bug; it’s the pattern of bugs. If the same kind of bug keeps coming up in different shapes, your abstraction is wrong.
Six bugs in one Reverie release was that pattern. It just took me a while to recognise it as a pattern rather than as bad luck.
The pipeline ships every app in the suite cleanly. Release Manager is smaller, simpler, and visibly does less. The shared release.mk does most of the actual work and lives in a sibling repository — PerpetualBeta/jorvik-release, public, open source, available to anyone whose Mac app distribution flow has the same shape as mine. I’m honestly not expecting many takers; the audience for “a shared Make include for orchestrating macOS code signing and notarisation” is small. But it’s there. And when I add the next Jorvik app, its release pipeline will be a fifteen-line file that says “here’s my identity, please ship me,” and the rest will happen automatically, in shell, where it always belonged.
Sometimes the most useful thing you can do for a piece of software is give some of its responsibility to a different piece of software, written in a language better suited to that responsibility. The total amount of work being done is the same. The amount being done well goes up.