Files
margin/simplify/release.md
T
2026-10-03 22:48:49 +05:30

28 KiB

Release

One release pipeline for four apps, from the measurements in .research/ci-release-signing.md. The channels, the licences, the reason the updater pubkey lives in an overlay, the contents of ~/.margin-signing and the Google Cloud project are settled in guidelines/distribution.md. This document is what to build so those decisions are enforced in one place instead of copied into four.

Four copies of one pipeline, and none of them is the good one

The prepare job is lines 1 to 81 of margin-caledar/.github/workflows/release.yml and lines 1 to 81 of margin-mail/.github/workflows/release.yml. diff on those 81 lines reports exactly one change, at line 78: Margin Calendar $TAG against Margin Mail $TAG. Eighty lines out of eighty-one are identical, down to the five-attempt rebase loop, the sleep 3 and the ellipsis in "main advanced during release".

The publish job is worse. margin-caledar:162-181 against margin-mail:210-229 differs on one line, and the difference is punctuation inside an error string: calendar's line 177 still carries an em dash where mail's line 225 has a comma. Against margin:199-218 the only substantive difference in the whole job is the platform key list at line 212, which includes windows-x86_64.

Every workflow pins the same eight actions at the same versions: actions/checkout@v7, actions/setup-node@v6 with node-version: 26, pnpm/action-setup@v6 with version: 10, dtolnay/rust-toolchain@stable, swatinem/rust-cache@v2, tauri-apps/tauri-action@v0, cachix/install-nix-action@v31, actions/upload-artifact@v4. Four copies of one pin set, four places to edit when one of them goes to v8.

The more useful observation is that copying did not converge on a best version. Every capability worth having exists in exactly one repository, and no repository has more than a third of them.

Capability Lives in Where
Version string validated before use Margin Docs release.yml:38-41
[package]-anchored Cargo.toml bump, with a verifying grep Margin Docs release.yml:58-75
Signing distinguishes unconfigured from half configured, fails the second Margin Docs release.yml:158-189
latest.json version checked against the tag Margin Docs release.yml:227-231
latest.json entries checked for a signature, not just a url Margin Docs release.yml:245-255
The Windows matrix row, and windows-x86_64 in the gate Margin release.yml:88-102, :212
Cargo.lock bumped and committed Margin release.yml:47-52, :60
codesign, spctl, stapler verification after the build Margin release.yml:188-197
Homebrew tap update Margin release.yml:220-259
The App Store track Margin appstore.yml, 130 lines
The Nix package, pinned and rebuilt Margin Calendar release.yml:187-241, ci.yml:74-81
Sibling checkout so the shared package resolves Margin Mail release.yml:101-109, ci.yml:19-32
fonts:check actually run in CI Margin Mail ci.yml:44
docs-check.mjs run in CI Margin Mail ci.yml:51

Margin has no ci.yml at all. Nothing checks a push or a pull request there, so the first time a broken tree is noticed is a release build, which is also the moment it is most expensive. Margin Docs is a milder version of the same thing: it defines fonts:check at package.json:16 and never calls it from a workflow, so the vendored copy under public/fonts can drift from the package without anything saying so.

The two real bugs

Margin Docs cannot install its own dependencies in CI

margin-editor/package.json:33 declares "margin-shared": "file:../../python/margin/shared" and pnpm-lock.yaml:1449 records it under that path. margin-editor/.github/workflows/ci.yml:19 and release.yml:119 each do a single actions/checkout@v7 with no path: and no sibling repository. On a runner the workspace is /home/runner/work/margin-docs/margin-docs, so ../../python/margin/shared resolves to /home/runner/work/python/margin/shared, which does not exist. pnpm install --frozen-lockfile at ci.yml:29 and release.yml:141 cannot resolve it. Margin Docs CI and Margin Docs releases are broken as committed.

Margin Mail has the identical dependency and solved it: two checkouts at release.yml:101-109, itself into rust/margin-mail and priyanshujain/margin into python/margin, plus defaults.run.working-directory at :97-99, a rust-cache workspace of rust/margin-mail/src-tauri -> target at :143, and projectPath: rust/margin-mail on tauri-action at :202. Four coordinated edits, made once, never carried back. That is the concrete cost of copy-paste in this set: the fix and the bug are in two repos that were the same file.

