Releases
The automated canary-to-main flow that versions the repo and drafts the changelog.
Two long-lived branches carry the project: canary for development and main for production. Day-to-day work squash-merges into canary; when you're ready to cut a release you review and merge a single pull request that opens itself, and the changelog, version bump, tag, and GitHub release all follow automatically.
Turn on write permissions first
A fresh fork's Actions token is read-only
By default the release workflows get 403 Resource not accessible by integration, and no release PR opens until you grant write access.
Under Settings → Actions → General → Workflow permissions (the pre-push hook prints a direct link on your first push):
- Select Read and write permissions so workflows can branch, commit the changelog, and tag.
- Check Allow GitHub Actions to create and approve pull requests so the release PR can open.
- Click Save.

On a brand-new empty repo, push canary first (git push origin canary) so GitHub makes it the default branch. The pre-push hook (.github/scripts/ensure-remote-branches.ts) then seeds the release branches (main by default) on your next push (each a separate ref, so they never collide) and the release PR opens on its own. No gh and no repo-admin required.
Land work on canary with squash-merge
Feature PRs merge into canary as a squash: one commit per PR, then delete the branch. This matters because changelogen builds the changelog straight from commit subjects: one clean Conventional Commit per PR yields one clean changelog line, and a branch's messy work-in-progress history never leaks in. The auto-labeler.yml workflow posts this reminder on every PR opened against canary.
The lone exception is the canary → main release PR below, which merges with a merge commit so main keeps shared history with canary instead of diverging.
The release, one merge
A draft PR opens itself
Every push to canary runs auto-canary-into-main.yml, which opens a draft PR (canary → main, titled ci(release): 🚀 merge canary into main) if one isn't already open.
You review and merge with a merge commit
Mark the draft ready, review it, and merge with a merge commit. That merge is what triggers auto-release.yml.
Changelog, version, and release (automatic)
auto-release.yml checks out canary and:
- generates
CHANGELOG.mdfrom the lastv*tag, - bumps the patch version in
package.json(or ships the exact version you hand-set, see Pinning a version), - refreshes the build-graph snapshot at
.github/assets/graph-build.svg, - commits
ci(changelog): update changelog and bump versiondirectly ontocanary, - tags
v<version>and pushes branch and tag atomically, - publishes a GitHub release whose notes mirror the new
CHANGELOG.mdsection.
If the commit range holds only filtered-out types (like ci), it skips the release.
The changelog commit lands directly on canary rather than through a second PR because it's mechanical, generated from PR titles you already reviewed. If a run tags the version but fails before publishing, the next run backfills the missing GitHub release.
Pinning a version
Ordinary releases bump the patch (0.1.0 -> 0.1.1 -> ...). To cut something bigger (a minor like 0.2.0 or a major like 1.0.0), set the version in the root package.json yourself on canary before you merge the release PR. auto-release.yml notices a hand-set version that is ahead of the last v* tag and ships that exact number (retargeting the generated changelog section to it), then resumes patch-bumping from it on the next release.
How it deploys
Both branches deploy through Vercel. Drizzle migrations run at API build time via .github/scripts/migrate-on-deploy.ts, but only when VERCEL_ENV=production or the ref is canary; every other deploy (including PR previews) logs a skip and exits, so an unmerged migration never touches the shared database. Those two builds therefore need a valid POSTGRES_URL.
Next
- Code Quality: the conventional commits and hooks that feed the changelog.
- Deploy to Vercel: where
mainandcanaryactually ship.