Skip to content

feat: notarize releases and ship a .dmg, once there is a Developer ID - #43

Draft
ostapondo wants to merge 3 commits into
mainfrom
ci/developer-id-notarization
Draft

ostapondo wants to merge 3 commits into
mainfrom
ci/developer-id-notarization

Conversation

@ostapondo

Copy link
Copy Markdown
Owner

Why

scripts/release.sh has been able to notarize since it was written. It never
could from CI: the workflow set PLONK_SIGN_IDENTITY: Plonk Signing, which
forces the self-signed branch every time. This wires the other branch up.

The Gatekeeper warning is the visible half. The half that costs more is
invisible: TCC keys Accessibility and Screen Recording to the bundle's
designated requirement, and ours currently pins a self-signed root by its own
SHA-1.

identifier "dev.plonk.app" and certificate root = H"b8efe1c8…8021"

If that certificate is ever regenerated, lost, or lapses, every installed copy
drops both permissions with nothing on screen to explain it, and no copy can
update to anything again. A Developer ID pins Apple's root plus a team ID —
neither of which moves when a certificate is rotated.

Dormant until the secrets exist

Nothing changes until APPLE_DEVELOPER_ID_P12 is set. Without it the run is the
one that happens today: self-signed, no notarization, zip only, same
attestation. With it, the workflow switches paths by itself — no flag, nothing
to remember on release day.

Secret What it is
APPLE_DEVELOPER_ID_P12 Developer ID Application certificate and key, base64
APPLE_DEVELOPER_ID_PASSWORD its export password
APPLE_NOTARY_KEY App Store Connect .p8, base64
APPLE_NOTARY_KEY_ID the key's ten-character ID
APPLE_NOTARY_ISSUER the issuer UUID

An API key rather than an Apple ID and app-specific password: notarytool store-credentials cannot be answered on a runner, and a key is revocable on its
own without touching the account.

What it does

  • One import step covers both identities. A self-signed root still gets
    add-trusted-cert; a Developer ID does not, since inventing trust for a
    certificate that already has a real chain would be a lie about its origin.
  • The identity name is read back out of the keychain rather than guessed —
    a Developer ID carries an account and team that nothing here knows in advance.
  • notary() in release.sh picks the API key when it is there and the keychain
    profile otherwise, so the laptop path is untouched.
  • scripts/make-dmg.sh packs the image with hdiutil and nothing else. The
    usual create-dmg wrapper drives Finder over AppleScript for icon placement,
    which needs a logged-in session a runner does not have — and it would be the
    only build dependency in a repo that has none.
  • The image is built after stapling, so the bundle a user drags out carries
    its own ticket, and is then notarized and stapled itself, because the
    quarantine bit lands on the .dmg and that is what Gatekeeper reads first.
  • Both artifacts get a provenance attestation; only the zip did.
  • The notary key is written to RUNNER_TEMP, chmod 600, and removed in an
    always() step so a failed build does not leave it behind.

Verified locally

make-dmg.sh was run against the current self-signed build — the whole path
except the two Apple round trips, which need an account that does not exist yet:

  • image mounts, holds Plonk.app and an Applications symlink
  • codesign --verify --deep --strict passes on the app inside, and it still
    satisfies its designated requirement — ditto keeps the signature that
    cp -R would have stripped
  • the requirement inside the image is byte-identical to scripts/release-requirement
  • the image itself signs and verifies

lint.sh clean, shellcheck -s sh clean, workflow YAML parses, all ten app
steps ordered as intended.

Found and fixed on the way: left to itself codesign derives a disk image's
identifier from the filename and stops at the first dot, so Plonk-0.2.3.dmg
signed as Plonk-0 — a different identifier on every minor bump. Set to
dev.plonk.dmg explicitly.

Before this can be merged and used

This is why it is a draft.

  1. Apple Developer Program enrolment, $99/yr. Nothing else here is blocked
    on anything.
  2. scripts/release-requirement has to be updated in the same commit that
    adds the secrets. release.sh will refuse the build otherwise, by design and
    with a clear message. The new value is not knowable until the Team ID exists.
  3. It strands existing installs. A Developer ID signature is a different
    designated requirement, so copies signed with Plonk Signing will not accept
    the update and have to be reinstalled by hand, with both permissions granted
    again — exactly what 0.0.5 did. SECURITY.md already makes the argument for
    doing this early: the bill scales with how many people are running it, and
    the latest release currently has 2 downloads.
  4. Docs in the same commit. README.md says "signed, but not
    notarized
    ", and SECURITY.md explains there is no paid account. Both
    become false the moment this turns on, and AGENTS.md requires they be fixed
    alongside. Not done here, because doing it now would make them false in the
    other direction.
  5. CHANGELOG.md deliberately untouched. Nothing user-visible changes while
    the secrets are absent; the entry belongs to the release that flips it on.

Unrelated, but found while reading

