This document is the maintainer-facing runbook for cutting a Markpad release with auto-update enabled. Auto-update is wired through tauri-plugin-updater, which verifies signed update bundles using minisign.
On your local machine, in the Markpad checkout:
npm run tauri signer generate -- -w ~/.tauri/markpad-updater.keyYou'll be prompted for a password. Pick a strong one and store it together with the private key in your password manager. The command produces two files:
~/.tauri/markpad-updater.key— PRIVATE. Never commit. Never share. Back up to a password manager.~/.tauri/markpad-updater.key.pub— PUBLIC. Shared with developers; ends up shipped inside Markpad.
In the GitHub repo settings → Secrets and variables → Actions → New repository secret:
| Name | Value |
|---|---|
TAURI_SIGNING_PRIVATE_KEY |
full content of ~/.tauri/markpad-updater.key |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
the password you set in step 1 |
The build workflow reads both at signing time on macOS, Windows, and Linux runners.
Send the single-line content of ~/.tauri/markpad-updater.key.pub (no comments, no header lines) to the developer who'll commit it to src-tauri/tauri.conf.json under plugins.updater.pubkey. The committed value is the live public key — steps 1–3 are here for whoever has to redo the setup, not for the next release.
Once a release ships with the pubkey embedded, it cannot be rotated without breaking auto-update for every existing user. Rotation means everyone re-installs Markpad manually. Treat the keypair as a long-lived release secret.
If you ever lose the private key:
- Existing users can still use Markpad, but they will not auto-update again.
- A new keypair has to be generated, embedded in a new release, and that release has to be installed manually by every user.
- Communicate this in release notes so users aren't blindsided.
The updater endpoint in src-tauri/tauri.conf.json points at sftwrdotdev/Markpad. (scripts/releaseWorkflow.test.ts holds that name and the endpoint together — if the repository ever moves again, this line moves with it.)
That only helps builds made from here on. The endpoint is compiled into the binary, so every copy up to and including v2.7.0 asks GitHub for https://github.com/alecdotdev/Markpad/releases/latest/download/latest.json and reaches the current feed only because GitHub answers with a 301. Those installs use that redirect for as long as they run.
GitHub voids a transfer redirect if the old location is occupied. From Transferring a repository:
If you create a new repository or fork at the previous repository location, the redirects to the transferred repository will be permanently deleted.
So: do not create a repository named Markpad under alecdotdev, do not fork this repository to alecdotdev, and do not transfer Markpad back and away again. A fork is the easy mistake — it copies the code and not the releases, so that URL 404s the moment one exists.
The failure is quiet and unfixable from here: latest.json 404s, the updater reports no update available, and users on old versions simply stop being offered new ones. It is not a code-execution risk — the pinned pubkey means whoever serves that URL cannot produce a signature that installs.
build.yml checks this before creating a release. It fetches that URL and asserts the feed's download URLs still name this repository. A network failure warns instead of blocking.
Configure these repository Actions secrets before the next release:
| Name | Value |
|---|---|
APPLE_DEVELOPER_ID_P12 |
Base64 of the project's Developer ID Application certificate and private key exported as PKCS#12 |
APPLE_DEVELOPER_ID_PASSWORD |
PKCS#12 export password |
APPLE_DEVELOPER_ID_SHA1 |
The certificate's 40-character SHA-1 fingerprint, not its shared common name |
APPLE_ID |
Apple account email authorized for notarization |
APPLE_APP_SPECIFIC_PASSWORD |
An app-specific password for that account |
APPLE_TEAM_ID |
The certificate's 10-character Apple team ID |
The new certificate takes precedence over the legacy MACOS_* secrets below.
Setting any APPLE_DEVELOPER_ID_* secret requires all six values. Missing or
invalid credentials stop the release; they never fall back to legacy signing.
With none of the three new certificate secrets, the existing flow is unchanged.
Tauri receives the app-specific password as APPLE_PASSWORD and notarizes the
app before creating the updater archive. The workflow checks the extracted
archive and the app inside the DMG, then uploads only after verifying their
Developer ID signatures and stapled tickets. The DMG is also signed, notarized
and stapled. See Tauri's macOS signing guide.
Keep TAURI_SIGNING_PRIVATE_KEY, its password, plugins.updater.pubkey and the
updater endpoint unchanged. Apple signing and the existing updater signature are
independent. The first Developer ID build may require users to grant folder
access once more when migrating from the earlier identity. Do not claim a release
is notarized until the macOS verification step passes.
Superseded by Developer ID signing. build.yml still uses these secrets when no APPLE_DEVELOPER_ID_* secret is set. Releases signed this way keep folder grants across updates but are not notarized.
Add three secrets (Settings → Secrets and variables → Actions):
| Name | Value |
|---|---|
MACOS_CERTIFICATE |
base64 -i markpad-codesign-certificate.p12 | pbcopy |
MACOS_CERTIFICATE_PASSWORD |
the .p12 export password |
MACOS_SIGNING_IDENTITY |
markpad-codesign-certificate (the certificate's common name) |
With all three set, the macOS job imports the certificate into a throwaway keychain and tauri build signs with it. With MACOS_CERTIFICATE absent, the step prints one line and exits.
Apple can revoke a Developer ID certificate. Nobody can revoke this one. Two consequences worth accepting deliberately before step 6:
- If the
.p12leaks, whoever holds it can sign a build that satisfies the same designated requirement — and therefore inherits every folder grant users gave the real Markpad. The only remedy is to switch certificates, which costs every user their permissions. - If it is lost or expires, same outcome: a new certificate is a new identity, and every user re-grants from scratch.
Back it up in the same place as the minisign private key, and treat it with the same care.
The workflow uses npm ci, so its installed dependency graph is exactly the committed lockfile. Do not replace it with npm install in release jobs. scripts/releaseWorkflow.test.ts guards that.
snapcraft.yaml builds nothing. It packages the .deb the release already shipped (plugin: dump, source-type: deb), so the snap carries the same binary as the .deb and the AppImage. The same test asserts it is packaging rather than building.
-
Bump the version:
npm run release X.Y.Z
It writes all three files the version lives in and checks they agree —
package.json,src-tauri/Cargo.toml[package].version, and theMarkpadentry insrc-tauri/Cargo.lock. The lock is the one that gets forgotten by hand: nothing in the editing loop reads it, so a bump without it lands green and stays wrong.It stops there. Committing, tagging and dispatching stay below, on purpose.
-
Commit, tag, push:
git commit -am "chore: bump version to X.Y.Z" git tag vX.Y.Z git push origin master vX.Y.Z -
Trigger the workflow:
- GitHub UI: Actions → "Build and Release" → Run workflow → master
- Or CLI:
gh workflow run build.yml --ref master
Only one release build runs at a time — a second dispatch queues behind the first instead of racing it to the same draft.
-
Wait ~30 min for matrix builds to finish, plus ~2 min for
generate-update-feed. -
Open the draft release on the Releases page. Verify the assets:
- macOS:
*.dmg,*.app.tar.gz,*.app.tar.gz.sig- The workflow verifies the Developer ID signature and stapled tickets before upload. To spot-check the draft: mount the
.dmgand runspctl -a -vvagainst the.appinside it. It must printsource=Notarized Developer ID.
- The workflow verifies the Developer ID signature and stapled tickets before upload. To spot-check the draft: mount the
- Windows x64:
Markpad_<version>_x64.exe(portable),*_x64-setup.exe(NSIS installer),*_x64-setup.exe.sig - Windows ARM64:
Markpad_<version>_arm64.exe(portable),*_arm64-setup.exe(NSIS installer),*_arm64-setup.exe.sig - Linux:
*.deb,*.rpm,*.AppImage,*.AppImage.sig - Update feed:
latest.json(one entry per successfully built platform)
- macOS:
-
Click "Publish release" — this is the gate. It activates auto-update for all clients pointing at
releases/latest/download/latest.json, and it startspublish-packages.yml, which pushes to Chocolatey and the Snap Store. -
Watch
Publish Packagesfinish. Two independent jobs, neither swallowing a failure; either can fail without affecting the release that already went out. Fix, then re-run it with the tag asworkflow_dispatchinput — that path also works against an already-published tag, which is the only way to exercisesnapcraft.yamlor the Chocolatey packaging without cutting a release, since neither is reachable from a pull request.
The first release after auto-update is enabled does not auto-update existing users — older Markpad builds don't have the updater wiring yet. They must download and install this version manually once. From then on, every subsequent release reaches users automatically.
Mention this clearly in the release notes for the first auto-update-capable version, e.g.:
This release activates in-app auto-updates. Install it manually one last time — future releases will update Markpad on their own.
- macOS uses one universal binary (
darwin-aarch64+darwin-x86_64share the same.app.tar.gzand signature). - Windows uses NSIS — the auto-updater downloads
*-setup.exe(verified by*-setup.exe.sig) and runs it inpassiveinstall mode. The existing raw portable.exedistribution path is preserved alongside, so users who download the portable.exedirectly continue to work; only the auto-updater path uses the NSIS installer. - Linux: only
AppImageusers get auto-updates —tauri-plugin-updaterdoesn't support.debor.rpm, and there is no apt or dnf repository either..deband.rpmare therefore one-time installs: those users upgrade by downloading a newer package, and the app's Check for Updates… tells them so rather than offering one. This is stated in the README as well; if a repository is ever published, both change together. - Homebrew: the
markpadcask lives in Homebrew's repository, not here. Homebrew's bot bumps it from the git tags and downloadsMarkpad_<version>_universal.dmgfrom the release, so a tag pushed before its release is published can produce a bump that fails until the release is out. Renaming the.dmgor shipping a build that Gatekeeper rejects breaks the cask. The cask declaresauto_updates, so the app still updates itself. - Snap / Chocolatey: independent distribution channels, published by
publish-packages.ymlafter the release is published. Their update mechanisms are unaffected. The Chocolatey package wraps the release's ownMarkpad_<version>_x64.exerather than a second build, which is whatpackaging/choco/tools/VERIFICATION.txtpromises.
| Symptom | Likely cause / fix |
|---|---|
Build fails: "missing TAURI_SIGNING_PRIVATE_KEY" |
Step 2 of one-time setup wasn't done, or Secret name doesn't match. |
macOS build fails at security import |
The .p12 was exported by a recent openssl with its default cipher, which security cannot read (it reports a MAC failure, not a cipher failure). Re-export from Keychain Access, or pass -legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1. |
macOS build logs no identity found |
The certificate is a CA rather than a leaf — codesign will not sign with it. Keychain Access's Create a Certificate… produces the right kind; openssl req -x509 needs an explicit basicConstraints=critical,CA:FALSE. |
generate-update-feed succeeds but latest.json lacks a platform |
That platform's matrix build failed silently (or the .sig file wasn't produced). Check the failed build's logs. |
latest.json missing entirely |
The generate-update-feed job didn't run — usually because no *.sig files were uploaded. Check the Upload * Artifacts steps. |
| Users don't see the update | (1) Did you click Publish release? Drafts aren't visible to clients. (2) Is the user on a version older than the first auto-update-capable release? They need a one-time manual reinstall. |
| Update download succeeds but install fails with signature error | Pubkey mismatch — the Secrets and tauri.conf.json pubkey belong to different keypairs. |
| An AppImage check fails | Three scripts run against it and the log says which: check-appimage-libraries.sh (what it bundles), strip-appimage.sh (removing the host-coupled ones), smoke-appimage.sh (whether it starts). All three run locally against the AppImage from the draft release; no signing key, no FUSE. |
Publish Packages / snap job fails |
Read the snapcraft error rather than re-running. snapcraft.yaml only unpacks a .deb, so the two live failures are: the job did not put markpad.deb next to it (the message names the path), or the snapcraft snap the runner installs changed under us — the job prints snap list snapcraft. Fix, then re-run with the tag as workflow_dispatch input. |
Publish Packages / chocolatey job fails on push |
A version can only be pushed to Chocolatey once. If it is already there, nothing needs doing; if it is not, check CHOCO_API_KEY. |
- Windows Authenticode signing — neither the portable
.exenor the*-setup.exeNSIS installer is signed with a code-signing certificate. Users may see a SmartScreen warning. Minisign verification by the updater is independent. - Retroactive signing of older releases.