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
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:
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.
https://github.com/leo1oel/lattice/releases
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.mdseeded 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: 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.
- Checkout with
fetch-depth: 0— the release notes need the full tag history. - Generate release notes. Finds the nearest released ancestor tag with
git describe --tags --abbrev=0 <tag>^, lists every non-merge commit in that range as- <subject> (<short hash>), and skips theRelease v…commit itself. Falls back to "Maintenance release." when the range is empty. Puts.github/release-notes/<tag>.mdabove 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. - Toolchains. pnpm 10, Node 22, Bun 1.4.4, stable Rust with the
aarch64-apple-darwintarget. - Rust cache.
Swatinem/rust-cache@v2reads the warm dependency build produced byrelease-cache.ymlonmain. Its shared key includesbundle.macOS.minimumSystemVersion, because Tauri exports that value asMACOSX_DEPLOYMENT_TARGETbefore compiling and Cargo cannot reuse artifacts warmed without it.save-if: falseprevents the tag build from writing a cache no later release could read. pnpm install --frozen-lockfile.- Fetch the pinned Synara source. The workflow reads
repositoryandrevisionfromscripts/synara-runtime.json, shallow-fetches exactly that revision into$RUNNER_TEMP/lattice-synara, assertsHEADequals the pin, and exportsSYNARA_SOURCE_DIRandBUN_BIN. - Fetch the licensed interface fonts. With the
LATTICE_FONTS_DEPLOY_KEYsecret, the workflow clones the privateleo1oel/lattice-fontsrepository into$RUNNER_TEMP/lattice-fontsand exportsLATTICE_PRIVATE_FONTS_DIRandLATTICE_PRIVATE_FONTS_REQUIRED=1, so a missing or incomplete copy fails the build; without the secret it builds with the open-licensed fonts. Seedesign-system.md, "Private interface fonts". - Import the Apple Developer ID certificate. The workflow base64-decodes
APPLE_CERTIFICATEinto 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 theDeveloper ID Applicationidentity, and exports it asAPPLE_SIGNING_IDENTITY. - Restore the prepared Synara runtime. The restore-only cache is written on
mainand keyed by the target platform,scripts/synara-runtime.json, andscripts/prepare-synara-sidecar.mjs. A miss runsbun install --frozen-lockfileand letspnpm prepare:buildbuild 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. - Prepare the App Store Connect API key. The workflow writes
APPLE_API_PRIVATE_KEYto$RUNNER_TEMP/AuthKey_$APPLE_API_KEY.p8with mode 600 and exportsAPPLE_API_KEY_PATH. - Build, sign, and publish with
tauri-apps/tauri-action@v1:
args: --target aarch64-apple-darwin(this is what makes the updater platform keydarwin-aarch64);releaseDraft: true— the Release stays a draft until verification passes;uploadUpdaterJson: true— generates and uploadslatest.json, which the in-app updater reads fromhttps://github.com/leo1oel/lattice/releases/latest/download/latest.json(seeplugins.updater.endpointsintauri.conf.json);TAURI_SIGNING_PRIVATE_KEY/..._PASSWORDsign the updater artifact (bundle.createUpdaterArtifactsis on);LATTICE_FIRECRAWL_KEYis compiled into the binary here viaoption_env!insrc-tauri/src/literature_credentials.rs. It is extractable from the shipped app — that is a known and accepted trade-off; rotate it on abuse.
- Check the built app for loose font files with
node scripts/check-font-leaks.mjs app <Lattice.app>, before it is packaged any further. - Notarize.
xcrun notarytool submit <dmg> --waitwith the API key, thenxcrun stapler staple <dmg>. - Verify before publishing. On the DMG:
codesign --verify --strict,spctl --assess --type open --context context:primary-signature, andxcrun stapler validate. Then it mounts the DMG and repeats on the.appinside:codesign --verify --deep --strict,spctl --assess --type execute,xcrun stapler validate. - 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). - Check the release assets for font files. Downloads the draft's assets
and runs
node scripts/check-font-leaks.mjs assets <dir>: only the signed artifacts andlatest.json, none of them a font. - 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 https://leo1oel.github.io/lattice/demo/ 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-darwinlocally. Without an Apple Developer ID this produces an ad-hoc-signed app (signingIdentity: "-"intauri.conf.json), which macOS Gatekeeper will quarantine on first open. Users must right-click → Open, or clear the flag withxattr -dr com.apple.quarantine /Applications/Lattice.app.- Ship without the in-app updater, or point
plugins.updater.endpointsat your own release host and sign updater artifacts with your own key pair (see below). Updater artifacts built withoutTAURI_SIGNING_PRIVATE_KEYare 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.
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 overtauri.conf.json, gives the identity: identifierapp.leo1oel.researchwriter.beta, product and binary nameLattice 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 bysrc-tauri/build.rsandvite.config.ts, moves the browser host tohttp://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:
-
Generate a new pair:
pnpm tauri signer generate -w ~/.lattice-secrets/updater-YYYY-MM/updater.keySet a strong password, and write the private key outside every repository and worktree.
-
Put the public key into
src-tauri/tauri.conf.jsonunderplugins.updater.pubkey. Commit that; it is meant to be public. -
Put the private key into the
TAURI_SIGNING_PRIVATE_KEYGitHub secret (the full file contents), and its password intoTAURI_SIGNING_PRIVATE_KEY_PASSWORD. Never commit it, never paste it into an issue, and do not keep it in a shared drive or chat. -
Cut the next release normally.
-
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.