ci(macos): a signing identity that outlives a release, so the folder grant does (#209) - #707
Conversation
…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.
|
@PathGao created the cert and added the required MACOS_ variables so it should be ready. |
|
@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 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 2.7.5 is ready to cut. #719 has the version bump ( RELEASING.md has the rest. Two things specific to this release:
Also worth watching the One follow-up, optional and not a blocker: #718 closes the one gap this PR leaves open — a certificate set with an empty |
…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.
…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.
…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.
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:
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_CERTIFICATEunset 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 andtauri buildsigns with it throughAPPLE_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 becomesSigning a second, different binary with the same certificate produces the same leaf hash. Ad-hoc signing those two binaries produces two different
cdhashrequirements. 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
.p12leaks, 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-signresolves 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 beforecodesignis reached. Importing into the keychain ourselves and passing onlyAPPLE_SIGNING_IDENTITYtakes the other branch, which hands the string tocodesignunexamined.What it does not do
Nothing about Gatekeeper. The app stays un-notarized, so a downloaded
.dmgstill warns on first launch — the same as today, since ad-hoc bundles already failspctl. AeroSpace hides that with a Homebrew caskpostflightthat stripscom.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.jsonis untouched:signingIdentitystaysnull, so a localtauri buildbehaves 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).