# Release process How a Lattice release is actually cut today. Everything below is derived from `scripts/bump-version.mjs`, `.github/workflows/release.yml`, `.github/workflows/release-cache.yml`, and `src-tauri/tauri.conf.json`; if you change one of those, change this document with it. Releases target **Apple Silicon macOS only** (`--target aarch64-apple-darwin`, `minimumSystemVersion: 14.0`). ## Cut a release ```bash node scripts/bump-version.mjs patch # or: minor | major | 0.3.0 ``` The script only edits files — it does not commit or tag. It bumps the version in lockstep across `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, and the `research-writer` entry in `src-tauri/Cargo.lock` (and writes the What's new notes, below), then prints the exact next commands: ```bash git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/Cargo.lock src/release-notes/X.Y.Z.json git commit -m "Release vX.Y.Z" git tag vX.Y.Z git push origin main git push origin refs/tags/vX.Y.Z ``` Pushing the **tag** is what triggers the release. The workflow listens on `push: tags: [v*]`. ## Where the changelog lives **GitHub Releases is the changelog. There is deliberately no `CHANGELOG.md`.** The reasoning, so nobody re-opens this without new information: - The notes are **generated, not curated** — step 2 below builds them from the commit range between the new tag and the nearest released ancestor. That makes them complete by construction. A hand-maintained file would be complete only as long as somebody remembered, and there is no CI step that could enforce it. - The cadence makes a hand-written file untenable. There are **192 tags**, mostly same-week patch releases. A `CHANGELOG.md` seeded from "the recent tags" would either duplicate the generated notes or, worse, imply the ~170 earlier releases went undocumented — which is false; every one of them has notes. - Retro-fitting Keep a Changelog honestly is not possible here. The history has no conventional-commit discipline, so categorising 192 releases into Added/Changed/Fixed would mean guessing at commit subjects nobody re-verified. That is the exact failure mode this documentation pass exists to remove. - One source of truth beats two. If both existed, the generated one would be right and the written one would drift. The consequence for contributors is real and is stated in [`../CONTRIBUTING.md`](../CONTRIBUTING.md): **a commit subject is a changelog entry.** It is what a user reads in the release notes. The consequence for maintainers is that anything a generated line cannot convey — a minor version's highlights, a breaking change, a migration, the updater-key rotation warning below — goes in `.github/release-notes/vX.Y.Z.md`, committed with the version bump. The workflow puts that file above the generated list, so the lead is reviewed with the bump instead of being edited into a published Release. It is per-release and optional, not a running changelog. The same lead feeds the app's What's new sheet. Write it before running `bump-version.mjs`, which then writes `src/release-notes/X.Y.Z.json` from its highlights and fixes (`scripts/release-notes.mjs`) with the Chinese left empty; fill in every `zh` before the release commit (`src/release-notes/release-notes.test.ts` fails on an empty one). A release without a lead keeps showing the last one that had one. ## What `release.yml` does One job on `macos-latest` (Apple Silicon), with `contents: write` so it can create the Release and upload assets. 1. **Checkout** with `fetch-depth: 0` — the release notes need the full tag history. 2. **Generate release notes.** Finds the nearest released ancestor tag with `git describe --tags --abbrev=0 ^`, lists every non-merge commit in that range as `- ()`, and skips the `Release v…` commit itself. Falls back to "Maintenance release." when the range is empty. Puts `.github/release-notes/.md` above that list when the file exists. Appends the Apple Silicon install blurb and a `**Full Changelog**` compare link, then passes the whole thing to the build step through a heredoc-delimited `$GITHUB_OUTPUT`. 3. **Toolchains.** pnpm 10, Node 22, Bun 1.4.4, stable Rust with the `aarch64-apple-darwin` target. 4. **Rust cache.** `Swatinem/rust-cache@v2` reads the warm dependency build produced by `release-cache.yml` on `main`. Its shared key includes `bundle.macOS.minimumSystemVersion`, because Tauri exports that value as `MACOSX_DEPLOYMENT_TARGET` before compiling and Cargo cannot reuse artifacts warmed without it. `save-if: false` prevents the tag build from writing a cache no later release could read. 5. **`pnpm install --frozen-lockfile`.** 6. **Fetch the pinned Synara source.** The workflow reads `repository` and `revision` from `scripts/synara-runtime.json`, shallow-fetches exactly that revision into `$RUNNER_TEMP/lattice-synara`, asserts `HEAD` equals the pin, and exports `SYNARA_SOURCE_DIR` and `BUN_BIN`. 7. **Fetch the licensed interface fonts.** With the `LATTICE_FONTS_DEPLOY_KEY` secret, the workflow clones the private `leo1oel/lattice-fonts` repository into `$RUNNER_TEMP/lattice-fonts` and exports `LATTICE_PRIVATE_FONTS_DIR` and `LATTICE_PRIVATE_FONTS_REQUIRED=1`, so a missing or incomplete copy fails the build; without the secret it builds with the open-licensed fonts. See [`design-system.md`, "Private interface fonts"](design-system.md#private-interface-fonts). 8. **Import the Apple Developer ID certificate.** The workflow base64-decodes `APPLE_CERTIFICATE` into a `.p12`, creates a throwaway keychain, imports the certificate with `-T /usr/bin/codesign`, sets the key partition list so codesign can use it without prompting, makes it the active keychain, finds the `Developer ID Application` identity, and exports it as `APPLE_SIGNING_IDENTITY`. 9. **Restore the prepared Synara runtime.** The restore-only cache is written on `main` and keyed by the target platform, `scripts/synara-runtime.json`, and `scripts/prepare-synara-sidecar.mjs`. A miss runs `bun install --frozen-lockfile` and lets `pnpm prepare:build` build the runtime normally. A hit lets the preparation script validate the source-derived manifest and re-sign the cached unsigned binaries with the current Developer ID certificate without rebuilding Synara's four-minute web bundle. 10. **Prepare the App Store Connect API key.** The workflow writes `APPLE_API_PRIVATE_KEY` to `$RUNNER_TEMP/AuthKey_$APPLE_API_KEY.p8` with mode 600 and exports `APPLE_API_KEY_PATH`. 11. **Build, sign, and publish** with `tauri-apps/tauri-action@v1`: - `args: --target aarch64-apple-darwin` (this is what makes the updater platform key `darwin-aarch64`); - `releaseDraft: true` — the Release stays a **draft** until verification passes; - `uploadUpdaterJson: true` — generates and uploads `latest.json`, which the in-app updater reads from `https://github.com/leo1oel/lattice/releases/latest/download/latest.json` (see `plugins.updater.endpoints` in `tauri.conf.json`); - `TAURI_SIGNING_PRIVATE_KEY` / `..._PASSWORD` sign the updater artifact (`bundle.createUpdaterArtifacts` is on); - `LATTICE_FIRECRAWL_KEY` is compiled into the binary here via `option_env!` in `src-tauri/src/literature_credentials.rs`. It is extractable from the shipped app — that is a known and accepted trade-off; rotate it on abuse. 12. **Check the built app for loose font files** with `node scripts/check-font-leaks.mjs app `, before it is packaged any further. 13. **Notarize.** `xcrun notarytool submit --wait` with the API key, then `xcrun stapler staple `. 14. **Verify before publishing.** On the DMG: `codesign --verify --strict`, `spctl --assess --type open --context context:primary-signature`, and `xcrun stapler validate`. Then it mounts the DMG and repeats on the `.app` inside: `codesign --verify --deep --strict`, `spctl --assess --type execute`, `xcrun stapler validate`. 15. **Upload the stapled DMG** with `gh release upload --clobber` (the stapled ticket is added after tauri-action's own upload, so the asset is replaced). 16. **Check the release assets for font files.** Downloads the draft's assets and runs `node scripts/check-font-leaks.mjs assets `: only the signed artifacts and `latest.json`, none of them a font. 17. **Publish** with `gh release edit "$GITHUB_REF_NAME" --draft=false`. If any verification step fails, the Release simply stays a draft and nothing reaches users. ### `web-demo.yml` Once the Release workflow succeeds, `web-demo.yml` builds the website's interactive demo (`tools/web-demo/`, `node scripts/web-demo.mjs`) from the release's commit and publishes it to the `gh-pages` branch under `demo/`, so always runs the version people download. It writes nothing outside `demo/`: the rest of `gh-pages` is the website. The build is checked before anything is published (no Timeless font, by name or by bytes; no bench or tldraw code; the startup download within its budget; the app opens in headless Chrome), and a failed check leaves the previous demo up. ### `release-cache.yml` A separate workflow on `main` warms two caches in parallel. A push that changes no Rust input, such as a Synara pin bump, skips the Rust job; the weekly schedule and a manual run warm both. The Rust job compiles the release dependency graph with the same target and `MACOSX_DEPLOYMENT_TARGET` as Tauri, so a nominal cache hit does not trigger a second macOS dependency build inside the release. It deliberately does **not** set `LATTICE_FIRECRAWL_KEY`, and `rust-cache` removes the workspace crate before saving, so the key never lands in a cache artifact. The Synara job builds and stages the pinned runtime without a Developer ID certificate, then saves that unsigned directory on the default branch where tag workflows can restore it. The release re-signs the restored native binaries before Tauri packages them. Changing the Synara pin or preparation script creates a new cache key; the first release can still build on a miss, while later releases restore the warmed result. ## Required GitHub secrets | Secret | Used for | | --- | --- | | `APPLE_CERTIFICATE` | Base64-encoded Developer ID Application `.p12` | | `APPLE_CERTIFICATE_PASSWORD` | Password for that `.p12` | | `KEYCHAIN_PASSWORD` | Password for the temporary CI keychain (any value) | | `APPLE_API_KEY` | App Store Connect API **key ID** (also names the `.p8` file) | | `APPLE_API_ISSUER` | App Store Connect API issuer ID | | `APPLE_API_PRIVATE_KEY` | Contents of the App Store Connect `.p8` private key | | `TAURI_SIGNING_PRIVATE_KEY` | Updater signing private key (minisign) | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for it (empty string for the current key) | | `LATTICE_FIRECRAWL_KEY` | Shared Firecrawl key baked into release builds | | `VITE_TLDRAW_LICENSE_KEY` | tldraw license key inlined into the board editor; without it the release ships tldraw in its unlicensed, watermarked mode | | `LATTICE_FONTS_DEPLOY_KEY` | Read-only SSH deploy key for the private `leo1oel/lattice-fonts` repository (optional; without it the release ships the open-licensed fonts) | `GITHUB_TOKEN` is provided automatically by Actions; it does not need to be created. ## Forks cannot produce signed or notarized builds None of the Apple secrets are available to a fork, and the updater private key is not in the repository. A fork that pushes a `v*` tag will fail at the certificate-import step (`security find-identity` finds no `Developer ID Application` identity and the `test -n` assertion fails). What a fork can do instead: - `pnpm tauri build --target aarch64-apple-darwin` locally. Without an Apple Developer ID this produces an ad-hoc-signed app (`signingIdentity: "-"` in `tauri.conf.json`), which macOS Gatekeeper will quarantine on first open. Users must right-click → Open, or clear the flag with `xattr -dr com.apple.quarantine /Applications/Lattice.app`. - Ship without the in-app updater, or point `plugins.updater.endpoints` at your own release host and sign updater artifacts with your own key pair (see below). Updater artifacts built without `TAURI_SIGNING_PRIVATE_KEY` are unsigned and the installed app will refuse them. - Set your own `LATTICE_FIRECRAWL_KEY`, or ship without one — webpage import then asks the user for a key. ## Build Lattice Beta Lattice Beta is the same source built as a second app that installs and runs beside the release one. It is never published; `release.yml` builds only the release flavor, which `tauri-security-config.test.ts` checks. ```bash pnpm build:beta # extra tauri build flags go after it, e.g. --bundles app ``` That is `LATTICE_FLAVOR=beta tauri build --config src-tauri/tauri.beta.conf.json`, and it needs both halves: - `src-tauri/tauri.beta.conf.json`, merged over `tauri.conf.json`, gives the identity: identifier `app.leo1oel.researchwriter.beta`, product and binary name `Lattice Beta`, and no updater endpoint or artifacts. App data, caches, logs, WebKit data, managed tools and the literature keychain item all derive from the identifier (`src-tauri/src/app_identity.rs`), so Beta never touches the release app's. - `LATTICE_FLAVOR=beta`, read at build time by `src-tauri/build.rs` and `vite.config.ts`, moves the browser host to `http://127.0.0.1:18472` (`PREFERRED_PORT`, `src/platform/flavor.ts`), off the release app's 18452 and the Trellis prototype's 18462. A build with one half and not the other exits at launch, since it would share the release app's data or contend for its port. Any other `LATTICE_FLAVOR` value fails the build. The app lands in `src-tauri/target/release/bundle/macos/Lattice Beta.app`, ad-hoc signed like a fork's build (above). ## Rotating the updater signing key **Last rotated 2026-10-25** (new minisign key ID `B984A4F7ACF6F51B`). The previous key pair (key ID `352AE3E46AE8C892`) was generated in a third party's temporary environment (this is stated plainly in the setup notes this document replaces), delivered to the maintainer as a file, never rotated, and had an empty password. Anyone who still holds a copy of it can sign an artifact that every install of 0.12.0 or earlier accepts as a legitimate update, which is why it was replaced. Deleting the old documentation does not remove it from git history, and the private key's exposure has nothing to do with this repository's contents anyway; rotation was the only mitigation. The current private key has a password. The maintainer generated it locally and keeps the key and its password in `~/.lattice-secrets/updater-2026-10/` on their Mac (outside every repository), in the `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` GitHub secrets, and in an offline backup. Nothing else holds a copy. The first release built after the rotation has to carry the reinstall warning from step 5 below. A lead that can go into its `.github/release-notes/vX.Y.Z.md`: > **Please download and install this version manually, once.** This release is > signed with a new update key. Copies of Lattice 0.12.0 and earlier only > trust the old key, so they will not offer this update automatically. Download > the DMG from this page and install it over your current copy; your projects > and settings are kept, and automatic updates resume from here on. Runbook: 1. Generate a new pair: ```bash pnpm tauri signer generate -w ~/.lattice-secrets/updater-YYYY-MM/updater.key ``` Set a strong password, and write the private key outside every repository and worktree. 2. Put the **public** key into `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`. Commit that; it is meant to be public. 3. Put the **private** key into the `TAURI_SIGNING_PRIVATE_KEY` GitHub secret (the full file contents), and its password into `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Never commit it, never paste it into an issue, and do not keep it in a shared drive or chat. 4. Cut the next release normally. 5. **Warn users in the release notes.** Installed copies verify updates against the *old* public key, which is compiled into the app they are running. They will reject every artifact signed with the new key and will silently stop updating. Every existing install has to download and reinstall the DMG once, after which automatic updates resume. Say this at the top of the release notes for the rotating version, and consider keeping it in the notes for a release or two afterwards. Because of step 5, rotate at a moment you are willing to ask everyone to reinstall — ideally before the user base grows.