AniSri

A gitflow branching strategy for a release-managed codebase

Four Maven poms share one version string. SNAPSHOT comes off before anything hits pre-prod. The release branch is built and verified ahead of the change window, so the window is a promotion of an artifact that already exists. Almost none of this is enforced by tooling. It still depends on someone reading the wiki correctly, twice, every release.

Insights Release engineering Published December 2023 Edited August 2025 Edit Assist: AniBot powered by Claude

The shape of it

Two branches are protected and nobody commits to them directly: main, which only holds what has shipped, and develop, where finished features land before they become a release candidate. Everything else is disposable. feature/* carries work in progress. release/* carries a numbered candidate through its final checks. Both get deleted when their job is done. The permanent record lives in merge history and the tag, not in a long-lived branch name.

This is the 2010 Vincent Driessen gitflow model, mostly unchanged, and still common on release-managed estates. Whether it is the right call depends on fit. Gitflow earns its ceremony when releases are coordinated across teams and gated outside git: a change advisory board, a fixed deployment window, a regulatory sign-off. Trunk-based development with feature flags is faster when the team can deploy the moment code merges. The tell is in the workflow itself: a release branch sits built and verified in pre-prod, waiting for a change window, because the team cannot ship on merge. Teams that can, skip this step.

The version string is the environment gate

The rule that pre-prod and production can only deploy from a release/* branch running a non-SNAPSHOT build is Maven's own versioning contract doing the enforcement, not a policy bolted on top of git. A -SNAPSHOT coordinate is deliberately mutable, it resolves to whatever was last deployed under that identifier, which is exactly what you want while a feature is still moving. Strip the suffix and the coordinate becomes permanent, that exact jar or image, forever. You cannot accidentally promote a moving target into production, because a moving target cannot resolve to a pinned build in the first place. The safety rail is the artifact system itself, not a separate policy layer someone has to remember to check.

Deployment gate, not just semver

Most teams treat SNAPSHOT as a versioning convention. Used this way it is a deployment gate, because Maven will not give you a deterministic resolve on a SNAPSHOT coordinate. The version string does the enforcing.

Four pom.xml files, one human keeping them in sync

Here is the part of the model that is still running on discipline instead of tooling. Four separate pom.xml files need the same version string, and the source document says it plainly: get it wrong and you will get a build error. That sentence, in bold, on a wiki page, is a documented failure mode being handled by asking someone to read carefully at the exact moment they are least likely to, mid-release, under a deadline.

If these four modules are a real Maven reactor, this should never be a four-file manual edit. A parent POM holds the version once, child modules inherit it, and the actual bump is a single command run as a CI step the moment a feature/* or release/* branch is created:

mvn versions:set -DnewVersion=2.4.0-SNAPSHOT -DprocessAllModules=true -DgenerateBackupPoms=false

That turns a step in a wiki that four different engineers have to execute identically into a step nobody has to remember at all. If the four poms are not actually a reactor, if they are independent artifacts that happen to need coincidentally matching versions, that is a structural smell worth naming on its own, not a versioning problem to work around.

Starting and finishing a feature

feature branch lifecycle
Branchcut from develop, named by ticket or by intent
Bumpversion moves forward with -SNAPSHOT attached
Testruns the full ladder through UAT, same as anything else
Mergeapproved work lands back on develop
Deletethe branch, its job is finished, history keeps the record

One default worth keeping: a feature/* branch cannot reach any real environment unless someone deliberately sets DEPLOY_FEATURE_BRANCHES=true for that run. In-progress work is invisible everywhere by default. Visibility is opt-in, not something you have to remember to prevent.

What this looks like with three people pushing at once

Three engineers, three feature branches, all cut from the same develop, all landing back on it at different times, then one release branch that carries the lot through to production. Press play.

branch flow, three devs, one release
R · checkout-timeout M · webhook-retry W · notif-batching develop release/2.4.0 tag / prod
main release/2.4.0 develop feature/checkout-timeout feature/webhook-retry feature/notif-batching
build dev test uat pre-prod prod

R, M and W branch off develop independently and commit at their own pace. Each merges back on its own schedule once it clears UAT. Once develop is stable, release/2.4.0 is cut, SNAPSHOT is stripped from all four poms, and the release branch is built through pre-prod before anything asks a change board for a window. The tag gets cut, the merge goes to both main and develop, and the release branch disappears. The prod chip is the only thing that lights up during the actual change window.

The release branch and the pre-prod head start

Building the release branch all the way to pre-prod before the change window opens is the best operational call in this model, and it is easy to miss why. By the time the CHG record exists, the image is already in the production Docker repository. The window itself is a promotion of an already-built, already-verified artifact, not a build-and-deploy race against a change board clock. If the CHG slips a day, nothing is lost. The artifact waits.

Compare that to building inside the window. Compile failures, flaky integration tests, and slow dependency pulls now happen inside the one hour you got approved for. Front-load the expensive, failure-prone work into a low-stakes rehearsal and leave the cheap, fast promotion for the window. That idea holds even if you dislike gitflow as a branching model.

Where the human is still the control plane

The part that has not caught up is the pipeline run screen. Getting a release to pre-prod, then to production, means setting eight to ten independent booleans by hand, in the right combination, twice: once for pre-prod with production stages held false, then again for cutover with a different set flipped. That is a checklist wearing pipeline clothes, and checklists fail when people are tired, rushed, or new.

Get one boolean wrong and the failure modes are not symmetric. Skip a test stage silently and you find out later, expensively. Flip a production deploy flag on the wrong run and an unverified build ships. The version-string gate only catches the SNAPSHOT case. It does nothing if someone runs the correct release branch with the wrong stages enabled.

Two fixes, neither exotic. Collapse the boolean matrix into named presets such as build-and-verify-to-preprod and promote-to-prod, so a human picks one action instead of ten switches. And put the hard rule, release/* branch pattern plus non-SNAPSHOT version, into a pipeline guard that fails fast on its own. The version gate and the pipeline guard should both hold independently. Right now only one of them is enforced by anything other than a careful reader.

The model, one screen

main
protected, no direct commits
Only receives merges from release/*. Always deployable, always what is actually running.
develop
protected, no direct commits
Integration branch. Receives merges from feature/* and release/*, feeds the next release/*.
feature/*
branched from develop
Version bumped with -SNAPSHOT on creation. Tested through UAT. Deleted after merging back to develop.
release/*
branched from develop
SNAPSHOT stripped, version matches the branch name across all four poms. Only source pre-prod and prod will accept.
tag
cut from release/* once approved
The permanent record of what shipped. The branch it came from is disposable, the tag is not.
after release
merge to main and develop
Both branches move forward together. release/* is deleted, the tag already has the history.