Files
margin-mail/docs/release.md
T

9.1 KiB

Releasing

Installing locally

just install builds the app for whatever machine you are sitting at and puts it where that machine expects to find applications: /Applications on macOS, or the package manager 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, and starts the new one once it is in place, so the copy on screen is never older than the copy installed. just uninstall reverses it and leaves the data directory alone, which matters more here than it does in the siblings: that directory holds the state database, and the state database is every decision you have ever made about a sender.

The local build skips the dmg and builds only the .app, since nothing about copying a bundle into place needs a disk image and building one is the slowest part of a mac bundle. A locally installed app has no updater artifacts, so it will not update itself. Rerun just install.

Signing

macOS shows no notifications from an app whose bundle is not signed, and tauri build on its own leaves only the linker's signature on the binary, which does not count: the app never appears under Notifications in System Settings and is never asked. So tauri.conf.json names - as the signing identity and every macOS build is at least ad-hoc signed as a bundle, which is enough for notifications. A real identity replaces that wherever one is available. just build sources ~/.margin-signing/studio.margin.app.env when it exists (another directory with MARGIN_SIGNING_DIR), which exports APPLE_SIGNING_IDENTITY, and Tauri takes that over the config. The release workflow does the same from repository secrets and notarizes when the App Store Connect key is there too:

  • APPLE_CERTIFICATE and APPLE_CERTIFICATE_PASSWORD, the Developer ID Application certificate as a base64 .p12 and its password. Tauri imports it into a temporary keychain for the build.
  • APPLE_SIGNING_IDENTITY and APPLE_TEAM_ID, the identity's name and the team behind it.
  • APPLE_API_KEY, APPLE_API_ISSUER and APPLE_API_KEY_P8, the App Store Connect API key id, its issuer and the contents of the .p8. Without these three the bundle is signed and not notarized, which Gatekeeper minds on a download and notifications do not.

From the signing directory that is:

cd ~/.margin-signing
gh secret set APPLE_CERTIFICATE < <(base64 -i developer-id.p12)
gh secret set APPLE_CERTIFICATE_PASSWORD < developer-id.p12.pass
gh secret set APPLE_SIGNING_IDENTITY --body "Developer ID Application: <name> (<team>)"
gh secret set APPLE_TEAM_ID --body "<team>"
gh secret set APPLE_API_KEY --body "<key id>"
gh secret set APPLE_API_ISSUER --body "<issuer>"
gh secret set APPLE_API_KEY_P8 < AuthKey.p8

Before the first release

Two things have to exist that do not yet.

The updater key. Done. The pair was generated with pnpm tauri signer generate -w ~/.tauri/margin_mail_updater.key, the public half is in src-tauri/tauri.release.conf.json and the private half and its password are the repository's TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD secrets. The private half and its password sit beside the siblings' in ~/.tauri, and only there. The public half is baked into every build, so the private half can never be rotated without stranding everyone who has not updated yet: back it up somewhere that is not the machine that made it.

The verification. gmail.modify and gmail.settings.basic are restricted scopes, so the shared Cloud project has to pass restricted scope verification before this app is anything other than a hundred lifetime users behind an unverified consent screen. That is a form, a privacy policy that is true, a demo video and a written justification per scope, and it is per project rather than per client, so it covers margin and Margin Calendar too and a lapse blocks all three. The hedge is in architecture.md: Gmail over IMAP and SMTP with an app password needs no project, no verification and has no cap.

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 Cargo.toml together, commits that to main, tags it, and builds the tag rather than whatever main happens to be by then.

Three runners build in parallel: a universal macOS bundle, an x86_64 Linux one and an x86_64 Windows one. Nothing is published until all three have landed. The publish job downloads latest.json and refuses to take the release out of draft unless darwin-aarch64, darwin-x86_64, linux-x86_64 and windows-x86_64 are all in it. A half-populated manifest is worse than no release: the updater would offer an update to the platforms that made it and error on the ones that did not.

Linux builds on Ubuntu 22.04 on purpose. The bundle will not run on anything older than the glibc it was linked against, so it is built on the oldest release that is supported.

The Windows installers are not code-signed, so SmartScreen warns on the first download until the app has built up reputation. A certificate would go in as WINDOWS_CERTIFICATE and WINDOWS_CERTIFICATE_PASSWORD and needs nothing else changed.

Phones do not come from this pipeline at all. The store is their update channel.

The two Linux packages Tauri does not build

Tauri produces the deb and the AppImage. The flatpak and the Nix package are built from the deb afterwards, by two more jobs, and install.md says which of the four a reader should actually pick.

The flatpak job runs between the build and the publish, so a release never goes out with the Linux artifacts half there. It downloads the deb from the draft release, runs flatpak/build.sh over the manifest in the same directory, and uploads a single-file bundle beside it. The manifest is hand-written because Tauri has no flatpak bundler, and it pins org.gnome.Platform 48, which is the newest runtime that still carries the GTK3 WebKit wry links against. The sandbox gets the network, the notification service and the downloads directory, and nothing else. CI builds the same manifest on every push to main, against a deb built there, which is the only way a break in it gets found before a release.

On Linux, just flatpak builds the deb and repackages it locally. Install flatpak first; the build script installs the GNOME runtime and org.flatpak.Builder from Flathub for your user. Both CI and local builds use that builder because Ubuntu 22.04's flatpak-builder calls the old appstream-compose tool, which the GNOME 48 SDK no longer includes.

The nix job runs after the publish, so the flake can only ever point at a release that survived the manifest check. It hashes the published deb into nix/release.json, builds the package to prove the pin works, and commits the pin to main. NIXPKGS_ALLOW_UNFREE and --impure are in that command because FSL is not a free licence and nix/package.nix says so honestly rather than claiming MIT to dodge the prompt.

Adding a Homebrew cask is the one distribution route the siblings have and this does not. It is a job at the end of the release workflow, a Casks/margin-mail.rb in priyanshujain/homebrew-margin and a HOMEBREW_TAP_DEPLOY_KEY secret; margin's release.yml has the job to copy.

What the build needs

Both jobs check out this repository and the public margin repository side by side, because package.json depends on margin-shared through a relative path. A token edited there is meant to show up in every Margin app at once, and a copy vendored here would defeat that.

Ten repository secrets, all of them set. The three below, and the seven Apple ones under Signing above:

  • GOOGLE_CREDENTIALS, the contents of the real google-credentials.json. The build writes it to the repository root and build.rs embeds it. Without it the build falls back to the example file and warns, which produces an app that runs and then says it is not set up yet. It is the same value in all three Margin repositories, because it is the same OAuth client.
  • TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD, which sign the updater artifacts.

The OAuth client secret ends up inside the shipped binary. That is how installed apps work and Google does not treat it as confidential: an installed client cannot keep a secret, which is why the flow uses PKCE and why the token exchange is safe without one.

Updates

Installed copies check https://github.com/priyanshujain/margin-mail/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 nobody is offered a broken update.

A packaged install does not update itself. The Nix wrapper in the siblings sets a PACKAGED_BY variable and the app checks for it before it would touch its own binary, which in a read-only store it could not replace anyway; this app reads MARGIN_MAIL_PACKAGED_BY for the same reason, and reports the newer version and the upgrade command instead.