← Back to the section

Say a team has an automated pipeline: tests run on every commit, builds are automatic. But the developers work in branches that live for several weeks. And when the time comes to merge, disaster strikes: hours of conflicts, bugs at the seam between two big changes that nobody tested together. No pipeline will save you from that. The problem isn't the tooling — it's how branches and the release cycle are organized.

Why long branches break integration

Merge conflicts are a symptom. The real problem is that while a branch lives on its own, it drifts away from what's in the main branch. That drift grows with every day. After two weeks, two branches have diverged so much that even an automatic merge guarantees nothing: the code merged technically, but not logically. Bugs show up in production.

There's an important pattern here: integration complexity grows quadratically with the branch's lifetime. A one-day branch is a trivial merge. A one-month branch is a month of drift and half a day of conflicts.

Continuous integration in its original sense means exactly this: developers integrate their changes into the shared branch often, every day. Not "the server runs tests," but frequent integration of the code itself.

Trunk-based development

This is an approach where there's a single main branch (usually main), and all changes land in it quickly — through small, short-lived branches. "Short-lived" means a day or two, at most a few days, not weeks.

Big tasks don't disappear in this model. They're cut into a sequence of small steps, each of which can be safely merged into main. If a feature isn't ready to show to users yet, the code still lands in main, but behind a turned-off flag — more on that below.

The old GitFlow — a develop branch, release branches, hotfix branches — made sense for shrink-wrapped software released a few times a year. For a service with continuous delivery it adds overhead without real protection. The exception is supporting several versions at once: when you need to patch version 1.x and 2.x in parallel, long-lived branches are justified. But that's not the typical case for an ordinary web service.

How a PR works in practice

Trunk-based isn't just "shorter branches." It rests on several team norms.

A small PR is one logical unit. That can be one vertical slice of a feature, one fix, one refactoring step. Nobody truly reviews a thousand-line PR — they skim it and approve. The bugs stay.

Review within the day. If a PR sits for a week, the author is blocked, conflicts pile up in their branch, and motivation drops. In trunk-based teams, reviewing someone else's code ranks higher than writing your own new code.

A green pipeline before review. The reviewer looks at design and meaning rather than catching syntax errors. Let the machine catch what the machine can catch.

Squash on merge. The history in main is a stream of meaningful changes, one entry per PR. Not "fix," "fix2," "let me try another way" — but a single entry with a clear description of what was done.

Feature flags: how to hide the unfinished

Say a new feature can't be shown to users until it's fully ready. The classic answer is to keep the branch open until it's done. That's exactly what leads to multi-week branches.

The alternative is a feature flag: the code goes into main and gets deployed, but the feature is turned off. When product decides "okay, it's ready," the flag is flipped on and the feature becomes visible. This separates deploying code from releasing a feature: deploys happen often and safely, while turning a feature on is a separate decision.

Flags range from simple boolean variables in configuration to full-fledged systems like Unleash, which let you enable a feature for individual users, a percentage of the audience, or specific groups.

How to assign versions

For a service that deploys continuously, the version is the commit SHA. Every commit in main is a potential release. The SHA unambiguously identifies exactly what's running in production right now. It's visible in /actuator/info and in the logs. Human-friendly numbers like "1.4.2" aren't needed here.

SemVer (major.minor.patch) belongs where there are external consumers of a contract: libraries, public APIs, versioned events. The numbers convey the meaning of a change:

  • patch — a fix without changing the contract;
  • minor — new functionality, backward compatibility preserved;
  • major — a breaking change, consumers must adapt.

Assigning these numbers by hand is a bad idea: people make mistakes and get confused. SemVer is derived automatically from conventional commits — a standard format for commit messages:

feat: add a search-by-tags method
fix: fix result sorting
feat!: change the API response format (breaking change)

From such a log, tools work out which number to bump and generate a changelog on their own.

GitOps: what's in git is what's in production

Traditional deploy: someone runs a script, posts "deploying" in chat, presses a button. What's in production right now can only be learned by asking whoever deployed last. Rollback is a manual operation.

GitOps flips this around: the desired state of production is stored in a git repository. A tool (usually Argo CD) watches that repository and makes sure the cluster holds exactly what's described there. No manual deploys — only changes in git.

Practical consequences:

  • deploy history = git log — it's clear what changed, when, and by whom;
  • rollback = revert — an ordinary git operation, reproducible and understandable;
  • auditing is free — everything is documented in commits.

The loop from commit to production

Here's what the full cycle looks like in a team with trunk-based + GitOps:

PR → pipeline (tests, linter, build) → merge into main
→ image tagged with SHA → automatic deploy to staging
→ verification → PR with a new tag into the GitOps repository
→ Argo CD updates production
→ flip the flag when the feature is ready for users

There are two human decisions in this loop: approve the PR and flip the flag. Everything else is automated.

Common mistakes

ProblemWhat happensHow to fix
A feature branch lives for a monthConflict-ridden merge, untested seamsCut into small PRs + feature flags
Develop + release + hotfix for an ordinary serviceExcess ceremony, unclear where the truth isTrunk-based: main is the single source of truth
A PR sits for a weekBlocking, conflicts, demotivationReview within the day as a team norm
Version "1.0-final-new2" by handUnclear what's in productionSHA for services, auto-SemVer for libraries
Deploy "by agreement in chat"Not repeatable, not traceableGitOps: a PR into the config repository is the only path
A huge "the whole feature at once" PRDiagonal review, bugs in productionVertical slices; flags for what's not ready

In short

  • Long branches create conflicts and untested seams — complexity grows quadratically with the branch's lifetime.
  • Trunk-based development: a single main branch, changes land in it quickly through small short-lived branches.
  • Unfinished code hides behind feature flags — this separates deploying code from turning a feature on.
  • For services — SHA as the version; for libraries and public APIs — SemVer, derived automatically from conventional commits.
  • GitOps: the desired state of production is described in git, and Argo CD keeps things in sync. Rollback = revert, deploy history = git log.
  • Squash on merge keeps the main history readable.
  • Pipeline principles — a green main and deploy ≠ release.
  • CI: build, tests, quality gates — the gates every PR passes through.
  • Release strategies — rolling, blue-green, canary, and feature flags in detail.
  • Deploying to Kubernetes — how GitOps works at the cluster level.