# CI, packaging, signing, the updater, distribution Four repos, four hand-maintained copies of the same release pipeline. This is what is in them, what has already drifted, and what a shared version would have to keep per app. Paths: `margin` is `/Users/pj/Workspace/projects/python/margin`, `margin-calendar` is `/Users/pj/Workspace/projects/python/margin-caledar` (misspelled on disk), `margin-docs` is `/Users/pj/Workspace/projects/rust/margin-editor`, `margin-mail` is `/Users/pj/Workspace/projects/rust/margin-mail`. ## The workflows | repo | release.yml | ci.yml | other | | --- | --- | --- | --- | | margin | 259 lines | none | appstore.yml, 130 lines | | margin-calendar | 241 | 81 | | | margin-docs | 258 | 78 | | | margin-mail | 229 | 92 | | margin has no CI workflow at all. Nothing checks a push or a pull request there; the first time a broken tree is noticed is a release build. ### How near-duplicate the release YAML is Whole-file changed lines (`diff | grep -c '^[<>]'`) run from 126 (calendar vs mail) through 136 (margin vs calendar), 144 (margin vs mail), 211 (docs vs mail), 225 (calendar vs docs) to 241 (margin vs docs). Those numbers overstate the difference, because they count reflowed comment blocks. The structural duplication is much worse. The whole `prepare` job, lines 1 to 81 of both files, is byte-identical between margin-calendar and margin-mail except for one line: the title at `margin-caledar/.github/workflows/release.yml:78` says `Margin Calendar $TAG` and `margin-mail/.github/workflows/release.yml:78` says `Margin Mail $TAG`. Nothing else in 81 lines differs. The `publish` job is the same story. `margin-caledar/.github/workflows/release.yml:162-181` against `margin-mail/.github/workflows/release.yml:210-229` differs on one line, and that line is punctuation inside an error string (calendar still has an em dash at line 177, mail rewrote it as a comma). Against margin, `release.yml:199-218`, the only real difference is the platform key list: margin checks `darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64` at line 212, the other two check the same list without Windows. Every workflow pins the same 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. ### Triggers, permissions, concurrency, caching All four releases are `workflow_dispatch` only, with one optional `version` string input, and `permissions: contents: write` at workflow level. margin's `appstore.yml:16-17` is the one workflow with `contents: read`. No release workflow has a `concurrency` block. Two dispatches at once would both compute a version from `tauri.conf.json`, both bump, and race on the push-with-rebase loop at `margin/.github/workflows/release.yml:62-74`. The three `ci.yml` files do have one, identical in all three (`group: ci-${{ github.ref }}`, `cancel-in-progress: true`): another three-way copy. Cargo is cached everywhere through `swatinem/rust-cache@v2`. pnpm is cached nowhere: no workflow sets `cache: pnpm` on `setup-node`, so every job downloads the whole tree fresh. ### Matrix and runners margin, `release.yml:88-102`: three rows, `macos-26` universal, `ubuntu-latest`, `windows-latest`. Only margin builds Windows, and only margin installs `rpm` (`release.yml:124`). margin-calendar and margin-mail, both `release.yml:88-95`: two rows, `macos-26` universal and `ubuntu-22.04`, both carrying the same comment about 22.04 being the glibc baseline. margin builds Linux on `ubuntu-latest` instead, contradicting the reasoning the other two committed to and producing a bundle with a higher glibc floor. margin-docs, `release.yml:111-117`: no matrix, one `macos-26` runner, with a good comment on why a matrix of one is where a stale Linux row survives. margin-mail is the only one that needs two checkouts, `release.yml:101-109`: itself into `rust/margin-mail` and `priyanshujain/margin` into `python/margin`, because `package.json` has `"margin-shared": "file:../../python/margin/shared"`. That forces `defaults.run.working-directory` (`release.yml:97-99`), a different rust-cache workspace path (`release.yml:143`), and `projectPath: rust/margin-mail` on the tauri-action (`release.yml:202`). **margin-docs has the same relative dependency and does not do this.** `margin-editor/package.json` declares `margin-shared` at `file:../../python/margin/shared` and `pnpm-lock.yaml:1449` records it under that path, but `margin-editor/.github/workflows/ci.yml:19` and `release.yml:119` each do a single checkout with no sibling repo. `pnpm install --frozen-lockfile` cannot resolve that path. margin-docs CI and margin-docs releases are broken as committed. That is the single most concrete cost of copy-paste here: the fix landed in mail and was never carried back. ### Which workflow is most evolved margin-docs, and it is not close. It is the only one that validates the version string before using it (`release.yml:38-41`), the only one that bumps `Cargo.toml` with a `[package]`-anchored awk pass and then verifies the result (`release.yml:58-75`), the only one whose Apple signing step distinguishes "unconfigured" from "half configured" and fails the second case (`release.yml:176-189`), the only one that checks the manifest version against the tag (`release.yml:227-231`), and the only one that checks `latest.json` carries a signature and not just a url (`release.yml:245-255`). Its comments explain why each check exists. margin is the most complete in scope: the only Windows row, the only App Store workflow, the only post-build `codesign`/`spctl`/`stapler` verification (`release.yml:188-197`), the only Homebrew tap job (`release.yml:220-259`), and the only bump step that rewrites `Cargo.lock` (`release.yml:47-52`). margin-calendar is the only one with a Nix job (`release.yml:187-241`). margin-mail is the plainest: the two-repo checkout and nothing else the others lack. Nobody has all of it, and every good idea lives in exactly one repo. ## tauri.conf.json side by side | | margin | margin-calendar | margin-docs | margin-mail | | --- | --- | --- | --- | --- | | productName | Margin | Margin Calendar | Margin Docs | Margin Mail | | version | 0.1.17 | 0.0.5 | 0.0.1 | 0.0.1 | | identifier | studio.margin.app | studio.margin.calendar | studio.margin.docs | studio.margin.mail | | devUrl port | 1420 | 1430 | 1440 | 1450 | | window | 1280x820, min 920x640 | 1360x900, min 880x560 | 1360x900, min 880x600 | 1440x900, min 880x560 | | titleBarStyle | Overlay | Overlay | Overlay | Overlay | | trafficLightPosition | absent | 9,25 | absent | 9,25 | | bundle.targets | `"all"` | app, dmg, appimage, deb | app, dmg | app, dmg, appimage, deb | | category | Productivity | Productivity | Productivity | Productivity | | macOS.minimumSystemVersion | 10.15 | 10.15 | 10.15 | 10.15 | | macOS.hardenedRuntime | true | absent | true | absent | | macOS.signingIdentity | absent | absent | absent | `"-"` | | linux.deb.depends | absent | webkit2gtk-4.1-0, gtk-3-0 | absent | same as calendar | | deep-link plugin | no | yes | no | yes | | resources | dictionaries/en | none | none | none | | copyright | present | absent | absent | absent | Line references: `margin/src-tauri/tauri.conf.json:3-5,29,40-47`, `margin-caledar/src-tauri/tauri.conf.json:3-5,49-54,65-75`, `margin-editor/src-tauri/tauri.conf.json:3-5,29-32,43-46`, `margin-mail/src-tauri/tauri.conf.json:3-5,45,56-64`. `hardenedRuntime` defaults to `true` in tauri-utils (`~/.cargo/registry/src/index.crates.io-.../tauri-utils-2.9.3/src/config.rs:682`), so the two that omit it get it anyway. Two repos state it and two do not, for no reason. The CSP is four variations on one string. All four begin `default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';` and end with `connect-src 'self' ipc: http://ipc.localhost`. margin adds `worker-src 'self' blob:`, margin-docs adds that plus `asset: http://asset.localhost` to `img-src`, margin-mail adds `frame-src 'self'`, margin-calendar adds nothing. Every app declares the same five icon paths. `bundle.targets: "all"` in margin is why margin gets an rpm and the others do not. It is a default rather than a decision. ## Signing and notarisation Local credentials live in `~/.margin-signing`, overridable with `MARGIN_SIGNING_DIR`. Only two repos reference it. In margin: `scripts/apple-provision.rb:39`, `scripts/apple-secrets.sh:8`, `scripts/mas-upload-local.sh:15`, documented at `docs/publishing.md:213-226`. In margin-mail: `justfile:47` and `docs/release.md:25-26,40`. The directory holds, by name: three `.p12` files (`developer-id.p12`, `apple-distribution.p12`, `mac-installer.p12`) each with a sibling `.pass` file, a `.provisionprofile` named after the bundle id, `AuthKey.p8` with `AuthKey.env` beside it holding the key id and issuer, and an env file per bundle id (`studio.margin.app.env`) exporting `APPLE_TEAM_ID`, `APPLE_SIGNING_IDENTITY`, `MAS_APP_IDENTITY` and `MAS_INSTALLER_IDENTITY`. Private keys are generated locally so Apple only ever sees a CSR, and each `.p12` bundles Apple's intermediate so a fresh CI keychain can build a chain (`docs/publishing.md:214-218`). `margin/scripts/apple-secrets.sh` pushes all of it into a repository's Actions secrets by piping files straight into `gh secret set` so nothing is echoed (`apple-secrets.sh:17-32`). It is already parameterised: `DIR`, `BUNDLE_ID` and `REPO` are env-overridable (`apple-secrets.sh:8-10`). It could serve all four repos today and does not, because it lives in one of them. margin-mail's local build sources `$MARGIN_SIGNING_DIR/studio.margin.app.env`, hardcoded to margin's bundle id (`justfile:47`), which is right in effect (one certificate covers the team) and wrong in shape. The three release workflows arrive at the same signing step by three different routes. margin-calendar has none at all: `release.yml:152-160` passes only the Tauri updater key, so calendar ships unsigned macOS bundles. margin writes only the notarisation `.p8` to disk (`release.yml:159-171`) and passes the certificate variables directly to the action (`release.yml:175-183`). margin-mail exports certificate variables into `$GITHUB_ENV` only when they are non-empty, with a heredoc delimiter, and warns twice when they are not (`release.yml:167-197`). margin-docs does the same with a random heredoc delimiter and a hard failure on the half-configured case (`release.yml:158-197`). The secret names have drifted. margin uses `APPLE_API_KEY_ID` as the secret and maps it to `APPLE_API_KEY` in the action environment (`release.yml:183`). margin-mail uses a secret literally called `APPLE_API_KEY` (`release.yml:176`). margin-docs uses the Apple ID and app-specific password route instead: `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` (`release.yml:163-165`), which is the one `docs/publishing.md:22-24` explicitly argued against, because the App Store Connect key does notarisation and store upload with one credential to rotate. Only margin verifies the result. `release.yml:188-197` runs `codesign --verify --deep --strict`, then `spctl --assess --type execute`, then `xcrun stapler validate`, with a comment that spctl is the check a double-clicking user actually meets. No other repo checks that its signed bundle is notarised. ### The App Store track `margin/appstore/` is listing content, not code: one text file per App Store Connect field under `appstore/metadata/en-US/` (name, subtitle, description, keywords, promotional_text, release_notes, privacy_url, support_url, marketing_url, beta_description), review contact details under `appstore/metadata/`, and five 2560x1600 frames under `appstore/screenshots/` that are a CSS rebuild of the app rather than a screen capture. `margin/target-mas/` is a build output directory, holding `Margin.pkg`; `appstore.yml:125-129` uploads `target-mas/*.pkg` as an artifact. `scripts/mas-package.sh` covers the distance Tauri does not: it stamps `CFBundleVersion` from the workflow run number (`mas-package.sh:27-30`), copies the provisioning profile into the bundle before signing, widens permissions so Apple can read every file (`mas-package.sh:33-39`), substitutes `__TEAM_ID__` into `entitlements.mas.plist`, signs nested code first and never with `--deep`, then `productbuild`s the result (`mas-package.sh:44-58`). `entitlements.mas.plist` declares four entitlements, each justified in a comment: app-sandbox, network.client, network.server for the loopback OAuth listener, and files.user-selected.read-write. `src-tauri/Info.plist:8-9` declares `ITSAppUsesNonExemptEncryption` false. Six Ruby scripts drive the Developer Portal and App Store Connect through fastlane's spaceship. None of this exists in the other three, and calendar and mail both have a Google OAuth loopback listener, so if either goes to the store it needs the same entitlement and the same review note. ## The updater One endpoint per app, all on GitHub releases: | repo | endpoint | pubkey | | --- | --- | --- | | margin | `.../priyanshujain/margin/releases/latest/download/latest.json` | real | | margin-calendar | `.../margin-calendar/releases/latest/download/latest.json` | real | | margin-docs | `.../margin-docs/releases/latest/download/latest.json` | `REPLACE_WITH_TAURI_SIGNER_PUBKEY` | | margin-mail | `.../margin-mail/releases/latest/download/latest.json` | `REPLACE_WITH_THE_MINISIGN_PUBLIC_KEY` | All at line 8 to 11 of each `src-tauri/tauri.release.conf.json`. Two of the four have never had a keypair generated, so neither has released. **All four already use the overlay workaround.** No `tauri.conf.json` carries `plugins.updater.pubkey`; all four keep it plus `bundle.createUpdaterArtifacts: true` in `tauri.release.conf.json`, merged with `--config src-tauri/tauri.release.conf.json` in the build args. What did not propagate is the reason. Only margin records it, and only outside `docs/`: `simplify/guidelines/distribution.md:44` and `simplify/.research/memories-raw.md:283` name tauri-apps/tauri#14581, that the mere presence of the pubkey makes `tauri build` demand a signing key and would break the key-free local build (`margin/package.json` still has `"dmg": "tauri build --bundles dmg"` with no overlay). The three sibling `docs/release.md` files describe the overlay as "where the public half lives" and give no reason, so the next person to tidy a config has nothing telling them not to inline it. The overlay carries a second job nobody has written down: it is the flag that switches the plugin on. Every app registers the plugin conditionally on the merged config, ported verbatim four times: `margin/src-tauri/src/lib.rs:159-161`, `margin-caledar/src-tauri/src/lib.rs:260-262`, `margin-editor/src-tauri/src/lib.rs:263-265`, `margin-mail/src-tauri/src/lib.rs:265-267`. Two of the comments say "Ported from margin's lib.rs" outright. `margin/src-tauri/src/updates.rs` is the only per-channel logic anywhere. `channel()` at lines 17 to 26 reads which plugin key the merged config declares, `updater` meaning direct download and `appstore` meaning store, and a `_MASReceipt` in the bundle overrides both (lines 28 to 37), so a store build cannot self-update even if built with the updater in it. `appstore_latest()` at lines 51 to 84 asks `itunes.apple.com/lookup` with a cache-busting timestamp. No sibling has or needs this. Release notes are surfaced but empty. All four create the draft with `--notes "Release $TAG"` (`release.yml:78` in calendar, docs and mail; `:84` in margin), tauri-action copies the release body into `latest.json`, so `update.body` is the literal string "Release v0.1.18". `margin-editor/src/update.ts:79` passes that into `useUpdate.offer(version, notes)`, which `store/useUpdate.ts:39` calls "the release notes, as the release wrote them". The four update UIs are four different things: margin has `src/updater.ts` (111 lines) plus an `UpdateDialog.tsx` and a store; margin-docs has the most developed, `src/update.ts` (171 lines) with a daily background check, a 6 second launch delay and explicit handling of "this build has no updater in it" (`update.ts:39-56`); margin-calendar has a 41 line toast-only version (`src/keys/updates.ts`) whose header says it is margin's minus the dialog; margin-mail has no updater module, just an inline `checkForUpdates` in `App.tsx:98-118` and a panel in `screens/Settings.tsx:2465-2540`. `packaged_by()` is the Nix escape hatch, ported twice: `margin-caledar/src-tauri/src/lib.rs:240-245` reading `MARGIN_CALENDAR_PACKAGED_BY`, `margin-mail/src-tauri/src/lib.rs:197-201` reading `MARGIN_MAIL_PACKAGED_BY`. mail reads a variable nothing sets, because mail has no Nix package. ## Versioning Three files per app, all bumped by the release workflow and by nothing else: `.version` in `src-tauri/tauri.conf.json`, `.version` in `package.json`, and `[package] version` in `src-tauri/Cargo.toml`. `tauri.conf.json` is the source of truth, since the "leave empty to bump the patch" path reads it (`release.yml:31` in all four). All four are consistent right now: margin 0.1.17, calendar 0.0.5, docs 0.0.1, mail 0.0.1, with `Cargo.lock` matching in each. Nothing enforces it. There is no check in any `ci.yml` that the three agree, so the only thing keeping them together is that a human never edits one by hand. The `Cargo.lock` problem is history rather than theory. Only margin bumps the lock, with an awk pass and a comment explaining that the lock records the crate's own version (`margin/.github/workflows/release.yml:47-52`), and it is the only repo that adds `Cargo.lock` to the release commit (`:60`). margin-calendar does not, and its history carries two manual repair commits for exactly this: `3754e4a Sync the lock file to the version the crate declares` and `ae5a7b4 let cargo.lock catch up with the 0.0.4 bump`. docs and mail have the same gap and have not released yet. The bump itself is three different implementations. margin and margin-mail and margin-calendar use `sed -i "0,/^version = \".*\"/s//.../"` on `Cargo.toml`, which takes the first `version =` line in the file. margin-docs replaced it with a `[package]`-anchored awk pass plus a verification grep (`release.yml:58-75`), with a comment explaining that a long-form dependency puts `version = "0.4"` on its own line and bumping that one ships the version before. margin-mail's `Cargo.toml` is 6568 bytes with many long-form dependencies, so it is the repo most exposed to the bug and it has the old code. ## Linux, Windows, mobile What each app actually ships: | repo | macOS | Linux | Windows | store | | --- | --- | --- | --- | --- | | margin | universal dmg, signed and notarised, Homebrew cask | deb, rpm, AppImage from ubuntu-latest | msi and nsis from windows-latest | 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 today | none | none | none | | margin-mail | universal dmg, ad hoc signed today | deb and AppImage from ubuntu-22.04 | none | none | margin-calendar's `flake.nix` is 21 lines: one input, one system (`x86_64-linux`), an overlay and a package, both calling `nix/package.nix`. That file is 113 lines and repackages the published `.deb` rather than building from source, justified at lines 1 to 4 by the OAuth client being embedded at compile time from a file that is not in the repo. `autoPatchelfHook` relinks it against nixpkgs' webkit2gtk so it runs as a native Wayland client instead of the AppImage's Xwayland fallback, a generated launcher points libglvnd at nixpkgs' Mesa when `/run/opengl-driver` is absent (`package.nix:68-94`), and `preFixup` sets `MARGIN_CALENDAR_PACKAGED_BY=nix` (`package.nix:98-103`). `nix/release.json` is the pin, `{version, hash}`, currently 0.0.5. The `nix` job (`release.yml:187-241`) runs after publish, downloads the deb, hashes it, writes the pin, builds the package as proof, and pushes the pin to main with the same rebase loop as the version bump. `ci.yml:74-81` rebuilds it on every push. This replaced an AUR package, rationale at `docs/release.md:41-81`; no AUR file is left in the tree. margin-mail reads `MARGIN_MAIL_PACKAGED_BY` and documents the Nix behaviour at `docs/release.md:116-119` but ships no flake, so that path is dead code today. Mobile is scaffolded in two repos. `margin/src-tauri/gen/apple` is a committed iOS Xcode project; `margin-caledar/src-tauri/gen/` has both `apple` and `android` tracked, including `app/src/main/java/studio/margin/calendar/MainActivity.kt`. margin-docs and margin-mail have only `gen/schemas`, though margin-mail's include `iOS-schema.json` and `mobile-schema.json`. The desktop-only cfg gating is the same three lines in three repos, with the same comment ("There is no auto-updater and no process to restart on a phone: the store is the update channel"): `margin-caledar/src-tauri/Cargo.toml:43-46`, `margin-editor/src-tauri/Cargo.toml:84-87`, `margin-mail/src-tauri/Cargo.toml:134-137`, each gating `tauri-plugin-process` and `tauri-plugin-updater` behind `cfg(not(any(target_os = "android", target_os = "ios")))`. margin, the repo that actually has a committed iOS project, does not gate them: `margin/src-tauri/Cargo.toml:24-25` has both unconditional. `capabilities/desktop.json` is in all four with the same two permissions, `updater:default` and `process:allow-restart`. `src-tauri/build.rs` is two files across four repos: margin and margin-docs share one (39 bytes), margin-calendar and margin-mail share the credential-embedding one (1171 bytes), byte-identical. ## docs/release.md margin-calendar 105 lines, margin-docs 117, margin-mail 119. margin has none; its equivalent is `docs/publishing.md`, 229 lines, covering three distribution channels the others do not have. The "Installing locally" opening is near-identical in all three, down to "It is the same command whether or not the app is already installed, so it doubles as the update" and the sentence about a bundle going half old and half new. "Cutting a release" is the same paragraph in all three with the app's own manifest list. "Windows is not built" appears in all three, calendar and mail sharing the identical follow-up about a runner, `msi`/`nsis`, and the gate then wanting `windows-x86_64`. Where they genuinely diverge: calendar has a 41 line Linux and Nix section nobody else has; docs has a long honest section on self-update being impossible until a Developer ID certificate exists (`docs/release.md:105-117`) and a paragraph on why the bundle asks for the hardened runtime and no entitlements; mail has a Signing section built around `~/.margin-signing` with a `gh secret set` recipe (`docs/release.md:37-48`) and a "Before the first release" section covering both the missing updater key and Google restricted scope verification. The signing advice contradicts itself across the set. docs tells the reader to use `APPLE_ID` and an app-specific password (`docs/release.md:73-75`), mail and margin tell them to use an App Store Connect key. Both cannot be the house rule. ## margin/website Astro 5, one page, deployed by hand: `package.json` has `"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"`. No workflow deploys it. Downloads resolve at build time, not at request time. `website/src/data/release.ts:29-45` fetches `api.github.com/repos/priyanshujain/margin/releases/latest`, picks one asset per platform by filename suffix from `site.ts:40-43` (`.dmg`; `.exe` then `.msi`; `.deb` then `.AppImage` then `.rpm`), and falls back to the releases page on any error including the 8 second timeout. Astro runs that once at build, so the buttons point at whatever was latest when the site was last deployed and a release not followed by a deploy leaves stale links. `site.ts:23` carries the repo slug, so the whole thing is one constant away from serving a sibling app. ## What to build **One reusable workflow, `workflow_call`, in a shared repo.** The publish job goes in verbatim, the prepare job goes in with the title as an input, and the build job goes in with the matrix as an input. Inputs the four repos actually differ on, and nothing else: - `app-name`: release title and, on margin, the dmg filename in the Homebrew job. - `platforms`: a list like `macos,linux,windows`, driving both the build matrix and the `latest.json` key list in publish. Those two must not be able to disagree; today they are two hand-edited lists in every repo. - `linux-runner`: default `ubuntu-22.04`, since three repos already agree that is the right glibc baseline and margin's `ubuntu-latest` is an oversight. - `project-path` and `sibling-repos`: empty for margin and calendar, `rust/margin-mail` plus `priyanshujain/margin` for mail, and the same for docs, which needs it and does not have it. - `needs-google-credentials`: boolean. margin, calendar and mail true; docs false. - `post-publish`: which trailing job runs, `homebrew` for margin, `nix` for calendar, none for the others. These are different enough that they should be separate reusable workflows the caller chains, not a flag. Take margin-docs' version validation, its `[package]`-anchored Cargo bump, its manifest-version and signature checks, and its half-configured signing failure as the baseline; add margin's `Cargo.lock` bump and its `codesign`/`spctl`/`stapler` verification. That combination exists in no repo today. Add `concurrency: group: release-${{ github.repository }}, cancel-in-progress: false` and `cache: pnpm` on `setup-node`, neither of which exists anywhere. The three `ci.yml` files should share a second reusable workflow with the same inputs plus a `rust-test-command` and an `extra-frontend-steps` hook, since margin-docs splits its Rust suite (`ci.yml:58,78`) and margin-mail adds docs and fonts checks (`ci.yml:44,51`). margin must call it. **A shared tauri.conf fragment.** Generate rather than fragment: Tauri's `--config` merge only helps at build time and the committed file still has to be readable. A small script in the shared package that takes app name, identifier, dev port, window size, targets and any extra CSP directives, and writes `tauri.conf.json`, with a `--check` mode wired into CI the way `pnpm fonts:check` already is. That kills four copies of the icon list, the category, the min system version, the base CSP and the window defaults, and it makes the odd ones out visible: margin's `targets: "all"` and the two repos that state `hardenedRuntime` redundantly. `tauri.release.conf.json` is four lines of structure and one pubkey. Generate it the same way, and put the tauri#14581 reason in the generator's header so it survives the next cleanup. **One signing procedure, documented once.** `margin/docs/publishing.md:193-229` plus `margin-mail/docs/release.md:18-48` is already the whole thing; it needs to be one page in the shared repo covering `~/.margin-signing`'s layout, the three certificate types and why they cannot be combined, and the App Store Connect key as the single notarisation credential. Settle the `APPLE_ID` versus API key question in favour of the key, and fix margin-docs' workflow to match. Move `scripts/apple-secrets.sh` and `apple-provision.rb` into the shared repo unchanged: they are already parameterised by `DIR`, `BUNDLE_ID` and `REPO`. Standardise on `APPLE_API_KEY_ID` as the secret name in all four, since margin and mail currently disagree. Add the `spctl` and `stapler` verification to the shared build job so margin-calendar stops shipping unsigned macOS bundles without anyone noticing. **One versioning convention.** `tauri.conf.json` is the source, the workflow writes `package.json`, `Cargo.toml` and `Cargo.lock`, and a CI check asserts all four agree. Roughly ten lines, and it would have prevented both of margin-calendar's manual repair commits. While there, replace `--notes "Release $TAG"` with `--generate-notes` or an extracted `CHANGELOG.md` section, since it feeds a dialog margin-docs already built. ## What genuinely cannot be shared The identifiers, product names, dev ports, window sizes and the CSP additions each app needs; those are inputs, not duplication. margin's App Store track. The listing text, the screenshots, the entitlements and the six Ruby scripts are about one app's submission. `mas-package.sh` and `entitlements.mas.plist` could be templated later if a second app goes to the store, but there is no second app and templating for a hypothetical one is worse than copying it when the day comes. margin's Homebrew job and margin-calendar's Nix job. Both are per-app distribution channels with per-app asset names, per-app tap or flake repos, and a per-app credential. They can be reusable workflows the caller chains, but they are not one job with a flag. `margin/website`. It is one product's marketing site, with pricing, an offer counter and store logos. `data/release.ts` is genuinely generic and could move to the shared package if a second app ever gets a site. The updater UIs. Four apps have four different answers to what should happen when an update is found, and margin's App Store channel logic in `updates.rs` has no meaning in the other three. The plugin registration block, the `packaged_by` command and the `capabilities/desktop.json` permissions are the same three things four times over and are worth sharing; the dialogs are not. The per-app minisign keypair. One key per app is correct: the pubkey is baked into every shipped binary and cannot be rotated without stranding installed copies, so a shared key would make one compromise a four-app problem.