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

29 KiB

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 productbuilds 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.