Skip to content

fix: verify and retry the notary staple instead of assuming it worked - #98

Open
yepzdk wants to merge 2 commits into
mainfrom
feature/issue-97-staple-retry
Open

fix: verify and retry the notary staple instead of assuming it worked#98
yepzdk wants to merge 2 commits into
mainfrom
feature/issue-97-staple-retry

Conversation

@yepzdk

@yepzdk yepzdk commented Aug 20, 2026

Copy link
Copy Markdown
Owner

webwrap update --sign … --notarize … printed its complete success sequence — Notarized. Stapling ticket…, ✓ Updated — while leaving the app unstapled. Found while signing WebReader for distribution to another Mac.

Notarization itself had genuinely succeeded (notarytool historystatus: Accepted). Stapling by hand ~2 minutes later worked first try: Apple accepted at 13:10:03Z, and the secure timestamp on the successful manual staple was 13:12:14Z. Apple distributes the ticket asynchronously, so a staple attempted the instant notarytool --wait returns often can't find it yet.

Why it mattered

A stapled ticket is what lets Gatekeeper approve the app offline. Without one the recipient's Mac must fetch it from Apple at first launch and refuses the app if it can't — precisely the audience the signing was for.

The trap was worse than a plain silent failure: on the machine that signed the app, spctl -a -vvv reports accepted — source=Notarized Developer ID even with no ticket, because assessments are cached per developer. So the obvious way to double-check agreed with the bug. xcrun stapler validate is the only truthful check, and nothing ran it.

Root cause

AppBuilder.swift:489-490 — one attempt, no verification, diagnostics discarded:

print("Notarized. Stapling ticket…")
try run("/usr/bin/xcrun", ["stapler", "staple", appPath], quiet: true)

run does throw on a non-zero exit, so the observed silent success means stapler exited 0 without attaching a ticket. The code couldn't tell the difference — and quiet: true sent both streams to nullDevice, so whatever stapler said about why was unrecoverable.

The fix

  • Always verify with stapler validate, never trust the staple's exit status. This is the fix — trusting exit 0 leaves the bug reproducible.
  • Retry ~6 attempts over ~1 minute (immediate, +2s, +5s, +10s, +20s, +30s), printing a line per wait so a slow run doesn't look hung.
  • On exhaustion, fail loudly and keep the bundle — it's notarized and fine locally; deleting it would waste an Apple round-trip. The message states the app IS notarized but NOT stapled, warns against trusting spctl, and gives the manual fix with the path quoted (app names contain spaces).

Retries are unconditional rather than classifying transient vs permanent failures: that would mean matching Apple's undocumented, unversioned message strings, and guessing wrong reintroduces this exact bug. The whole budget is a minute; a permanent failure just reports the honest error a minute later.

No Process injection seam was added — the thing worth testing (the schedule, the message) is pure and extractable without one, and a seam for a single call site would be a large diff for a test that asserts the implementation back at itself.

Verification

swift test — 368 tests, 0 failures (11 new: schedule shape/budget/monotonicity, and the message's wording, quoting, attempt count, and captured-output handling).

Measured the loop's real semantics with stapler validate against both bundle states:

Bundle validate Time
Stapled (WebReader) exit 0 404ms
Un-notarized exit 65 25ms

So the happy path returns on attempt one with no sleeping — no perceptible slowdown — and exit 65 is exactly the failure the old code ignored. Also confirmed a failed run leaves the bundle in place, still Developer ID signed with the hardened runtime.

Not verified end-to-end: a fresh real notarization. Submissions to appstoreconnect.apple.com/notary/v2/asp time out from this network (~61s, reproducibly), while apple.com returns 200 in 0.4s and notarytool history works — so something here blocks the submission endpoint specifically. That's environmental, not this change, but it means the full sign→notarize→staple round-trip should get one manual run before this is relied on.

Spotted but deferred

run(quiet: true) discards stderr for codesign too, so a signing failure surfaces only as /usr/bin/codesign exited with status 1 — losing "no identity found" and similar. Same defect class, but it shares a helper with the icon pipeline (11 call sites), so it's a wider blast radius and belongs in its own change rather than riding along in a notarization fix. sips/iconutil are deliberately different: their failures are caught and degrade to the default icon with a visible warning.

Closes #97

🤖 Generated with Claude Code

yepzdk and others added 2 commits August 20, 2026 07:45
`--notarize` could print its full success sequence while leaving the app
unstapled. Apple distributes the notary ticket asynchronously — observed
accepted at 13:10:03Z, first successful staple at 13:12:14Z — and webwrap
stapled once, immediately after `notarytool --wait` returned, with
`quiet: true` discarding stapler's explanation and nothing checking the result.

A missing stapled ticket is exactly the failure the signing was meant to
prevent: Gatekeeper falls back to fetching it online, so the app is refused on
an offline or restricted machine. The user ships it believing it's fine, and
`spctl` on the signing machine agrees with them — it reports "accepted" even
with no ticket, because assessments are cached per developer.

Stapling now runs up to six attempts over ~1 minute and every attempt is
verified with `stapler validate` rather than trusted on exit status: staple can
exit 0 without attaching a ticket, which is how this went unnoticed. On
exhaustion it throws a message saying the app IS notarized but NOT stapled,
with the copy-pasteable manual fix, and leaves the bundle in place.

Retries are unconditional rather than classifying transient vs permanent
failures, which would mean matching Apple's undocumented message strings —
guessing wrong there reintroduces this bug.

The schedule and the failure message are pure statics with unit tests, per the
repo's split; the stapling itself stays hand-verified.

Closes #97

Co-authored-by: Claude <noreply@anthropic.com>
…l its output

Two diagnostics fixes from review of #98.

The retry line said "Ticket not available yet", but the loop retries on ANY
failed verification — a malformed bundle fails validate with exit 66 for
reasons unrelated to Apple's distribution, and would have been told six times
over 67s that the ticket wasn't ready. Now "Staple not verified yet", with the
failure message hedging the same way and pointing at the captured output for
the real answer.

The two commands' text was also concatenated unlabelled, and since
runCapturingAll merges stdout and stderr there was no way to tell which
command said what. Now labelled with each command's exit status, which makes
the actual #97 failure legible: staple reports "The staple and validate action
worked!" with exit 0 while validate reports "does not have a ticket stapled to
it" with exit 65.

Labelling is a pure static so the formatting is tested, including that silent
commands are dropped rather than left as bare headers.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update --notarize can report success without stapling the ticket

1 participant