Giving a solo project a real branch model (and why it isn't overkill)
Shipped
v0.12.0 gave the fitness agent’s release process an actual shape. Work now lands on a feature branch, merges into dev, promotes to main, and the release tag is cut automatically when it gets there. I shipped the wiring first and left branch protection for next. Alongside it I cleared a backlog of dependency bumps, including moving the web builder from Node 22 to Node 26.
That’s the changelog. The part worth writing about is the actual wiring: what the research says about which branch model to pick, the CI that cuts a trustworthy tag without me touching it, and the dependency drift the sweep surfaced when a single major bump tripped an assumption. Here is the end-to-end build.
Setup: the branch model, and why a solo repo earns it
For a long time my honest answer was that a solo project needs no branch model at all. When you’re the only committer, committing straight to main is fine, and the branching is ceremony you pay for and never use. What changed wasn’t team size, it was who’s committing: an agent now opens PRs against this repo, a dependency bot bumps versions on its own schedule, and releases have to be repeatable.
It helps to anchor this in evidence rather than taste. DORA’s research on trunk-based development found that teams perform better on software delivery when they keep three or fewer active branches, merge to the mainline at least once a day, and avoid long code-freeze or integration phases (DORA: Trunk-based development). Short-lived branches drift less from the mainline, so merges stay small and conflicts stay rare. That research is also the reason I did not reach for GitFlow, which keeps a long-lived develop branch plus temporary release and hotfix supporting branches, built around scheduled, versioned releases with a separate stabilization phase (Atlassian: Gitflow workflow). That solves a problem a continuously-shipped side project does not have, so most of its machinery would sit unused while still charging me the overhead.
feature → dev → main is the middle, and it keeps the trunk-based property that matters: branches stay short and merge often. dev is the integration line that stays releasable, main is the line that gets tagged and deployed. The prerequisite is just two long-lived branches and the discipline that day-to-day work always starts from dev, never from main.
# main already exists and is the deploy line. Add the integration line.
git switch -c dev main
git push -u origin dev
# every change, mine or the agent's, starts from dev and never from main
git switch -c feature/auto-tag dev
I turned this on as plumbing first, deliberately before branch protection. Protection rules reference branches and required status checks, so switching them on before those branches and checks have a track record mostly just blocks you. DORA’s own writeup names an overly heavy review-and-approval gate as a common obstacle to actually adopting trunk-based development (DORA: Trunk-based development), which is the same failure in a different costume. The safer order is to create the branches, wire the flow, get CI green, watch one real release move all the way through, and only then add the rules on top of a path you’ve already seen work.
Build: CI on every PR into dev and main
The flow only earns its keep if something checks the integrated change before it advances. One workflow, triggered on pull requests into either line, builds and tests the web app. The same job runs whether you’re merging a feature into dev or promoting dev into main, so nothing reaches the deploy line without having passed on the integration line first.
# .github/workflows/ci.yml
name: ci
on:
pull_request:
branches: [dev, main] # runs on PRs into either line
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 26
- name: build and test
working-directory: web
run: |
npm ci
npm run build
npm test
Build: cut the release tag automatically, but only when the version is new
The release tag is cut automatically when a change reaches main. Automating it isn’t about saving a command. Semantic versioning only works as a communication tool if the number is trustworthy; the spec exists so a version conveys real meaning about what changed (Semantic Versioning 2.0.0). A manually applied tag is a step you eventually forget or apply inconsistently, and the moment that happens the version starts lying about what’s deployed.
A separate workflow runs on pushes to main, reads the version out of package.json, and tags only if no tag for that version exists yet. That guard is the whole point: every merge into main fires the job, but most merges do not bump the version, so the job has to be a no-op unless the number actually changed. Tying the tag to “this version reached main for the first time” collapses it to a single meaning, which is what lets the changelog, this dev log, and a future deploy all key off it without re-deriving whether something is really released.
# .github/workflows/release.yml
name: release
on:
push:
branches: [main] # fires on every merge into the deploy line
jobs:
tag:
runs-on: ubuntu-latest
permissions:
contents: write # required to push the tag back
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # full history so existing tags are visible
- name: read version from package.json
id: ver
run: echo "version=$(node -p "require('./web/package.json').version")" >> "$GITHUB_OUTPUT"
- name: skip if this version is already tagged
id: check
run: |
# -q --verify against refs/tags so only a real tag counts, not any ref
if git rev-parse -q --verify "refs/tags/v${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT" # version unchanged, do nothing
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: cut the release tag
if: steps.check.outputs.exists == 'false'
run: |
# an annotated tag needs a tagger identity; the runner has none by default
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ steps.ver.outputs.version }}" -m "Release v${{ steps.ver.outputs.version }}"
git push origin "v${{ steps.ver.outputs.version }}"
Build: do dependency work in small sweeps
The same release cleared a stack of bumps: react and its types, react-router-dom, lucide-react, vite and the vite react plugin, the setup-uv and checkout CI actions, and the web builder’s base image from Node 22 to Node 26. None of it is user-facing, which is exactly why it had been sitting untouched.
The major one, Node 22 to 26, is the kind of bump that makes the case for small regular sweeps over one heroic upgrade. Drift is invisible right up until a major version trips an assumption you didn’t know you’d made, and untangling several of those at once costs far more than handling one at a time. The Node jump is also a good reminder to check what changed at the runtime level: Corepack stopped shipping with Node as of 25 (Socket: Node.js TSC votes to stop distributing Corepack; nodejs/corepack).
One commit in the sweep installs Corepack explicitly in the build image. Because Corepack no longer ships with Node, enabling it now takes two steps: install it from npm first, then turn it on.
# web/Dockerfile
FROM node:26-bookworm-slim
# Corepack isn't bundled as of Node 25, so install it before enabling
RUN npm i -g corepack && corepack enable
That change is worth being honest about: it only matters if you pin pnpm or yarn through package.json’s packageManager field, since Corepack is what shims those. This project builds with npm, and npm still ships with Node 26, so the line was belt-and-suspenders rather than the thing the bump actually required. If you do use pnpm or yarn, that same npm i -g corepack && corepack enable (in the Dockerfile, and in the CI job above) is the substitution to make.
Use it: how a change flows through
With both workflows in place, the path is mechanical. A change starts on a feature branch and its PR targets dev, never main. Once dev is green and I want to ship, a second PR promotes dev into main, and merging that one is what fires the release workflow.
# 1. feature work lands via a PR into dev; ci.yml runs on it
gh pr create --base dev --head feature/auto-tag --fill
# 2. to ship, promote the integration line to the deploy line
gh pr create --base main --head dev --title "release: v0.12.0"
# merging the promotion PR pushes main, which triggers release.yml.
# release.yml reads the bumped version and cuts v0.12.0 because no tag exists yet.
A version bump on dev only becomes a real release at the moment it crosses into main. Nothing gets tagged speculatively from a feature branch, and a promotion that doesn’t change the version simply re-runs the guard and exits.
Verify: confirm the tag matches the shipped version
The whole point of automating the tag was to keep the version honest, so the check that proves it is a one-liner. After the promotion merges, fetch tags and assert that the latest tag matches the version in package.json. If they ever disagree, either the bump never happened or the release workflow didn’t run, and both are worth knowing immediately.
git fetch --tags
expected="v$(node -p "require('./web/package.json').version")"
# describe returns the nearest tag by history, not the highest semver; right
# after a promotion that is the tag just cut. To assert on exactly the merge
# commit instead, use: actual="$(git tag --points-at HEAD)"
# the 2>/dev/null || echo none fallback matters on a very first release, where
# HEAD has no reachable tag yet and bare describe would exit non-zero and abort
actual="$(git describe --tags --abbrev=0 2>/dev/null || echo none)"
if [ "$expected" = "$actual" ]; then
echo "release tag $actual matches package version"
else
echo "MISMATCH: package says $expected but latest tag is $actual" >&2
exit 1
fi
The guard inside the workflow handles the other direction: re-running a merge that didn’t bump the version finds the tag already present and skips, so a no-op promotion can never produce a duplicate or a misleading tag. Drop that assertion into CI later and the version can’t quietly start lying again.
Next
Branch protection on dev and main is the follow-up. The branches exist and have moved a real release end to end now, so the rules finally have something solid to attach to.
Sources
- DORA: Trunk-based development — research linking few short-lived branches and daily merges to higher delivery performance, and naming heavy approval gates as an adoption obstacle.
- Atlassian: Gitflow workflow — what GitFlow’s long-lived branches are built for.
- Semantic Versioning 2.0.0 — why a version number has to be trustworthy to mean anything.
- Socket: Node.js TSC votes to stop distributing Corepack and nodejs/corepack — Corepack no longer bundled as of Node 25.
Changelog
- chore: release 0.12.0, feature->dev->main branch model + auto-tag (#36) (#37) (94426f5)
- fix: install corepack explicitly for node:26 web-builder (1e4e80d)
- ci: adopt feature->dev->main branch model (plumbing, pre-protection) (df36aa6)
- chore(deps): bump react-router-dom, lucide-react, vite in /web (9255a52)
- chore(deps): bump react and @types/react in /web (#15) (d8e6a97)
- chore(deps): bump node from 22-bookworm-slim to 26-bookworm-slim (#29) (6502956)
- chore(deps): bump actions/checkout from 4 to 7 (#30) (9e1c09b)