The shared workflow makes this a one-line input, and repo-layout.md removes the underlying relative path entirely by publishing @margin/*. Until that lands, the input is the fix.

Only one app bumps Cargo.lock

Cargo.lock records the crate's own version. Bumping Cargo.toml without it leaves the lock a release behind, and the next cargo build rewrites it under whoever ran it, so the diff lands in an unrelated commit made by whoever built next.

Margin handles it: an awk pass anchored to name = "margin-app" at release.yml:47-52, with a comment saying why, and src-tauri/Cargo.lock in the git add at :60. The other three bump tauri.conf.json, package.json and Cargo.toml and stop.

Margin Calendar's history has already paid for it twice:

  • 3754e4a652dfcfc7dc93507ae58eddf5415e95e4 "Sync the lock file to the version the crate declares"
  • ae5a7b4c0e126f9f034604b907165c6f5582448b "let cargo.lock catch up with the 0.0.4 bump"

Two manual repair commits for a five-line awk pass that existed in a sibling repo the whole time. Margin Docs and Margin Mail have the same gap and have not released yet, so theirs is unpaid rather than absent. All four are consistent right now (0.1.17, 0.0.5, 0.0.1, 0.0.1, lock matching in each); nothing keeps them that way except that nobody has released since the last repair.

The reusable workflow

One workflow_call workflow in the shared repo, .github/workflows/release.yml, with three jobs and two optional chained workflows. Inputs are only the things the four repos genuinely differ on.

Input Type Default What it drives
app-name string required The draft release title (prepare), and the .app path the signing verification checks
platforms string macos Comma list of macos, linux, windows. Drives the build matrix and the latest.json key list in publish
linux-runner string ubuntu-22.04 The Linux matrix row's runner
project-path string "" defaults.run.working-directory, the rust-cache workspace, and projectPath on tauri-action
sibling-repos string "" owner/repo:path pairs checked out beside the app
needs-google-credentials boolean false Whether the credentials provisioning step runs at all

platforms is the input that matters most, because today the matrix and the publish gate are two hand-edited lists in every repo and nothing stops them disagreeing. One step expands the list into both: macos gives the macos-26 universal row and the keys darwin-aarch64 and darwin-x86_64, linux gives the linux-runner row and linux-x86_64, windows gives windows-latest and windows-x86_64. A platform that builds and is not checked, or is checked and never built, stops being expressible.

prepare runs on ubuntu-latest. It resolves the version from the input or bumps the patch out of src-tauri/tauri.conf.json, validates it against ^[0-9]+\.[0-9]+\.[0-9]+$ (Margin Docs' release.yml:38-41, the string becomes a git tag, a TOML value and a JSON value and is typed by hand), writes all four manifests including Cargo.lock, verifies both bumps, commits, tags with the rebase-and-retry loop, and creates the draft release. Release notes come from --generate-notes rather than --notes "Release $TAG", because tauri-action copies the release body into latest.json and Margin Docs' src/update.ts:79 already feeds that into a dialog, which today reads "Release v0.1.18" and nothing else.

build runs the expanded matrix with fail-fast: false and max-parallel: 1. The parallelism limit is required by guidelines/distribution.md and is in none of the three matrix workflows today: margin:91, margin-caledar:85 and margin-mail:85 set fail-fast and stop. Two rows uploading in parallel race on tauri-action's read-modify-write of latest.json. The job does the checkouts (itself at project-path, then each sibling-repos pair), Linux packages when the row is Linux, node with cache: pnpm (no workflow caches pnpm today, so every job downloads the whole tree fresh), Rust with the matrix targets, swatinem/rust-cache@v2 scoped to the right workspace, pnpm install --frozen-lockfile, Google credentials when needs-google-credentials, Apple signing on macOS rows, tauri-action, then the codesign, spctl and stapler verification from margin/.github/workflows/release.yml:188-197 with the bundle name taken from app-name.

publish runs on ubuntu-latest, downloads latest.json, checks its .version against the tag (Margin Docs release.yml:227-231), then checks every expanded platform key for both a url and a signature (:245-255), then flips the draft. An entry with a url and an empty signature is an update every installed copy will offer, download and reject, so it is not a smaller problem than a missing entry.

The workflow adds concurrency: { group: release-${{ github.repository }}, cancel-in-progress: false }, which no repo has. Two dispatches at once today would both compute a version from tauri.conf.json, both bump, and race on the push loop at margin/.github/workflows/release.yml:62-74.

Homebrew (margin/release.yml:220-259) and Nix (margin-caledar/release.yml:187-241) become separate workflow_call workflows in the same repo, chained by the caller with needs: release. They are per-app distribution channels with per-app asset names, per-app tap or flake repos and per-app credentials; a boolean on one job would be worse than two small workflows.

Margin Mail's 229-line release.yml becomes this:

name: Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number."
        required: false
        type: string

jobs:
  release:
    uses: priyanshujain/margin-shared/.github/workflows/[email protected]
    with:
      app-name: Margin Mail
      platforms: macos,linux
      project-path: rust/margin-mail
      sibling-repos: priyanshujain/margin:python/margin
      needs-google-credentials: true
    secrets: inherit

secrets: inherit is what keeps that short: without it every caller redeclares a dozen APPLE_* and TAURI_SIGNING_* names. If the shared repo is private, Settings, Actions, Access has to allow repositories owned by the same account, or the four callers cannot resolve the uses: at all.

The same treatment applies to the three ci.yml files, which share a concurrency block verbatim (group: ci-${{ github.ref }}, cancel-in-progress: true) and little else. That second reusable workflow takes the same checkout inputs plus a rust-test-command, because Margin Docs splits its Rust suite into two steps (ci.yml:58 and :78, the second running --test-threads=1 against a suite that shares one folder), and an extra-frontend-steps hook for Margin Mail's fonts:check and docs-check.mjs. Margin has to call it too, since it has nothing.

The tauri.conf story

Genuinely per app, and therefore inputs to a generator rather than duplication:

Value Margin Margin Calendar Margin Docs Margin Mail
productName Margin Margin Calendar Margin Docs Margin Mail
identifier studio.margin.app studio.margin.calendar studio.margin.docs studio.margin.mail
devUrl port 1420 1430 1440 1450
window, min 1280x820, 920x640 1360x900, 880x560 1360x900, 880x600 1440x900, 880x560
trafficLightPosition absent 9,25 absent 9,25
bundle.targets "all" app, dmg, appimage, deb app, dmg app, dmg, appimage, deb
CSP additions worker-src 'self' blob: none worker-src, asset: in img-src frame-src 'self'
linux.deb.depends absent webkit2gtk-4.1-0, gtk-3-0 absent as calendar
macOS.signingIdentity absent absent absent "-"
deep-link plugin no yes no yes
resources dictionaries/en none none none

Everything else is one fragment written four times: the $schema, category: Productivity, macOS.minimumSystemVersion: 10.15, the same five icon paths, titleBarStyle: Overlay, the build commands, and the CSP prefix default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; with the suffix connect-src 'self' ipc: http://ipc.localhost. All four apps agree on every one of those and none of them can see that they agree.

Generate the file, do not fragment it. Tauri's --config merge only helps at build time and the committed tauri.conf.json still has to be readable by a person opening the repo. A script in @margin/config reads a small per-app descriptor (name, identifier, port, window, targets, CSP additions, extra plugins) and writes both src-tauri/tauri.conf.json and src-tauri/tauri.release.conf.json, with a --check mode that compares and exits 1 on any difference. That shape already exists and works: margin/shared/bin/sync-fonts.mjs takes an app directory and an optional --check (sync-fonts.mjs:11,21,45,62), is exposed as fonts:sync and fonts:check, and runs in CI at margin-mail/.github/workflows/ci.yml:44. Copy that interface exactly rather than inventing a second one.

Generating makes the odd ones out visible. Margin's bundle.targets: "all" is a default rather than a decision, and it is the only reason Margin produces an rpm. Margin Mail's macOS.signingIdentity: "-" is a real decision (an unsigned bundle posts no notifications, see the ~/.margin-signing note at margin-mail/justfile:47) and has to survive as a per-app value, not get normalised away.

tauri.release.conf.json is four lines of structure and one pubkey in every repo. Generate it the same way, and put the tauri-apps/tauri#14581 reason in the generator's header, because right now that reason exists only in simplify/guidelines/distribution.md:44 and the three sibling docs/release.md files describe the overlay as "where the public half lives" with no explanation. The next person to tidy a config has nothing telling them not to inline the key. While there, note that margin/package.json:11 still has "dmg": "tauri build --bundles dmg" with no overlay, which is exactly the local key-free build the overlay exists to protect.

Two apps carry placeholder pubkeys and have therefore never shipped an update anyone's copy could verify: margin-editor/src-tauri/tauri.release.conf.json says REPLACE_WITH_TAURI_SIGNER_PUBKEY and margin-mail/src-tauri/tauri.release.conf.json says REPLACE_WITH_THE_MINISIGN_PUBLIC_KEY. Neither has had a keypair generated, so neither has released at all. Once the shared publish job carries Margin Docs' signature check, a first release without TAURI_SIGNING_PRIVATE_KEY set fails at publish with a message naming the missing secret rather than shipping a manifest nobody can use, which is the right failure.

Signing

Three routes to the same signing step, in three repos, plus a fourth that does not sign.

Margin Calendar has no signing step. release.yml:152-160 passes TAURI_SIGNING_PRIVATE_KEY and its password to tauri-action and nothing else, so every macOS bundle Calendar has shipped is unsigned and Gatekeeper refuses it on any machine that has not seen it before. Nobody noticed because nothing checks.

Margin writes the notarisation .p8 to $RUNNER_TEMP at release.yml:159-171 and passes the certificate variables straight into tauri-action's env block at :175-183. Margin Mail exports into $GITHUB_ENV only when the values are non-empty, using a fixed MARGIN_EOF heredoc delimiter, and warns twice when they are not (release.yml:167-197). Margin Docs does the same with a random delimiter, EOF_$(openssl rand -hex 12), and turns the half-configured case into a hard failure (release.yml:158-189): a certificate with no notarisation credential produces a signed bundle Gatekeeper still refuses, and doing that quietly is worse than not building. The random delimiter and the hard failure are both the better version; take Docs' step whole.

The credential itself should be the App Store Connect key, not the Apple ID and app-specific password that Margin Docs uses at release.yml:163-165. One key notarises and uploads to App Store Connect, so there is a single credential to rotate; it is revocable and reissuable in App Store Connect without touching a personal account; and an app-specific password is bound to an Apple ID with 2FA on it, which means the person holding the account is the only one who can reissue it. Margin's App Store workflow already requires the key (appstore.yml:114-117), so the Apple ID route means the one app shipping on both channels maintains two credentials for the same operation. The key is already in ~/.margin-signing as AuthKey.p8 with AuthKey.env beside it. Margin Docs' docs/release.md:73-75 tells the reader the opposite of what Margin and Margin Mail's docs tell them; the workflow and that paragraph both change.

Secret names have to be settled at the same time, because Margin and Margin Mail disagree today. Margin uses a secret APPLE_API_KEY_ID and maps it into the action's APPLE_API_KEY environment variable at release.yml:183; Margin Mail has a secret literally named APPLE_API_KEY (release.yml:176). Standardise on the Margin spelling, so the four repos hold APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_API_KEY_P8, APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY and APPLE_TEAM_ID, and the mapping to APPLE_API_KEY happens once, inside the shared workflow.

Only Margin checks the result. release.yml:188-197 runs codesign --verify --deep --strict, then spctl --assess --type execute, then xcrun stapler validate, with a comment noting that spctl is the check a double-clicking user actually meets. That step moves into the shared build job, gated on the notarisation credential being present, with the hardcoded Margin.app replaced by app-name. That alone is what stops Calendar shipping unsigned again.

margin/scripts/apple-secrets.sh can serve all four repositories today and does not, because it lives in one of them. It is already parameterised by DIR, BUNDLE_ID and REPO at :8-10, and it pipes each file straight into gh secret set (:17-32) so no value is ever echoed into a terminal or an agent transcript. Move it and apple-provision.rb into the shared repo unchanged. One wrinkle to write down rather than fix silently: BUNDLE_ID selects an env file, and only studio.margin.app.env exists, because one Developer ID certificate covers the whole team. That is why margin-mail/justfile:47 sources Margin's bundle id, which is right in effect and wrong in shape. The team-wide file should be the default and BUNDLE_ID should only matter where a provisioning profile does, which is the App Store track.

Versioning

Four files per app carry the version: .version in src-tauri/tauri.conf.json, .version in package.json, [package] version in src-tauri/Cargo.toml, and the crate's own entry in src-tauri/Cargo.lock. tauri.conf.json is the source of truth because the "leave empty to bump the patch" path reads it (release.yml:31 in all four).

Nothing keeps them in sync. The release workflow writes three of them (four in Margin), no ci.yml checks that they agree, and the only reason they agree today is that nobody has hand-edited one.

The single procedure, in the shared prepare job: jq the two JSON files, awk the Cargo.toml anchored to the [package] section, awk the Cargo.lock anchored to the crate's own name, verify both with a grep, commit all four in one chore(release): $TAG. Then a ten-line check in the shared CI workflow that reads all four and fails when they disagree, which is the thing that would have prevented both of Calendar's repair commits.

On the Cargo.toml anchoring, be precise rather than dramatic: three repos use sed -i "0,/^version = \".*\"/s//.../", which takes the first line-anchored version = in the file, and in all four repos today that is line 3 under [package], because every dependency is written in the inline table form (tauri = { version = "2", ... }) and never starts a line with version. The sed is a latent fault, not an active one. It is still worth replacing with Margin Docs' awk (release.yml:58-75), because the day a dependency is written in long form the failure is silent: the build succeeds and ships the version before.

Platforms

Repo macOS Linux Windows Store
Margin universal dmg, signed and notarised, Homebrew cask deb, rpm, AppImage from ubuntu-latest msi and nsis Mac App Store pkg
Margin Calendar universal dmg, unsigned deb and AppImage from ubuntu-22.04, plus a Nix flake none none
Margin Docs universal dmg, ad hoc signed none none none
Margin Mail universal dmg, ad hoc signed deb and AppImage from ubuntu-22.04 none none

Margin builds Linux on ubuntu-latest, which contradicts the reasoning Calendar and Mail both committed to in a comment at release.yml:88-95 (the bundle will not run against a glibc older than the one it was linked against, so build on the oldest supported) and produces a bundle with a higher glibc floor. ubuntu-22.04 is the default for linux-runner and Margin moves to it.

Calendar's Nix flake is the Linux answer the other three should adopt. flake.nix is 21 lines, one input and one system, delegating to nix/package.nix, which is 113 lines and repackages the published .deb rather than building from source, for the reason guidelines/distribution.md gives. nix/release.json is the pin, {version, hash}, currently 0.0.5. The release job downloads the deb, hashes it, writes the pin, builds the package as proof and pushes the pin to main; ci.yml:74-81 rebuilds it on every push, so a nixpkgs change that breaks autoPatchelfHook shows up on a pull request rather than at the next release.

Margin Mail reads MARGIN_MAIL_PACKAGED_BY at src-tauri/src/lib.rs:198 and documents the behaviour at docs/release.md:116-119, and ships no flake, so nothing ever sets that variable and the branch is dead code. Adopting the flake makes the code it already has mean something. Margin needs the same plus a packaged_by command it does not have. Margin Docs ships no Linux target at all today, so its bundle.targets changes first or not at all.

Mobile readiness is the inverse of the desktop gating, which is worth knowing before anyone plans a phone build. Margin has a committed iOS Xcode project at src-tauri/gen/apple and does not gate the desktop-only plugins: src-tauri/Cargo.toml:24-25 has tauri-plugin-process and tauri-plugin-updater unconditional. Margin Calendar has both gen/apple and gen/android tracked, including MainActivity.kt, and gates them correctly at Cargo.toml:43-46. Margin Docs and Margin Mail have only gen/schemas and gate them too (Cargo.toml:84-87 and :134-137), though Mail's schemas include iOS-schema.json and mobile-schema.json. So the one app that could build for a phone is the one that would fail to, and the three that carry the gate copied the same comment ("There is no auto-updater and no process to restart on a phone: the store is the update channel").

Per app, in order

Everyone, first. Nothing here starts until the trees are committed and pushed. risks.md makes that a precondition, and Margin Mail is the hard case: git remote -v returns nothing, so the repo does not exist anywhere but this machine, and both a reusable workflow reference and Margin Docs' sibling checkout need it to.

Margin Mail. Push the repo. Generate the minisign keypair, set TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD, replace the placeholder pubkey. Rename the APPLE_API_KEY secret to APPLE_API_KEY_ID. Replace release.yml and ci.yml with calls to the shared workflows, platforms: macos,linux, project-path: rust/margin-mail, sibling-repos: priyanshujain/margin:python/margin, needs-google-credentials: true. Move scripts/docs-check.mjs into the shared toolchain, since it exists in exactly one repo and guidelines/prose-and-docs.md wants it in all four. Add the flake so MARGIN_MAIL_PACKAGED_BY stops being dead. Mail gains, in one step: version validation, the anchored Cargo bump, the Cargo.lock bump, the manifest and signature checks, the half-configured signing failure, and the notarisation verification.

Margin Docs. Fix the checkout first, before anything else, because CI has never been able to install: add sibling-repos and project-path, or wait for @margin/* to be published and drop the relative path instead. Generate the minisign keypair and replace REPLACE_WITH_TAURI_SIGNER_PUBKEY. Switch the signing secrets from APPLE_ID and APPLE_PASSWORD to the App Store Connect key, and fix the contradicting paragraph at docs/release.md:73-75. Wire fonts:check into CI, since package.json:16 defines it and nothing calls it. Call the shared workflows with platforms: macos. Docs loses nothing: its version validation, its awk bump and both its manifest checks are the shared baseline.

Margin Calendar. Call the shared workflows with platforms: macos,linux and no sibling repos, since Calendar does not depend on margin-shared at all and keeps its own token copies, which is how the tokens drifted. It gets macOS signing for the first time, and the spctl verification means an unsigned bundle now fails the build instead of shipping. It gets the Cargo.lock bump, which retires the class of 3754e4a and ae5a7b4. The Nix job moves to the shared repo as a chained workflow_call and Calendar keeps calling it with needs: release.

Margin. It gets a ci.yml for the first time, calling the shared CI workflow. Its release workflow becomes a call with platforms: macos,linux,windows. The Linux runner moves from ubuntu-latest to ubuntu-22.04, which lowers the glibc floor and is the point of the change. bundle.targets: "all" becomes an explicit list, which means deciding whether the rpm was wanted or was a side effect; it was a side effect, and dropping it is fine unless somebody says otherwise. The Homebrew job moves to the shared repo as a chained workflow. appstore.yml, scripts/mas-package.sh, entitlements.mas.plist and the six Ruby scripts stay in Margin: they are one app's submission, and templating them for a hypothetical second store app is worse than copying them the day there is one. Separately, and not part of the release work, gate tauri-plugin-process and tauri-plugin-updater in Cargo.toml:24-25 before anyone builds the committed iOS project.

One thing that is not broken

macOS.hardenedRuntime defaults to true in tauri-utils (config.rs:682 in 2.9.3), so Margin Calendar and Margin Mail, whose configs omit it, get it anyway. Their bundles are hardened. Margin and Margin Docs state it explicitly and Calendar and Mail do not, for no reason anyone recorded, and the generator will settle that difference on its own.

This is written down because it looks exactly like a missing setting in a side-by-side comparison, and "fixing" it would be a no-op commit in two repos that teaches the next reader the default is something else. The thing that genuinely differs in that block is Margin Mail's macOS.signingIdentity: "-", which is deliberate.