Notarisation is Not Notarisation

I have a 10-stage build pipeline that takes a macOS app from source code to a signed, notarised, published GitHub release. One of those stages is called “Notarise.” Another is called “Verify Notarisation.” A third is called “Sign.” A fourth is called “Verify Signing.” You’d think, with this level of ceremony, that the hardest part of shipping a macOS app would be the cryptographic bits.

It isn’t. The hardest part is getting the name right.

The naming problem

Here’s a thing that seems like it should be simple: when you build a macOS app in Xcode, the output has a name. That name comes from the PRODUCT_NAME build setting in your Xcode project. For the Jorvik Release Manager, that setting is JorvikReleaseManager — no spaces, because Xcode uses this value for the executable name inside the bundle, and executables with spaces in their names are an invitation to debugging sessions you didn’t ask for.

So Xcode builds JorvikReleaseManager.app. That’s the bundle sitting in the build output directory after xcodebuild finishes. That’s what gets signed. That’s what gets notarised. That’s what gets stapled, verified, and zipped.

But the app installed in /Applications is called Jorvik Release Manager.app. With spaces. Because that’s the display name — the name humans see, the name that appears in Finder, the name that shows up in the Dock. macOS has a mechanism for this: CFBundleDisplayName in Info.plist can differ from CFBundleName, which can differ from the PRODUCT_NAME, which can differ from the executable name. The system is designed to handle this divergence.

The system handles it fine. My pipeline did not.

Where it broke

The Release Manager maintains a configuration for each app it manages. That configuration includes a productName field — the filename of the built bundle. For most apps, the Xcode PRODUCT_NAME matches the display name. ClipMan builds to ClipMan.app and installs as ClipMan.app. WindowPin builds to WindowPin.app and installs as WindowPin.app. No divergence. No problem.

For four apps — Jorvik Release Manager, Jorvik Dashboard, Jorvik Notes Editor, and Jorvik Web Editor — the names differ. The Xcode projects produce bundles without spaces. The installed apps have spaces in their names. And productName was being used for everything: finding the build output, signing it, notarising it, packaging it into a zip, and checking whether the installed version matches the released version.

The system audit — the part of the Release Manager that checks whether your locally installed apps match what you’ve released to GitHub — was the first thing to break visibly. It reported that the Release Manager was “N/A” for the path check, meaning it couldn’t find the running process. The app was running. It was running from /Applications. But the audit was looking for a process path containing JorvikReleaseManager.app, and the actual path contained Jorvik Release Manager.app.

The version check worked, but only by accident. The code tries the exact product name first, and when that fails, it falls back to scanning every app in /Applications looking for a matching bundle identifier. That’s an expensive operation — reading the Info.plist of every installed application to find the one with the right CFBundleIdentifier — but it gets the right answer eventually. The audit showed green for version and red for path, which is the kind of half-correct result that makes you doubt everything.

The deeper problem

I initially fixed this by adding an installName field to the configuration — an optional override that specifies what the app is called in /Applications when it differs from the build output name. The audit was updated to check both names when looking for running processes, and to use the install name for path lookups.

That fixed the audit. But the next time I ran a full release pipeline, the zip uploaded to GitHub still contained JorvikReleaseManager.app. Because the package stage — the one that creates the distributable archive — was using productName to find the bundle in the build output directory, and that was correct. The bundle in the build directory really is called JorvikReleaseManager.app. That’s what Xcode built.

So someone downloads the release from GitHub, extracts the zip, and drags the app to /Applications. They get JorvikReleaseManager.app in their Applications folder. Not Jorvik Release Manager.app. The display name in Finder might show the right thing (thanks to CFBundleDisplayName), but the actual directory on disk has no spaces. ls /Applications tells one story; Finder tells another. And my audit, which checks the filesystem path, sees the one without spaces and reports a mismatch.

The fix was straightforward: before packaging, rename the bundle from its build name to its install name. The zip now contains Jorvik Release Manager.app, and that’s what users get when they extract it.

Why this is harder than it looks

The rename has to happen at exactly the right point in the pipeline. You can’t do it before signing, because the signing stage needs to find the bundle by its build name. You can’t do it before notarisation, because the notary service receives a zip of the signed bundle and the stapling step needs to find it again afterwards. You can’t do it before the verify stages, because they also use the build name.

The only safe place is at the start of the package stage — after everything has been signed, notarised, and stapled, but before the bundle gets zipped for distribution. At that point, the code signature is sealed inside the bundle. Renaming the outer .app directory doesn’t affect the signature, because the signature covers the bundle’s internal structure — the _CodeSignature directory, the executable, the resources — not the directory name.

I know this because I tested it, nervously, about six times before I was convinced.

The audit cascade

