some more fixes

This commit is contained in:
pj committed 2026-10-03 22:48:49 +05:30
1 parent 7a3c04f170
commit 2b86a407ba
63 files changed
+12172 -44

No files matched your search

+448
View File
@@ -0,0 +1,448 @@
# 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.