01 THINKHow does he reason?

There was no need to cut the release branch late

"It worked in QA but broke on deploy" is not an accident once it repeats. The combination verified is not the combination shipped.

"It worked in QA but broke on deploy."

On a team where that sentence repeats, people usually go looking for the accident — who left something out, why the tests failed to catch it. But depending on the branch layout, this is the outcome the design schedules.

What gets verifiedABCTestingA+B+CWhat gets deployedABReassembleA+BThis combination is never deployed

A+B+C was verified, but what ships is A+B

A, B and C are verified together on the integration branch. Deployment picks A and B and reassembles them on the release branch. A+B+C was verified and is not deployed; A+B is deployed and was never verified.

A large share of defects come not from an individual feature but from the combination. Change the combination and the verification result is void along with it. So "but it worked in QA" is not a people problem.

Why lay it out that way

Going from here to "so let us get rid of the release branch" is wrong. That layout exists for a reason.

Cut the release branch as late as possible and, at the moment you cut it, what can actually ship is already settled. You pick only what is going out. If one feature drops, you simply do not include it — no git to touch.

In an organisation on a fixed release cadence this is a sound design. It is a capability with no reason to give up, and that capability is exactly why the layout is used.

So the question is not "is this structure wrong". It is can the verified commit be the deployed commit while keeping the late decision?

The flexibility did not come from when you cut

I thought cutting late was what let you choose late. I was treating the two as one thing.

Late binding does not come from "cutting the branch late". It comes from "merging only what is ready".

It does not. The reason you can still choose is that you have not merged yet. When you cut is irrelevant.

So the release branch can be created at the start of the sprint and features merged into it one at a time as each passes QA. The choice is still made late. Exactly one thing changes — QA happens on the branch that actually ships. The block you verified and the block you deploy become the same block.

That is the route that fixes the structure without losing the capability.

When a feature drops

When you create it and what you put in it are separate decisions. So all four of these still work with the release branch created first.

MethodCostWhen
Fill late — leave it outgit 0 · infra 0the default. Always, if it is not merged yet
Flag — put it in, switched offflag infrastructurewhen several features share common code
Revert — put it in, take it backconflicts once entangledalready merged, cancelled just before deploy
Next releasewaitingwhen the schedule has definitely slipped

Two combinations are dangerous. For features sharing a common refactor, ③ reverts the other features too. And in the other direction, a DB schema change cannot be hidden behind ② — a schema does not sit behind a flag. Either leave it out, or ship the schema alone, compatibly, first.

Revert does not behave the way you think

Before using ③ there was something to check, so I ran it myself in a scratch repository. The result was not what I expected.

merge featC → revert → new commit on featC (a different file) → merge again

core.txt   absent
ui.txt     absent
memo.txt   present   ← only the new commit made after the revert

Re-merging a reverted merge does not bring the code back. git skips commits it has already merged. What comes back is only what was created after the revert, and with no new commits at all you get Already up to date.

The most dangerous part is that there is no conflict. It succeeds quietly. The owner thinks "but I merged it", and QA tests a feature that is not there.

One more thing came out of this. If the new commit overwrites the same file wholesale, the code sometimes comes back by accident. Which means the result is unpredictable, and that is worse than "it never comes back". Succeed by accident once and you learn the rule wrong.

The fix is to revert the revert: git revert <the revert commit>. Everything was restored exactly, in the experiment.

Two rules to set. A revert used for postponement records the commit hash — that is not information a person can hold in their head. And when cancellations are frequent, do not stack reverts: reassemble the release. Past two or three reverts the history stops being readable.

For an external integration, the above only half works

Because the decision is not ours.

The schedule is in someone else's hands. The partner's development, review, contract and launch date move independently of our sprint. Without notice. The call comes after QA is done and just before deploy. And because it is a postponement rather than a cancellation, the code cannot be thrown away either.

Shortening the release cycle changes none of those three.

What can be changed is putting deploy and launch on different days. The waiting state moves from "unfinished, stuck on a branch" to "in production, switched off". A postponement notice then has nothing to roll back. You only push the switch-on date.

And for external integrations specifically, this is nearly free even with no flag infrastructure. An external integration needs configuration anyway — an endpoint, an API key, a partner identifier. Write it so that without the configuration the integration path never starts, and that is the switch.

  • No configuration = inactive, not an error. But log "integration inactive". Otherwise nobody knows it never came on
  • Tie the UI visibility to the same switch. Block only the call and leave the button visible, and to a user it looks broken
  • Switched off, fall back to the pre-integration behaviour. The same switch serves during a partner outage