The bundle signs with --options runtime and no entitlements at all
(SECURITY.md documents this and gives a command to check). Under the hardened
runtime, microphone access normally needs
com.apple.security.device.audio-input. Push-to-talk may be relying on
something that will not hold on a clean machine. Left out of this PR on purpose
— it changes runtime behaviour and would make the SECURITY.md claim false, so
it deserves its own change with a real test behind it, not a drive-by.

ostapondo and others added 2 commits August 9, 2026 13:20
release.sh has known how to notarize since it was written, but the workflow
pinned PLONK_SIGN_IDENTITY to "Plonk Signing", so the branch could never be
reached from CI. This wires the other path up and leaves it dormant: set
APPLE_DEVELOPER_ID_P12 and the release switches over on its own, unset it and
the run is byte-for-byte the one that happens today.

Notarization matters here for more than the Gatekeeper warning. TCC keys
Accessibility and Screen Recording to the designated requirement, and the
current one pins a self-signed root by its own SHA-1. Regenerate that
certificate, or let it lapse, and every installed copy loses both permissions
at once with nothing to say why. A Developer ID pins Apple's root and a team
ID, which do not move.

- notarytool credentials come from an App Store Connect key in CI, since
  store-credentials cannot be answered on a runner. The keychain-profile path
  a laptop uses is untouched, and release.sh takes whichever it was handed.
- The image is packed by hdiutil and nothing else, after stapling, so the
  bundle a user drags to Applications carries its own ticket. It is signed and
  notarized in its own right, because the quarantine bit lands on the .dmg.
- Both artifacts get a provenance attestation; only the zip did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review passes over the previous commit. Everything here came out of them.

- The requirement check now runs before notarization, not after. Apple is the
  slow and finite half of the script, and the first Developer ID release is
  precisely the build that fails this check — it should not spend a submission
  to find that out. Nothing about the check changed, only when it happens.
- The workflow header no longer claims there is "nothing to remember on release
  day". There is exactly one thing, and it now says what it is and where the
  build will stop.
- Release notes and the step summary name each artifact next to its own digest,
  and carry an attestation command for both. Publishing the zip's sha256 under
  a .dmg is worse than publishing none: someone checks it, sees a mismatch, and
  concludes the download was tampered with.
- make-dmg.sh no longer prints a digest. It was computed before release.sh
  staples the image, which rewrites it, so the log carried two hashes for one
  file and the first matched nothing.
- make-dmg.sh refuses when the app's signing authority is not the identity the
  image would be signed with. Each signature verifies on its own, so nothing
  downstream notices a Developer ID app wrapped in a self-signed image — and a
  run by hand does that by default.
- The image is now mounted and the bundle inside it verified, deep and strict
  and against the recorded requirement, the same round trip the zip already
  got. That an image mounts says nothing about whether what a user drags out
  of it will run.
- The publish step iterates a positional list rather than splitting a string,
  so the loops do not depend on the shell's word-splitting rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ostapondo

ostapondo commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Ran the review twice over f520085, at high and max. Seven findings, all
fixed in 8aad864. Two worth knowing about:

  • The requirement check ran after notarization. The first Developer ID
    release would have notarized, stapled, and only then exited 1 — burning a
    submission on a build that was always going to be rejected. Moved ahead of the
    Apple round trip.
  • The bundle inside the image was never verified. The zip gets extracted and
    checked before it ships; the image only got assessed as an image. Now mounted
    and checked the same way.

The rest: a digest printed before stapling rewrote the file, no guard against
wrapping a Developer ID app in a self-signed image, release notes carrying only
the zip's hash, and an unquoted $ARTIFACTS relying on word-splitting.

Both passes confirmed the extra .dmg can't divert the in-app updater
(Release.swift:93 filters to .zip) and that the self-signed path is
unchanged end to end.

make-dmg.sh was 52% comment, the highest in the repo, on a script of forty
lines. The style is the house one and most of it earns its place, but some of
it was explaining the product rather than the code.

- Dropped the header paragraph arguing why a .dmg is worth shipping at all.
  That belongs in the pull request, not above the script that builds it.
- Dropped the second explanation of ditto-versus-cp, six lines after the first.
- One line, not three, for why no digest is printed here.
- The HFS+ comment claimed an APFS image would not mount on older macOS, which
  is not the reason and reads as though nobody was sure. The real one is that
  stating the filesystem keeps the artifact from changing shape the day the
  runner's macOS does.

The workflow header had grown to 44 lines, half of it saying twice over what
release.sh already says once about API keys and the requirement switch.

Ratios now sit where the rest of the repo does: 38%, 35% and 33% against
build-mcpb.sh at 45% and lint.sh at 34%. Comments explaining a real trap —
ditto and extended attributes, --identifier and the filename truncation, the
two-signature guard — are untouched.

No code changed. lint, shellcheck, YAML parse and a full make-dmg.sh run all
still pass.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant