Loading page
Digitalgrub
ServerDesk app icon: a gold terminal prompt on a dark background
Build notes

Notarizing a Mac app: one error message, four separate bugs

5 min readNot released yet

ServerDesk is a macOS app I have been building. It is an SSH client with guardrails: saved server profiles, a command palette, and a confirmation step before anything dangerous runs.

It is not released, and this post is not really about the app. It is about the days I spent trying to produce one file: a .dmg that a person could download and open without macOS warning them about it.

My first attempt failed with this:

A timestamp was expected but was not found

So I went looking for a timestamp problem. That was the mistake. There were four problems, and only one of them had anything to do with timestamps.

Bug one

The timestamp server was throttling me

This one was real. When Xcode exports an app it does not sign one thing, it signs several in quick succession: the app, its frameworks, its helpers. Each signature asks Apple's timestamp server for a timestamp. Ask too many times too fast and it starts refusing.

What made it confusing is that signing a single binary by hand worked every time. The failure only appeared during a full export, which made it look like a problem with my project rather than a rate limit.

There is no clever fix. It is a network service having a bad moment. I put a retry around the export step and moved on.

Bug two

The real error was being eaten

Then I started getting this, which is not a signing error at all:

invalid byte sequence in US-ASCII

That is Ruby. The build was passing its output through xcpretty, which makes Xcode's output readable, and xcpretty was crashing on a character it could not decode because my shell locale was not set to UTF-8.

The build itself was fine. The tool reading the build log died, and took the result with it. So a successful build reported as a failure, with an error about text encoding, in the middle of a signing problem. Forcing the locale to UTF-8 fixed it.

Bug three

I broke it by asking for more detail

Next run: Apple accepted the notarization. The app was approved. And then the script crashed anyway, with a JSON parsing error.

Because I was debugging, I had turned on verbose output to see more. Verbose made Apple's notarytool print extra lines into the same stream that fastlane was trying to read as JSON. The extra lines were not JSON, so the parse failed.

The notarization had already succeeded. The only thing that failed was the part reading the reply, and it failed because of a flag I added to help me debug. Turning verbose off fixed it.

Bug four

I notarized a disk image I never signed

By this point the app was signed, notarized and approved. The disk image was notarized and stapled too. And mounting it still produced a complaint about a missing signature.

Because there are two separate things to sign. I had signed the app inside the disk image, and I had notarized the disk image, but I had never signed the disk image itself. Notarizing something is not the same as signing it, and stapling the result to an unsigned container does not make it valid.

The order that works is: sign, then notarize, then staple. At both layers.

What I would do differently

Stop reading the top line

All four of these arrived looking like the same thing. Some version of “signing failed.” One was a rate limit, one was a text encoding crash in an unrelated tool, one was self-inflicted by a debug flag, and one was a step I had genuinely skipped. The first error message was pointing at the wrong thing four times in a row.

What actually worked was testing one layer at a time, on its own, outside the pipeline. Call the timestamp server directly and see if it answers. Sign one throwaway binary and see if that works. Check the disk image separately from the app inside it.

The check that mattered most was the last one. A file you have built yourself is not treated the same way as a file someone downloads, because downloads carry a quarantine flag and get inspected more carefully. Testing my own copy told me nothing useful. I had to mark a copy as quarantined first, so the machine would treat it like something that arrived from the internet, and only then did I find out what a real user would see.

Where it is now

One command, start to finish.

The release is one command now. It builds, signs, notarizes, signs the disk image, notarizes that, staples it, and produces a file that opens cleanly on a machine that has never seen it before, with no network connection.

I am not sharing ServerDesk yet. It is still the thing I use to manage my own servers, and I want to live with it a while longer before handing it to anyone else. But the part I was dreading is finished, and it turned out the hard part was never the signing. It was believing the error message.

Let’s Build the Future Together.