Damaged Is Not Unsigned: One Command Separates a Config Bug From a $99 Apple Account
macOS refusing to open your app has two completely different causes that produce almost the same complaint, and the exit code of a single spctl run tells you which one you are holding.
29 Aug 2026

A stranger downloads your app, double-clicks it, and macOS says: "Marlin is damaged and can't be opened. You should move it to the Trash."
There are two buttons. Done, and Move to Trash. Move to Trash is the highlighted one, so it is what the return key presses. There is no Open Anyway. The operating system has told your user that the file they just downloaded is corrupt, and has offered to help them delete it.
That is not the unsigned-app dialog. The unsigned-app dialog says the developer cannot be verified, and it has a documented way through: System Settings, Privacy and Security, Open Anyway. It is annoying and it is a consent path. The user can decide to trust you.
The damaged dialog does not offer that decision. Gatekeeper rejects this bundle a step earlier than the trust question, so the documented escape hatch is aimed at a verdict the file never reaches. macOS does not think trust is the issue here.
Marlin, the terminal I build, shipped the damaged dialog to every macOS user for four months. The whole time, the project's own notes and its README described the problem as being unsigned. Every hour spent on it went into trying to buy an Apple Developer ID. The actual cause was a missing key in a JSON file, the fix cost nothing, and one command would have told us that on day one.
Absence and corruption are different failures
A code signature is a cryptographic seal over an application, and on a macOS bundle it covers two separate things: the executable itself, and a manifest of every other file in the bundle, which Apple calls sealed resources. The manifest is what lets the operating system notice that somebody swapped an image or a script inside the app after it was signed.
On Apple silicon, the Mach-O linker attaches an ad-hoc signature to every binary it produces, automatically, whether you asked for it or not. Ad-hoc means there is no identity behind it: it seals the bytes, it says nothing about who made them. You do not opt into this and you probably do not know it happened.
That linker signature covers the executable and nothing else. No sealed resources, because the linker does not know it is inside an app bundle.
Now put those two facts together. The bundle carries a signature. That signature declares a resource manifest. The manifest is not there. macOS is not looking at an unsigned app and deciding whether to trust it. It is looking at a signature that promises something the bundle cannot produce. That is exactly the fingerprint of a file mangled in transit, so the verdict is corruption rather than absence of trust. Corruption has no consent path, because consenting to a corrupt file is not a thing a user should be offered.
Marlin's tauri.conf.json never set bundle.macOS.signingIdentity. When that key is absent, tauri-bundler skips its signing branch entirely, so codesign never runs at all. Nobody removed anything. The step simply did not happen, and what shipped was whatever the linker left behind.
An unsigned app is a trust decision the user is allowed to make. A malformed signature is a corruption verdict they are not. Everything below follows from that one sentence.
The command, and read the exit code rather than the message
This is the part that transfers. Both failures look identical from outside: the user says macOS will not open your app. The support thread says signing. The fix is either five minutes or ninety-nine dollars, and guessing between them cost this project four months.
spctl -a -vvv -t exec /Applications/Marlin.app
echo $?
The message is useful. The exit code is the discriminator.
Exit 1 means the bundle is malformed. The message that comes with it is "code has no resources but signature indicates they must be present", which is the sentence describing exactly the state above. Exit 3 means the ordinary unidentified developer rejection, with the message "rejected", and that is the one the user can click through.
Same command, same-looking symptom, two different budgets. Run the discriminator before you buy the certificate.
What the shipped file actually reported
I ran this on 29 Aug 2026 against the artefact people were really downloading, not against a local build: Marlin_0.1.1_aarch64.dmg, sha256 725a0b76be00c0941266da81c6c3400041bb41fd579997d5f8c6604ed8fa6fcf, pulled fresh from the GitHub releases page onto macOS, Darwin 25.5.0, Apple silicon.
codesign -dvvv on the app inside it reports, exactly:
flags=0x20002(adhoc,linker-signed)
Signature=adhoc
Info.plist=not bound
Sealed Resources=none
There is no Contents/_CodeSignature directory in the bundle at all. Sealed Resources=none is the tell, and linker-signed in the flags is the confession: this signature came from the linker, not from codesign.
Then three states, three runs of spctl -a -vvv -t exec:
| State | Exit | Message |
|---|---|---|
| As downloaded | 1 | code has no resources but signature indicates they must be present |
After xattr -dr com.apple.quarantine | 1 | identical, unchanged |
After codesign --force --deep --sign - | 3 | rejected |
The third row is the proof. Re-signing changes nothing about who made the app, who vouches for it, or whether Apple has ever seen it. It changes only whether the signature is well formed. After it, codesign -dvvv reports Sealed Resources version=2 rules=13 files=1 and Info.plist entries=16, and a Contents/_CodeSignature/CodeResources file exists where there was nothing before. The verdict moves from corrupt to untrusted, and untrusted is a state the user is permitted to override.
That command is a diagnostic, not the fix. Apple's own guidance is explicit that --deep is the wrong way to sign a real product, because a complex bundle has to be signed from the inside out. It is in the table because it isolates the variable, not because you should ship it.
The advice everybody gives you, and why it cannot work
Search the damaged dialog and you will find one answer, everywhere, in every forum thread and half the READMEs of small open source Mac apps:
xattr -dr com.apple.quarantine /Applications/Marlin.app
Measured here it does nothing at all. Exit 1 before. Exit 1 after. Not improved, not partly improved, unchanged.
It cannot work, and the reason is worth holding onto, because it is the general shape of bad debugging advice. The quarantine attribute is a flag written onto a file by whatever downloaded it, and it is what makes Gatekeeper prompt on first launch. The code signature is a separate check on the contents of the bundle. They are two independent gates. Removing the quarantine flag suppresses the prompt; it does not conjure a resource manifest into a bundle that never had one.
Be fair to the advice. It is correct, and it works, for the case it was written for. That case is a well-formed app with no Apple identity behind it, where the only thing in the way is the Gatekeeper prompt. It is common, and the advice has rescued a lot of people. It is simply aimed at a different failure, and nothing about the way it gets passed around tells you which failure you have.
What that costs is worse than a wasted minute. A user runs the fix the internet promises will work. It does not work. The only conclusion left to them is that the app really is broken, or really is malware. The advice fails in a way that confirms the scariest reading.
There is a corollary worth knowing if you ship a self-updater: quarantine is attached by the downloading application, so an app fetching its own disk image never sets it. The xattr dance is a first-install problem and never an update problem.
The fix is one key, and it does not buy trust
{
"bundle": {
"macOS": {
"signingIdentity": "-"
}
}
}
The - is the ad-hoc pseudo-identity. It makes tauri-bundler actually invoke codesign instead of skipping the step, and codesign seals the bundle resources properly. No Apple account, no certificate, no notarisation, no money.
Say plainly what it does not do. It does not make the app trusted. Nobody has vouched for the software and the operating system will keep saying so. What it does is move the app out of a verdict the user cannot argue with and into one they can. From "this file is corrupt" to "nobody has signed this". For an open source project with no Developer ID, the second is the honest state, and it is the only one with a door in it.
Free is not the same as trusted, and the goal was never trust. The goal was to stop lying to the user about which problem they have.
The config key is not the fix, the gate is
Four months is not how long the bug took to fix. It is how long it took to notice. Nothing in the release pipeline ever looked at the thing it produced, so a build that came out corrupt looked exactly like a build that came out fine. I have written before about what software quality actually is, and how little of it one engineer controls. The piece that does stay in your hands is the checking, and nothing here was checking.
Marlin's release workflow now runs this on the macOS leg:
codesign -dv --verbose=2 "$APP" 2>&1 | tee sig.txt
codesign --verify --strict --verbose=2 "$APP"
if grep -q "Sealed Resources=none" sig.txt; then
echo "::error::linker-signed only, so macOS will call this damaged rather than unsigned"
exit 1
fi
spctl -a -vvv -t exec "$APP" 2>&1 || true
Two decisions in there are the interesting part.
spctl is run and logged, and deliberately not gated on. It rejects anything unnotarised and it always will. A build that fails whenever spctl rejects it is a test that can never pass, and a test that can never pass gets deleted or ignored within a month. What matters is not whether spctl rejected the bundle, but which rejection it gave. So the sentence stays in the build log where a human can read it, instead of being flattened into a red cross that means nothing.
The other decision is that the key lives in tauri.conf.json and not in a CI environment variable. It would have been easy to set APPLE_SIGNING_IDENTITY in the workflow and move on. It is the same instinct that puts the pre-commit checks in a git hook on every machine rather than only in the pipeline. Then the README, which tells people to clone and build from source, would be instructions for producing a broken bundle. A build that only comes out correct inside your CI is a trap laid for whoever follows your own documentation. The people who build from source are also the people you least want to hand a corrupt binary.
A check that runs where only your pipeline runs is not a check, it is a difference between your machine and everyone else's.
Where this actually stands
The fix landed on 22 Aug 2026. As I write this on 29 Aug 2026, it has not reached a single downloadable file. No tag has gone through the release pipeline since. The artefact sitting on the releases page is still the broken one built on 16 Aug, and it has been downloaded three times.
Three people, at most, have had a computer tell them my software was damaged and should be binned. Knowing the cause and shipping the cure are separate events with a gap between them, and users live in the gap, not in the commit log. I know exactly what is wrong, I know exactly what fixes it, the fix is on main, and the thing a stranger can download today is still broken.
So the rule I would defend in a post-mortem is not about signing at all. It is what owning a project end to end turns into once there is a release page in front of it. The commit is not the deliverable. The file is. Download your own release artefact, from the page a stranger uses, and run the check against that file. Not against a local build, not against the repository, not against what you believe you published. Everything above came from doing that once, four months late.
Production-Ready Systems with LLMs and Agents
A live Maven cohort, 5 October to 2 November: eight 90-minute sessions where you build LLM and agent systems that survive real traffic, real cost and real failure. Tuesdays and Thursdays, 7:30 to 9:00pm London.
Cohort 2 starts 5 October. Eight live sessions, $1,500.
View the live cohort