← All field notes

02 Release engineering

A repo that releases itself

One monorepo, two apps, an automated version bump, and a macOS DMG that ends up in Cloudflare R2 - the setup, the stale-checkout bug that broke it, and what the pipeline actually does.

useagent started as a single Tauri app. It grew into a small monorepo with two packages - the native app itself and a TanStack Start landing site - because the two ship on totally different cadences and targets. The repo is a pnpm workspace managed by Turborepo, and the interesting part is what happens between "commit on main" and "new DMG is somewhere on the internet".

The shape of the repo

A pnpm-workspace.yaml with a single apps/* glob, and a turbo.json that knows a build depends on its dependencies' builds. Two apps, one install, one task graph. The app builds a native bundle; the landing page deploys straight to Cloudflare Workers. Keeping them in one repo means a single set of tools and one place to coordinate versions.

.changeset/*.mddescribes the bumpversion:packageschangesets versionsync tauritauri.conf.json

Versioning is driven by Changesets. You add a small markdown file describing a bump, a CI check refuses to merge a PR touching the app without one, and on merge a version workflow consumes it. Because the native app also carries a version in tauri.conf.json, a tiny sync script reads it from the package version and writes it into the Tauri config - so one source of truth, nothing drifting.

The release that almost didn't happen

The intent was elegant. Push to main, the version job bumps the version and pushes chore: release usagent@vX.Y.Z, then the build job runs on a macOS runner, compiles the DMG, and uploads it to R2 while attaching it to a GitHub Release.

you push to mainpush / PRmerged to mainversion workflowchangesets apply bumpchore: releasetag usagent@vX.Y.Zbuild workflow (macos runner)checkout refthe version commitcargo buildpnpm tauri buildusagent.dmgaarch64 · sha256cloudflare r2s3://usagent/vX.Y.Z/*.dmgconsumerslanding pagelatest.json → dmggithub releaseattach dmggatekeeper realityunsigned ⇒ run from source

It failed on the very first real run. "Version mismatch: expected 0.2.2, got 0.2.1." The version job had pushed the bump, but the release workflow, invoked via workflow_call, was checking out the commit that triggered it - the pre-bump merge commit - then asserting the built version against the wrong tree.

The stale-checkout bugA workflow that runs a few seconds after another has pushed should check out the pushed commit, not the original trigger commit.

The fix is explicit and boring: the version job resolves the bumped commit's SHA and passes it as an input, and the release workflow checks out exactly that ref. Same as asserting anything else - pin the thing you are asserting against. After that, bumps chain automatically and the failed-run folklore is a thing of the past.

Then the distribution reality

The pipeline does its job: every merged release compiles, uploads the DMG to R2 at s3://usagent/vX.Y.Z/*.dmg, updates a latest.json so the landing page can point at the newest build, and attaches the artifact to a GitHub Release for the audit trail.

The mismatch is Gatekeeper. A free, ad-hoc-signed, un-notarized build triggers macOS's "can't verify the developer" warning, and notarization requires a paid Apple Developer account. Right-click to Open even reports the app "is damaged" because the signature isn't accepted. For a tool that reads each user's own local agents, the honest distribution path was decided: run it from source.

TakeawayThe build pipeline still archives every version to R2 - useful later - but the primary path for users is the repo, where the app actually runs cleanly.

So the repo does two jobs at once. It is the shipping line for the app's own releases, and it is the product's front door - README, run-from-source steps, and all. Sometimes the most reliable features are the ones the pipeline doesn't have to sign.

Back to the full repo notes →