ADR-0024: Git Branching Model — dev for Integration, main for Releases
Date: 2026-07-18 Status: Accepted
Context
Until now the repository used a single long-lived branch, main. Feature work,
bug fixes, chores, and release-preparation commits all landed directly on
main, interleaved. Two forces make this arrangement increasingly awkward:
- Releases are tag-driven. ADR-0020
publishes a stable release only when a
v*.*.*tag is pushed, andrelease.ymlalready verifies that the tag points at a commit onmain. Nothing else aboutmaindistinguishes "released" commits from "work-in-progress" commits — the branch is a mix of both. mainalso drives the rollingunstablesnapshot. A job inbuild.ymlre-points theunstablepre-release on every push tomain. Somainis simultaneously the integration branch and the release branch, and theCHANGELOG.md[Unreleased]section accumulates directly on the same branch that is supposed to represent shipped software.
There is no branch whose state answers the question "what is the latest
released version?" without inspecting tags, and no branch that represents
"everything merged since the last release" other than main itself.
Decision
Adopt a two-tier long-lived branch model.
mainis release-only. Every commit onmaincorresponds to a released, tagged version.mainmoves only when a release is cut. ItsHEADalways equals the most recent published release. No feature or fix is committed tomaindirectly.devis the integration branch. It is the default target for day-to-day work and always contains "everything merged since the last release." TheCHANGELOG.md[Unreleased]section lives and grows ondev.- Feature branches (
feat/…,fix/…,chore/…,docs/…) branch fromdevand merge back intodev, normally via pull request. Merging a branch intodevis what adds its entry to[Unreleased]. - Releasing is
dev→main+ tag. To release,devis merged intomain, the release-preparation commit renames[Unreleased]to## [X.Y.Z] — YYYY-MM-DD(see the changelog policy inCLAUDE.md), and avX.Y.Ztag is pushed onmain. The tag triggersrelease.yml(ADR-0020). After the release,mainis merged back intodevso the two branches share the release commit. - Hotfixes for an already-released version branch from
main(fix/…), merge intomain, are released via a patch tag, and are then merged back intodev.
Version numbering (semantic versioning, ADR-0001) and the tag-triggered pipeline (ADR-0020) are unchanged; this ADR only changes which branch carries integration versus release state.
CI consequences (applied in this change)
- The
unstablepre-release job inbuild.ymlis removed. Withdevin place, the tip ofdevis the rolling "latest integrated" state, so a separateunstableGitHub pre-release (and its force-pushedunstabletag) is redundant. This supersedes theunstablepre-release introduced by ADR-0020; per-run fat-JAR artifacts remain available on each Actions run. sonar.ymlruns its push analysis on bothmainanddevso the integration branch is quality-gated.docs.ymldeploys documentation fromdev, so ADRs and design docs publish when they are integrated rather than only at release time.release.ymlis unchanged: it still triggers onv*.*.*tags and still requires the tag to be an ancestor ofmain.
Rationale
mainbecomes a truthful release ledger.git log mainis exactly the release history;main@HEADis exactly what users get from Homebrew / the PPA. This mirrors the intent already encoded inrelease.yml's "tag must be on main" check, and makes it structural rather than incidental.[Unreleased]gets a natural home. The changelog's unreleased section is, by definition, "merged but not released" — precisely the contents ofdev. Keeping it offmainremoves the oddity of a shipped branch carrying an[Unreleased]heading between releases.- Deliberate releases, continuous integration. Work still flows
continuously into
dev(whoseHEADis the latest integrated code), while releasing stays an explicit, low-frequency act (adev→mainmerge plus a tag), consistent with ADR-0020's "releases are deliberate" rationale. - Low ceremony. Two long-lived branches and short-lived topic branches is the smallest model that separates integration from release; it does not require release branches or a full git-flow.
Consequences
- A
devbranch is created from the currentmainand becomes the default branch for development; branch protection should forbid direct pushes tomain. (Amended 2026-08-07: protection ondevmust not require a pull request — see the amendment below.) - Contributors branch from
dev, notmain. The default branch on the remote should be set todevso clones and PRs target it by default. - The release checklist gains a first step: merge
devintomainbefore tagging (and mergemainback intodevafterwards). The existing ADR-0020 / ADR-0023 release steps follow unchanged. - The
unstableGitHub pre-release and itsunstabletag are retired; users who want the latest build take the tip ofdevor a per-run Actions artifact. - Published documentation tracks
dev; a doc fix is visible once merged, not only after the next release. - Existing open work branched from
main(e.g. the branch introducing this ADR) is retargeted ontodev.
Amendment (2026-08-07): pull requests are not required for dev
The original decision said feature branches merge into dev "normally via pull
request" and that branch protection "should require PRs into dev". In practice
that made routine bookkeeping disproportionately expensive: bumping the version
to the next -SNAPSHOT after a release is a one-line change with nothing to
review, yet it cost a branch, a pull request, and a merge.
Amended decision
- A pull request remains the default for any change with reviewable content — new behaviour, bug fixes of substance, grammar or model changes, anything a second reader would improve.
- Direct pushes to
devare permitted when the author judges the change low-risk andmvn verifyis green locally. Blessed examples:- release bookkeeping — version bumps, changelog close-out, compare links;
- CI and build configuration;
- small fixes carried by their own tests.
mainis unchanged. It stays release-only, is entered exclusively through adev→mainpull request, and thevX.Y.Ztag is pushed by a human.- Branch protection on
devshould forbid force-pushes and require the status checks to pass; it must not require a pull request.mainkeeps its pull-request requirement.
Rationale
The pull request was never what made a change to dev safe. build.yml has no
branch filter, so it runs on every push; sonar.yml analyses pushes on
branches: [main, dev]. A direct commit is therefore built, tested and
quality-gated exactly like a pull-requested one. What the pull request adds is
review — valuable for reviewable content, and pure overhead for a version bump.
One caveat worth weighing rather than legislating: docs.yml deploys the
documentation site on every push to dev, so a docs change pushed directly is
published immediately, with no staging step.
This amendment narrows only how a change enters dev. The two-tier model, the
role of each branch, and the release procedure are unaffected. The full release
runbook lives in docs/releasing.md.