Skip it and this happens. The integration code waits two sprints on a branch while main moves hundreds of commits ahead. When the partner is finally ready and you merge, you start not with the integration logic but with the conflicts piled up since, and that code is not the code you first QA'd, so QA has to run again. The feature that waited longest ships in the most dangerous state.

What the four standards actually agreed on

That was one layout, taken apart. The standards each handle this problem differently.

StrategyLong-livedDisposableBranch lifetimeDeploy point
Git Flow (2010)main, developrelease/* hotfix/* feature/*days–weekswhen release stabilisation ends
GitHub Flowmainfeature/*hours–2 daysthe moment it merges to main
GitLab Flowmain, environment branchesfeature/* release/*hours–dayswhen flowing downstream
Trunk-Basedmainshort-lived featureunder a daycontinuously (exposure via flags)

All four share one invariant — deploy the commit you tested. What differs is only how short the branches are cut and how many long-lived branches there are.

Git Flow's author annotated his own post in 2020, saying teams that deploy continuously should use a simpler workflow like GitHub Flow, and that it is still valid only where explicit versioning is needed or several versions are supported at once. A 15-year-old standard whose author narrowed its own scope.

GitLab's own documentation no longer leads with the name "GitLab Flow". It only advises matching the level you need. The core of that strategy is that things flow one way — an environment branch never flows back up.

Trunk-Based presupposes feature flags. Do it without flag infrastructure and the unfinished work ships as it is.

Do not mix long-lived and disposable

There is one more shared premise. Even the original Git Flow post calls only master and develop main branches, and marks release/hotfix/feature as "supporting branches — branches with a limited life time, that will be removed eventually".

Long-lived branchDisposable branch
e.g.main, developfeature/*, release/*, hotfix/*
Lifetimeas long as the repository existsits reason to exist ends the moment it merges
Ruleone role eachdelete it once merged

Blur that distinction and the branch list loses its signal. You can no longer tell by eye what is alive, and you commit to a dead branch by mistake.

Structure and procedure have to be told apart

They cost completely different amounts to fix.

RiskHow to tell
Structureif the verification point and the deploy point differ, QA results do not transferit only goes away by changing the branch layout
Structurea long-lived branch that only flows one way cannot be a terminus of trustis there an arrow leaving this branch
Procedurea rollback procedure without tags is not a procedurecan you point at the last deploy with a single tag
Procedurecutting a branch off an old base accumulates conflictsa conflict-specific rule appeared = conflicts have become routine
Procedurenot deleting disposable branches costs the list its signalauto-delete on merge
Procedurea branch named in the docs that does not exist discredits every rulethe long-lived branch list lives in exactly one document

The test on the second row turned out to be especially useful. A branch with no outgoing arrow is not a main line, it is a puddle. A long-lived branch with only incoming arrows drifts further from what actually deploys as time passes, and verification on a branch unrelated to deployment is no grounds for deploying.

Do the procedure first

The procedure side can be done today without touching code.

  • Tag automatically on every deploy, with a prefix distinct from package tags (deploy/<date>-<n>)
  • Practise a rollback once, in calm conditions. A procedure that exists only in a document gets run for the first time during an incident
  • Turn on auto-delete of merged branches
  • Write the long-lived branch list in exactly one document

Structure comes after, and the smallest change is creating the release branch first and running QA there. Merge only what is ready and the late decision survives, while the commit you tested becomes the commit you deployed.

Whichever you pick, write the branch-lifetime target as a number. "Short" is not a target.


The four standards were checked against their official documentation. The frequently quoted DORA deployment-frequency figures are second-hand citations and I could not verify the original report, so they are left out. Only the revert behaviour was reproduced directly, in a scratch repository.

The diagram is inline SVG. mermaid bundles d3 and dagre and exceeds 500KB gzipped; even the lightweight alternatives are around 30KB, which does not fit the per-route budget. Hand-written SVG is a few hundred bytes, and because it uses currentColor and var(--accent) it follows this site's inversion automatically.

gitbranchingreleaseverification

Record

First committed 2026.08.27, and untouched since.

  • 8be82d4Content: 릴리스 브랜치를 늦게 만들 필요가 없었다

The full build record →