Files
margin-docs/docs/release.md
T
2026-08-28 21:47:48 +05:30

118 lines
7.5 KiB
Markdown

# Releasing
## Installing locally
`just install` builds the app for whatever machine you are sitting at and installs it where that
machine expects to find applications: `/Applications` (or `~/Applications` when `/Applications`
is not writable) on macOS, the package manager or an AppImage plus a desktop entry on Linux. It is
the same command whether or not the app is already installed, so it doubles as the update. On
macOS it asks a running copy to quit first, because replacing a bundle under a live process leaves
it half old and half new. `just uninstall` reverses it and leaves the data directory alone.
The local build skips the dmg and builds only the `.app` on macOS, or the `.deb` and the AppImage
on Linux, since nothing about copying a bundle into place needs a disk image and building one is
the slowest part of a mac bundle. That makes a locally installed app slightly different from a
released one: it is ad hoc signed and carries no updater artifacts, so it will not update itself.
Rerun `just install`.
## Cutting a release
Releases are manual: run the Release workflow from the Actions tab. Leave the version empty to
bump the patch number, or give one to set it. The workflow bumps `tauri.conf.json`, `package.json`
and `src-tauri/Cargo.toml` together, commits that to main, tags it, and builds the tag rather than
whatever main happens to be by then.
It builds one universal macOS bundle and publishes nothing until it has landed. The last job
downloads `latest.json` and refuses to take the release out of draft unless three things hold: the
version in the manifest is the version in the tag, `darwin-aarch64` and `darwin-x86_64` are both
present, and each of them has a signature as well as a url. One universal build writes both of
those keys, pointing them at the same archive and the same signature, because an installed copy
asks the manifest for the architecture it is running on and never for a universal one. A
half-populated manifest is worse than no release at all: the updater would offer an update to the
platforms that made it and error on the ones that did not. A manifest carrying the version before
this one is worse again, because it tells everybody the copy they are already running is the newest
there is, and an entry with an empty signature is an update every installed copy downloads and then
refuses.
Linux is not built. It was until recently, because this pipeline was copied from margin-calendar,
which ships on Linux. This app does not, so the row and the manifest key it required are gone and
the release runs on one macOS runner.
Windows is not built. Nothing in `tauri.conf.json` targets it and the app has never claimed it.
## What the build needs
Two repository secrets sign the updater artifacts, so that an installed copy can tell a real update
from anything else offered at the same URL: `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The public half lives in
`src-tauri/tauri.release.conf.json`, baked into every build, so the private half can never be
rotated without stranding everyone who has not updated yet; back up the key and its password
somewhere that is not the machine that generated them.
That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`, and the keypair
behind it does not exist yet. Making it is a one-time step, run once by whoever owns the repository
and never again:
```
pnpm tauri signer generate -w ~/.tauri/margin-docs.key
```
It asks for a password, writes the private key to that path and the public key beside it as
`margin-docs.key.pub`, and prints both. The public one replaces the placeholder in
`tauri.release.conf.json` and is committed. The private one is the contents of
`~/.tauri/margin-docs.key`, pasted into `TAURI_SIGNING_PRIVATE_KEY`, with its password in
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Neither the key file nor the password goes into the
repository.
Six more secrets codesign and notarize the bundle, and unlike the two above they are optional as
far as the workflow is concerned: with none of them set the build still runs, still publishes, and
produces an ad hoc signed app. `APPLE_CERTIFICATE` is the Developer ID Application certificate
exported from Keychain Access as a `.p12` and base64 encoded (`base64 -i certificate.p12`), with
the password it was exported under in `APPLE_CERTIFICATE_PASSWORD`. `APPLE_SIGNING_IDENTITY` is the
certificate's full name, which looks like `Developer ID Application: Your Name (TEAMID)`. The other
three are for notarization: `APPLE_ID` is the Apple account's email address, `APPLE_PASSWORD` is an
app-specific password made at appleid.apple.com rather than the account password itself, and
`APPLE_TEAM_ID` is the ten character team identifier.
Those six are all or nothing. A repository with a certificate but no notarization credentials fails
the build rather than warning, because a signed bundle that has not been notarized is one Gatekeeper
refuses on any machine that has not seen it before, and finding that out from a user is worse than
finding it out from a red run.
The bundle asks for the hardened runtime, which notarization requires, and for no entitlements at
all. That is worth a sentence because it nearly went the other way. Deleting a document goes to the
Trash through the `trash` crate, whose macOS default is to ask Finder over an Apple event, and the
hardened runtime blocks an Apple event unless the bundle carries
`com.apple.security.automation.apple-events` and the user agrees to a permission prompt about
controlling Finder. `src-tauri/src/fs.rs` asks for `trashItemAtURL:` instead, which needs none of
that. The cost is Put Back, which the Finder method leaves on the file and this one does not; the
file is still in the Trash and can still be dragged out of it.
Beyond signing there is nothing to provision. Margin Docs talks to no external API and holds no
OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing
equivalent to a `google-credentials.json` to embed at build time.
## Updates
Installed copies check
`https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json` and update
themselves from it. `--latest` on the publish step is what moves that pointer, so a release that
fails the manifest check stays a draft and no one is offered a broken update. The app checks that
URL once a day in the background and on the Check for Updates menu item, and either way what
happens next is a dialog rather than an install: the version, the release notes, the download with
a progress bar, and Later as a real answer.
Self-update does not work yet, and it will not until there is an Apple Developer ID certificate.
This is not a gap in the workflow, which is wired for one, or in the app, which is wired for the
whole flow. It is macOS: an update replaces the installed bundle with the downloaded one, and the
system will not let an ad hoc signed bundle take the place of a signed one, nor run a replacement
whose signature does not match what was there before. Every build this repository can currently
produce is ad hoc signed, so every self-update attempt ends in a bundle the system refuses to
launch. Until the certificate exists, updates are something to install by hand, and the honest
version of the feature is that the app will find the update, show it, download it, and fail on the
last step.
There is a second thing gated behind the same certificate. The build is not notarized either, so a
copy downloaded from the releases page is quarantined by the browser and refused on first launch
with the message about an unidentified developer. Both problems have the one fix.