Skip to content

Sign and notarize the macOS bundle - #70

Open
landsman wants to merge 4 commits into
mainfrom
macos-codesign
Open

Sign and notarize the macOS bundle#70
landsman wants to merge 4 commits into
mainfrom
macos-codesign

Conversation

@landsman

@landsman landsman commented Aug 3, 2026

Copy link
Copy Markdown
Owner

A newly installed Adminer Desktop.app did not open, and left no log — the last
line predates the install, because frankenphp never started. Apple's own checker
says why:

$ syspolicy_check distribution "/Applications/Adminer Desktop.app"
Codesign Error — Severity: Fatal
  Code has no resources but signature indicates they must be present.

make bundle assembled Contents/ and stopped. The executable carried the Go
linker's ad-hoc signature, which claims sealed resources — but nothing ever
signed the bundle, so there was no _CodeSignature to hold them. Gatekeeper
reads that as damaged rather than unsigned, and a damaged app is killed outright
where an unsigned one is at least offered as right-click → Open.

before after, ad-hoc after, with the cert
Codesign Error "no resources…" Fatal gone gone
Adhoc Signed App Warning Warning gone
Notary Ticket Missing Fatal Fatal gone

What this does

  • Makefilebundle signs, nested code first and no --deep. Ad-hoc
    when no identity is passed, so a local build and a secretless CI run keep
    working and differ from a release only in which identity got attached. zip
    moves to ditto (what notarytool documents; it preserves the symlinks and
    xattrs the seal covers). New notarize target submits, staples and re-zips.
  • cli/keychain.sh — the certificate import, out of the workflow so it can
    be run and linted off CI. qa globs cli/*.sh into shellcheck.
  • .github/workflows/build.yml — calls that script and notarizes, both
    guarded on their secret being present.
  • app/php/desktop.inipcre.jit = 0. Hardened runtime is mandatory for
    notarization and it refuses PCRE's executable-memory allocation. The lost
    optimisation is not the problem; the warning it prints is — display_errors
    puts it on the page, headers go out before session_start(), and the app dies
    on Undefined constant Adminer\SID before drawing anything. Caught by booting
    the signed bundle, not by reading the diff.

Before this can notarize: six secrets

Put them on the macos-arm64 environment (Settings → Environments), not
at repository level. This repo is public and claude-mentions.yml triggers on
issue_comment, which fires for a comment from anyone — an environment secret
is readable only by a job that declares that environment, so it cannot reach
them. A repository secret could.

Needs an Apple Developer Program membership ($99/yr); there is no free tier.

Secret Value
MACOS_CERT_P12 base64 -i DeveloperID.p12 | pbcopy
MACOS_CERT_PASSWORD the password you set when exporting that .p12
MACOS_SIGN_ID Developer ID Application: NAME (TEAMID), verbatim from security find-identity -v -p codesigning
NOTARY_KEY base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
NOTARY_KEY_ID the XXXXXXXXXX from that filename
NOTARY_ISSUER the Issuer ID (a UUID) above the key list in App Store Connect

Where the two files come from:

  1. Keychain Access → Certificate Assistant → Request a Certificate from a
    Certificate Authority
    → save to disk.
  2. developer.apple.com → Certificates → Developer ID Application — not "Mac
    Development", which notarization rejects → upload the CSR → download the
    .cer → double-click to install.
  3. Keychain Access → My Certificates → right-click the entry → Export with
    its private key
    .p12, set a password. That pair is MACOS_CERT_P12 and
    MACOS_CERT_PASSWORD.
  4. App Store Connect → Users and Access → Integrations → Keys → new key with the
    Developer ID role. The .p8 downloads exactly once.

Also worth doing first: all three environments currently have no protection
rules (gh api repos/{owner}/{repo}/environmentsprotection: []). Add a
deployment branch rule limiting macos-arm64 to main and tags, so a dispatch
from an arbitrary branch carrying a modified workflow cannot read the
certificate.

Doing it locally

For your own machine you need none of cli/keychain.sh — double-click the
.cer once and codesign finds it in your login keychain forever:

make notarize MACOS_SIGN_ID="Developer ID Application: NAME (TEAMID)" \
  NOTARY_KEY=AuthKey_XXXXXXXXXX.p8 NOTARY_KEY_ID=XXXXXXXXXX NOTARY_ISSUER=<uuid>

The script is for rehearsing the CI path before spending a runner minute on it,
or on a machine the certificate is not installed on. It takes the .p12 as a
path as well as base64, so locally that is just:

MACOS_CERT_P12=DeveloperID.p12 MACOS_CERT_PASSWORD=... make keychain
make keychain-clean   # afterwards

It appends to the keychain search list rather than using
security default-keychain -s, which every CI recipe for this reaches for and
which would point your default away from login and leave it there if the
script exited early. codesign searches the whole list, so appending was always
enough — that is what makes one script correct in both places.

Checked

make qa green (shellcheck now covers cli/*.sh), make security clean.
make zip locally, then booting the signed bundle headless — which is what
caught the PCRE fatal that reading the diff would not have. A copy extracted from
the zip with a quarantine xattr applied now passes codesign --verify --strict,
and the fatal codesign error is gone from syspolicy_check.

cli/keychain.sh exercised with a throwaway self-signed .p12, both input
shapes: the login keychain stays present and default, an unusable certificate
fails with a message rather than at codesign time minutes later, and the trap
leaves nothing in the search list when it does.

Not verifiable here: notarization, and the script's success path, both of which
need the real certificate. Once the secrets exist, dispatch with build: true,
then on a machine that has never seen the app — spctl -a -vvv should say
source=Notarized Developer ID, and stapler validate should find the ticket.
Double-click with no dialog is the pass.

Out of scope

Windows SmartScreen — same problem, dearer certificate, nobody has asked.

The macOS bundle is about to be signed with the hardened runtime, which
notarization requires and which refuses PCRE's executable-memory allocation.
The lost optimisation is not the problem -- PCRE falls back and matches fine.
The warning it prints is: display_errors puts it on the page, so headers are
sent before adminer reaches session_start(), the session never starts, and the
app dies on "Undefined constant Adminer\SID" before drawing anything.

The alternative is a com.apple.security.cs.allow-jit entitlement, which buys
back a micro-optimisation nothing here notices -- every regex in a request
costs less than the database round trip it precedes -- by letting the whole
process map executable memory. Not a trade worth making.

Set everywhere rather than on macOS alone: one ini is loaded on all three
platforms and a per-OS override is more machinery than the setting is worth.
… unsigned

A newly installed Adminer Desktop.app did not open on macOS and left no log --
frankenphp never started, so there was nothing to write one. The bundle carried
the go linker's ad-hoc signature on its executable but had no _CodeSignature of
its own, so that signature claimed sealed resources that did not exist. Apple's
own checker calls it a fatal codesign error, and Gatekeeper acts on it: a
damaged app is killed outright, where an unsigned one would at least have been
offered as right-click > Open.

So `bundle` now signs. Ad-hoc when no identity is passed, which is still a
sealed and therefore valid app -- it just names no developer -- so a local build
and a secretless CI run keep working and differ from a release only in which
identity got attached.

ditto rather than zip(1) for the archive: it is what notarytool documents and it
preserves the symlinks and extended attributes the signature was computed over.
Named once because notarize has to build the same archive again after stapling.
Signing alone leaves the app unidentified: still a warning, still right-click >
Open. Apple's notary service is what removes it, and stapling the ticket into
the bundle is what makes a first launch work offline -- without it Gatekeeper
asks Apple, and a machine behind a filter refuses the app.

codesign, notarytool, stapler and ditto all ship with the Xcode command line
tools already on the runner, so this adds no action and no install. Only a
certificate, which has to come from a paid membership and so lives in secrets.

Both steps are guarded on the secret rather than switched on in the matrix, so a
fork -- and this repo before the certificate exists -- still builds and uploads
an ad-hoc signed zip instead of dying on a missing credential. The cost is that
the phony bundle > zip chain runs twice; fold notarize into matrix.package once
the secrets are permanent.

The secrets belong on the per-platform environment and nowhere else. This repo
is public and claude-mentions.yml triggers on issue_comment, which fires for a
comment from anyone; an environment secret is readable only by a job declaring
that environment, so it cannot reach them. A repo-level secret could.
…ff CI

macOS runners bill at 10x and the three-platform build is manual dispatch
precisely so it is not iterated against -- but the signing path was shell in a
YAML `run:` block, which is the one place it could neither be run nor linted
before a dispatch found out. `make keychain` is the same code on a laptop, and
`qa` globs cli/*.sh into shellcheck so it is checked like everything else.

That is only worth having if it is safe to run here, and the recipe everyone
copies is not: `security default-keychain -s` points the default away from login
and leaves it there if the script exits early. On an ephemeral runner that costs
nothing. codesign searches the whole keychain list, so appending to the list was
always enough, and now login stays both present and default.

Two things the inline version could not do. It takes the .p12 as a path or as
base64, so neither side has to shape its input for the other. And it fails when
`find-identity` reports no Developer ID Application -- an expired certificate, or
a Mac Development one, imports perfectly happily and is only discovered minutes
later inside `make bundle`, where codesign has far less to say about why. A trap
unwinds the keychain on any failure so a wrong password leaves nothing behind.
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.

1 participant