Skip to content

ci(macos): a signing identity that outlives a release, so the folder grant does (#209) - #707

Merged
PathGao merged 2 commits into
masterfrom
ci/macos-signing-identity
Aug 25, 2026
Merged

ci(macos): a signing identity that outlives a release, so the folder grant does (#209)#707
PathGao merged 2 commits into
masterfrom
ci/macos-signing-identity

Conversation

@PathGao

@PathGao PathGao commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

macOS binds a persisted file-access grant — including the Full Disk Access checkbox — to an app's designated requirement, not to its bytes. Markpad's bundles carry no certificate, so there is nothing to pin. Measured on the installed release:

$ codesign -d -r- /Applications/Markpad.app
code object is not signed at all                 # the bundle itself
$ codesign -dvvv /Applications/Markpad.app
Identifier=Markpad-52a111448cc66310              # linker-generated, not the bundle id
Signature=adhoc

The requirement falls back to a content hash, so every build is a different app to TCC and the user grants folder access again. That is #209, and it is also why granting Full Disk Access does not help: that checkbox is a TCC record pinned to the same moving identity.

What changes

One conditional step in the macOS release job. With MACOS_CERTIFICATE unset it prints a line and exits, leaving the release byte-for-byte what it is today. With the three secrets set, the certificate goes into a throwaway keychain and tauri build signs with it through APPLE_SIGNING_IDENTITY — the variable the pinned CLI reads, and the reason the step has to run before the build rather than beside it.

Verified end to end on the real bundle: a copy of the installed Markpad.app, re-signed with a self-signed certificate and hardened runtime, launches and keeps running, and its requirement becomes

designated => identifier "com.alecdotdev.markpad" and certificate leaf = H"95b560d4…"

Signing a second, different binary with the same certificate produces the same leaf hash. Ad-hoc signing those two binaries produces two different cdhash requirements. The leaf hash not moving when the code does is the entire fix.

The trade-off

The certificate does not have to come from Apple. TCC compares the requirement; it does not ask who issued the certificate. So this needs no Developer Program membership and costs nothing — AeroSpace ships exactly this way (codesign_identity="aerospace-codesign-certificate", not notarized).

What is given up against a Developer ID is revocation. If the .p12 leaks, whoever holds it can sign a build that satisfies the same requirement and inherits every folder grant users gave the real Markpad. Apple can revoke a Developer ID certificate; nobody can revoke this one. Losing it or rotating it costs every user their permissions. That is written into RELEASING.md as step 7 rather than living only in this description.

I considered doing this through APPLE_CERTIFICATE, which Tauri imports on its own, and it does not work here: tauri-macos-sign resolves the identity by searching for seven Apple certificate prefixes (Developer ID Application: and friends) and requires an Organizational Unit, so a self-signed certificate is rejected before codesign is reached. Importing into the keychain ourselves and passing only APPLE_SIGNING_IDENTITY takes the other branch, which hands the string to codesign unexamined.

What it does not do

Nothing about Gatekeeper. The app stays un-notarized, so a downloaded .dmg still warns on first launch — the same as today, since ad-hoc bundles already fail spctl. AeroSpace hides that with a Homebrew cask postflight that strips com.apple.quarantine; Markpad has no cask (#102), so that half does not carry over. Nothing gets worse either way.

It also does not remove the first prompt. macOS asks once per app per protected folder by design. What changes is that the answer survives the next update.

tauri.conf.json is untouched: signingIdentity stays null, so a local tauri build behaves as before and the environment variable overrides it in CI only.

Before this does anything

Three repository secrets, from a certificate created in Keychain Access in about five minutes with no Apple account involved: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, MACOS_SIGNING_IDENTITY. RELEASING.md step 6 is the runbook. Until they exist this PR is inert, which is deliberate — #294 was closed for gating releases on credentials that did not exist yet.

Tests: 972 pass. The new one fails if the early exit is removed (checked by removing it).

…grant does (#209)

macOS binds a persisted file-access grant to an app's designated
requirement. An unsigned bundle has no certificate to pin, so the
requirement is a content hash that changes with every build -- which is
why the folder permission has to be granted again after each update.

Signing with a certificate that stays the same across releases replaces
that hash with `identifier <bundle-id> and certificate leaf = H"..."`.
The certificate does not have to come from Apple: TCC compares the
requirement, it does not ask who issued it.

The step is inert without the three secrets, so a release built today is
unchanged. This is not notarization -- Gatekeeper still warns on first
launch either way.
…heck the signature took

Step 6 leaves two things to a release the runbook did not say out loud.

The switch from ad-hoc hash to certificate is itself an identity change, so
the first signed release costs macOS users their folder grants one last
time. It reads as a regression unless the release notes say it is the end of
them.

And the import step exits 0 when the secrets are absent, by design, so an
unsigned macOS build is green and silent. The draft-release check now asks
for the requirement, where the rest of the asset verification already is.

The secret names live in two places that never see each other: the runbook
tells the maintainer what to create, the workflow reads it. A test ties them
together, since drift lands as the same silent unsigned build.
@alecdotdev

Copy link
Copy Markdown
Collaborator

@PathGao created the cert and added the required MACOS_ variables so it should be ready.

@PathGao
PathGao merged commit 3f8ffe2 into master Aug 25, 2026
4 checks passed
@PathGao
PathGao deleted the ci/macos-signing-identity branch August 25, 2026 03:29
@PathGao

PathGao commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

@alecdotdev Thanks — I dry-ran the whole release pipeline on a fork with a throwaway self-signed certificate before saying anything, since this step had never actually run on a runner. It works. The .dmg that came out carries:

designated => identifier "com.alecdotdev.markpad" and certificate leaf = H"b2f627f4…"
Authority=<the test certificate>
flags=0x10000(runtime)

That leaf hash belongs to the certificate, not to the code, which is the whole of #209 — it stops moving when the app updates. Nothing upstream was touched by that run.

I also saw your Publish Packages dispatch at 04:21 succeed: the Snap Store now serves 2.7.4, revision 16, up from 2.6.11 / revision 15 from June 3rd. That channel is alive again.

2.7.5 is ready to cut. #719 has the version bump (package.json, Cargo.toml, Cargo.lock) and the release notes in its description, covering the 25 commits since 2.7.4. Merge it, then:

git pull && gh workflow run build.yml --ref master

RELEASING.md has the rest. Two things specific to this release:

  1. Before you click Publish, mount the .dmg from the draft and run codesign -d -r- on the .app inside. It must print certificate leaf = H"…". If it prints code object is not signed at all, the secret names don't match what the workflow reads and the build is green anyway — that's the one failure this design deliberately allows, and it costs every macOS user their folder grants if it ships.
  2. The release notes need the one-time line, because the app's identity changes once with this release: "macOS will ask for folder access once more after this update. This is the last time." It's already in 2.7.5: the first signed macOS release, and the snap publishes on its own again #719's body and in RELEASING.md.

Also worth watching the snap job on that release: 2.7.5 is the first release where clicking Publish reaches the store on its own, without the hand dispatch you just did.

One follow-up, optional and not a blocker: #718 closes the one gap this PR leaves open — a certificate set with an empty MACOS_SIGNING_IDENTITY currently walks past the early exit and can ship unsigned while green. It's one guard and one test.

PathGao added a commit that referenced this pull request Aug 25, 2026
…ad of shipping unsigned (#718)

#707 made signing optional by reading one of its three secrets: with
MACOS_CERTIFICATE empty the step prints a line and exits, and the release is
byte-for-byte what it was. That covers a repository that never set signing up.
It does not cover the configuration in between -- certificate set, identity
missing -- which walks past the exit and exports an empty
APPLE_SIGNING_IDENTITY.

The two halves fail differently. A wrong identity stops `codesign` with "no
identity found" and the build goes red. An empty one is never looked up, and
whether `tauri build` errors or signs nothing is the pinned CLI's business --
a green build that shipped unsigned is the exact outcome this step exists to
prevent, and it reaches users as folder access being asked for all over again
with nothing on the release page to show it.

The password needs no guard of its own: `security import` fails on a wrong or
missing one, two lines later, loudly.
PathGao added a commit that referenced this pull request Aug 25, 2026
…2.7.4

The table is composed in `build.yml` and printed on every release page, so both
notes ship with whatever master holds when the workflow is dispatched. Both are
about to be wrong.

macOS: the last paragraph told users the app grants file access per prompt and
that self-signing in Keychain Access is the way out, redone after every update.
#707 is what that paragraph asks for, so it would print the workaround on the
first release that no longer needs it. Replaced with what is now true, worded so
it stays true for 2.7.6: the grant survives an update, and only a user coming
from a release older than 2.7.5 is asked once more, because the signature
changes the identity the old grants belonged to. The Gatekeeper paragraph above
it is untouched -- signing is not notarization, and the first-launch dialog is
unaffected.

Windows: the "false positive Trojan" half is the stale one -- VirusTotal no
longer flags the portable `.exe`, which is what #334 and #466 were. The
SmartScreen half is not: it fires on an unsigned binary regardless of what any
scanner says, and stays true until an Authenticode certificate exists (#562).
Dropping the whole note would leave the release page silent about a dialog every
Windows user still meets. So the antivirus claim goes and the unrecognized-app
one stays, in one shorter sentence.

Tests: 984 pass.
alecdotdev pushed a commit that referenced this pull request Aug 25, 2026
…own again (#719)

* chore: bump version to 2.7.5

* docs(syntax): bring the reference up to 2.7.5, in both languages

Four things shipped since 2.7.4 that the file which documents what Markpad can
do never heard about. It carries the editing behaviour around each construct,
not only the spellings, so each one belongs to a section that already exists.

- Lists: `Tab` moved a line by tabSize and left the marker alone. #713 makes it
  a level change -- the parent's content column, and both numbered lists
  renumbered -- which is what the file already claimed and now describes
  accurately.
- Quotes: `Enter` continues a block quote (#705), so the section gets the
  paragraph Lists has had. Including that one keystroke clears an empty quoted
  line at any depth, which is the way out.
- Images: where a pasted or dropped image lands, and `${filename}` in that
  setting (#716). It expands to a folder name, not a path, and the note says so
  -- `./images/${filename}/` is not a thing you can write here.
- Not-syntax: copying from the preview keeps its formatting (#680), and the
  split panes can trade sides (#693).

Tests: 984 pass.

* docs(release): the download table's two warnings describe 2.7.5, not 2.7.4

The table is composed in `build.yml` and printed on every release page, so both
notes ship with whatever master holds when the workflow is dispatched. Both are
about to be wrong.

macOS: the last paragraph told users the app grants file access per prompt and
that self-signing in Keychain Access is the way out, redone after every update.
#707 is what that paragraph asks for, so it would print the workaround on the
first release that no longer needs it. Replaced with what is now true, worded so
it stays true for 2.7.6: the grant survives an update, and only a user coming
from a release older than 2.7.5 is asked once more, because the signature
changes the identity the old grants belonged to. The Gatekeeper paragraph above
it is untouched -- signing is not notarization, and the first-launch dialog is
unaffected.

Windows: the "false positive Trojan" half is the stale one -- VirusTotal no
longer flags the portable `.exe`, which is what #334 and #466 were. The
SmartScreen half is not: it fires on an unsigned binary regardless of what any
scanner says, and stays true until an Authenticode certificate exists (#562).
Dropping the whole note would leave the release page silent about a dialog every
Windows user still meets. So the antivirus claim goes and the unrecognized-app
one stays, in one shorter sentence.

Tests: 984 pass.
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.

2 participants