Fixing the packaging exposed another issue. The system audit has three checks per app: version, path, and source. The path check compares the running process’s full path against the expected install location. The source check compares the latest local git tag against the current version in the configuration.

The source check was using git describe --tags --abbrev=0 to find the latest tag. This command walks the commit graph backwards from HEAD and returns the first tag it finds — but “first” here means “closest ancestor by commit distance,” not “highest version number.” If you have tags v1.1.4 and v1.1.5 and v1.1.5 was created on a commit that’s a bit further from HEAD than v1.1.4, git describe returns v1.1.4.

This is technically correct behaviour. It’s also completely useless for what I was trying to do, which is find the most recent release. The fix was to switch to git tag -l 'v*' --sort=-v:refname | head -1, which sorts tags by semantic version and returns the highest one regardless of graph topology. A one-line change that I should have gotten right the first time.

App translocation

While debugging all of this, I encountered a related problem that has nothing to do with naming but everything to do with deployment.

One of the apps — Jorvik Notes Editor — was showing “Running from AppTranslocation” in the audit. I had launched it from /Applications. I was looking at it in /Applications. Finder agreed it was in /Applications. But ps showed it running from a path like /private/var/folders/..‌./AppTranslocation/..‌..

App Translocation is a macOS security feature. When you download an app from the internet via a browser, macOS tags it with a quarantine extended attribute — a metadata flag that says “this came from outside, treat it with suspicion.” When you launch a quarantined app, macOS doesn’t actually run it from where it sits. It silently copies it to a random read-only location and runs it from there. The app thinks it’s running from /Applications. The process table says otherwise.

The purpose is to prevent apps from modifying their own bundle contents at runtime, which is a legitimate attack vector. The implementation means that my audit, which checks the process path, correctly identifies the app as running from the wrong place — because it genuinely is.

The fix isn’t in code. You remove the quarantine attribute with a command like xattr -dr com.apple.quarantine /Applications/Whatever.app and relaunch. Or you download the release using a CLI tool like gh instead of a browser, which doesn’t add the quarantine flag in the first place. Or you use the Release Manager to build and install locally, which also doesn’t trigger quarantine.

But explaining this to someone who just downloaded your app and sees “Running from AppTranslocation” in a system audit? That’s a conversation I’d rather not have. The audit is right. The user is right. macOS is doing something invisible between them.

The callback problem

One more thing broke during this investigation. The Release Manager has a callback — onVersionReleased — that’s supposed to fire after a successful release and update the configuration with the new version number. The callback was defined. It was invoked at the right point in the release stage. It just wasn’t connected to anything.

The PipelineEngine had the callback property. The ReleaseManagerStore — the SwiftUI state management layer — created the engine and set up callbacks for logging and user prompts, but never wired the version callback. So after a release, the engine said “I just released version 1.1.7” into the void, and the store’s configuration still showed whatever version it had before.

The full pipeline had a workaround: after the engine finished, the store manually updated the version from the pipeline’s input parameters. But the single-stage “Release” mode — which lets you re-run just the release step — didn’t have this workaround. Run a single-stage release, and the configuration wouldn’t update. Run the audit immediately afterwards, and it would show a version mismatch because the config still thought the old version was current.

One line of code in init() to wire the callback. That’s all it took. But finding it required understanding why the version sometimes updated and sometimes didn’t, which required understanding the two different code paths for full pipeline versus single stage, which required reading code I’d written myself and somehow not noticed was incomplete.

The meta-lesson

I built the Release Manager to automate the tedious parts of shipping macOS apps. And it does. The pipeline handles code signing, notarisation, GitHub releases, and all the fiddly bits that used to take me around thirty minutes per app. It has saved me hours.

But the pipeline itself is software, and software has bugs, and the bugs in meta-tooling are particularly insidious because they affect everything downstream. A naming mismatch in the build pipeline doesn’t just break the Release Manager — it breaks the deployment of every app that has a display name different from its product name. A wrong git command doesn’t just affect one audit — it affects every app’s source status.

The reassuring thing is that the audit caught it. The system I built to verify consistency across my apps is the same system that revealed its own inconsistencies. It’s turtles all the way down, but at least the turtles are honest.

The Release Manager can now build, sign, notarise, package with the correct display name, release to GitHub, tag the local repository with the right version, update its own configuration, and verify the whole thing afterwards. It can also do all of this to itself, which I have now tested by shipping three releases in a row while fixing these issues.

The 10-stage pipeline is working. The audit is green. The names are right.

Getting here required fixing six files, adding one struct property, and learning more about macOS display names, App Translocation, and git describe than I ever expected to need. Shipping software is easy. Shipping it correctly, consistently, and verifiably? That’s the actual work.