Skip to content

Branching ​

How a change gets from a ticket to a commit on main.

This is the developer's side of the model described in ci-cd.md. That document is the pipeline design and release.md is the operator's runbook for release day. This one answers the questions you have while writing code: where do I branch from, how long may it live, what must my merge request look like, what happens at release time when five people have work in flight, and when - if ever - do we cut a release branch.


The model ​

There is one long-lived branch, main. Everything else is short-lived and merged into it by merge request.

RefLivesDeploys toProtected
mainForeverStaging, on every mergeYes
<type>/<short-description>Hours to 3 days, or until a business decision (see below)Nothing; parked branches run locally onlyNo
hotfix/<short-description>HoursNothing; it merges to main like any other branchNo
support/vX.YOnly while an old minor is still in productionIts own tag pipeline, see belowYes
vX.Y.ZForeverProduction, after one approvalYes

Everything in this document exists to protect one invariant:

Every commit on main is a commit that has run, or is running, on staging.

That is what makes "tag any commit on main" a safe thing to say. It is not an aspiration either - image:resolve refuses to promote a commit whose image never passed a staging health check, so a violation stops the release instead of reaching users. See one build per commit.


Feature branches ​

Fork from main. Always, and never from another feature branch. A branch off a branch cannot be rebased independently and cannot be merged independently, so it turns two reviewable changes into one unreviewable one. If your work depends on someone else's unmerged change, either that change merges first, or the two of you agree to make it one branch with one merge request. Those are the only two answers.

Name it <type>/<KEY>-<short-description>, where <type> is a Conventional Commits type from commit.md and <KEY> is the Jira subtask you are working on: feat/AST-142-user-auth, fix/AGM-77-login-redirect, chore/DP-310-bump-go-sdk. The key is what links the branch, its commits and its merge request to Jira, and the branch name and the commit it becomes should agree.

Keep it under 3 days. Rebase onto main daily:

git fetch origin
git rebase origin/main

A branch that has started to conflict every morning is telling you it should have been three branches. Split it.

The one exception is a finished feature waiting on a business decision, which has its own rules in Features that may not ship. It still rebases daily. That is what keeps it mergeable.

Approved work that users should not see yet merges behind a feature flag that is off. Not a long-running branch, and this is worth being blunt about: a flag is reversible in seconds by anyone, from a config change, with no pipeline. A branch is reversible only by more merging, by the one person who understands it. When something has to be turned off at 02:00, you want the flag.

The word doing the work there is approved. A feature the business has not committed to releasing is a different case, and it does not merge at all until it has an answer. See Features that may not ship.

Link the ticket. Start the merge request title with the subtask key and end the description with Closes <KEY> (Closes AST-142). When the merge request merges, GitLab moves that Jira subtask to Done. There is no GitLab issue; see jira.md.

The cost of this model is honest and worth stating: it requires feature flags, and it requires every migration to be backward compatible with the release before it. The migration half is specified in release.md. If you are not willing to pay both, you do not get "tag any commit on main".


Features that may not ship ​

We build features that the business then decides not to release, or not to release yet. That is normal here, and it decides where the code waits.

Merging is a commitment to ship. A merge request is opened when the work is done and reviewed, and it is merged when the business has said the feature is going out.

So a finished feature has two possible homes:

The business has saidWhere the code waitsWhy
Ship it, now or on a known datemain, behind a flag that is off until the dateIt is going out, so it belongs in the line that staging tests and releases ship from
Not yet, or not sureIts own branch, rebased daily, not mergedmain stays free of code that may never ship, and a "no" costs one git branch -D

This is the part of the model that differs most from a textbook trunk-based setup, and it is deliberate. Merging everything the moment it compiles would put features on main that nobody has agreed to release, and each one would then have to be flagged, carried through every release, and eventually removed by somebody. A branch that is deleted costs nothing.

Verifying a parked feature ​

Everything a parked feature needs to be seen or tested happens locally. There is no shared environment for unmerged work: no develop, no per-branch deploy, nothing on staging.

  • The backend engineer runs the branch with the repository's local stack.
  • A frontend engineer who needs the new API runs that same stack and points the local frontend at it, instead of at staging.
  • A demo for the PM or BA is given from a developer's machine.

This is the cheapest arrangement that keeps main honest, and it is why no preview-environment machinery appears in ci-cd.md.

It puts a hard requirement on every repository: docker compose up has to produce a working service. Service, database, migrations applied, and seed data, in one command, from a clean checkout, with a documented .env.example. If that command is broken, parked features cannot be tested at all and the model collapses back into merging things to get them looked at.

Open item. templates/docker/ currently ships a production Dockerfile and compose hardening, not a local development stack. The shared compose.dev.yml and make seed that this section assumes do not exist yet. They are a prerequisite, not a nice-to-have.

Rules for a parked branch ​

  • Open the merge request as a draft as soon as the work is done. The blocking checks (lint, test, image:check, migrate:lint) then run on every push, so the branch stays releasable instead of quietly rotting.
  • Rebase onto main daily, not weekly. A branch parked for a month without rebasing is a rewrite, not a merge.
  • Name migration files by timestamp, not by sequence number, and re-stamp them before you merge. Two parked branches that both add 000007_*.sql break the migrate job on main. Worse, a timestamp older than the migrations that have shipped since is skipped in silence, so the deploy is green and the table is missing. Both cases and the rename command are in ci-cd.md.
  • Set a decision deadline, 30 days at the outside. At the deadline the branch either merges behind an off flag or is deleted. There is no third option, because a branch older than that costs more to rebase than to rewrite.
  • Never deploy a parked branch anywhere shared. Its migrations must not touch the staging database.

What this costs, stated plainly ​

  • Whoever verifies needs to run the stack locally to test an unmerged feature, or wait until it merges and test it on staging. Whichever you choose, decide it per feature and say so in the ticket.
  • Local data is not staging data. Bugs that only appear at real data volume are found after the merge, on staging, not before it.
  • Two parked branches never meet until they both merge. The second merge is the expensive one. Keep parked features few and small, and merge the committed ones promptly so the gap stays narrow.
  • Stakeholders cannot click a link. Demos cost a developer's time and a screen share.

What you get back: main contains only code the business has agreed to ship, every commit on it is releasable, and a cancelled feature leaves nothing behind.

After the decision ​

When the answer is finally yes, the branch goes through the normal path in the next section: rebase, green pipeline, approval, squash merge, staging. The owner and the BA verify it on staging before the tag, so the feature still has its night on staging and still appears in the checklist in release.md. Parking the branch moves where it is tested first. It does not remove the staging verification that a release depends on.

When the answer is no, close the merge request and delete the branch. Nothing else has to happen, which is the entire point.


Merge requests ​

Who reviews, how fast, and how to disagree are in Code Review. This section is only about what the merge does to main.

One merge request, one ticket, one deployable change. If your description needs the word "and", consider two merge requests.

Merge when the business has approved the release, not when the code compiles. A finished feature still waiting on that answer stays on its branch, per Features that may not ship.

To merge you need a green pipeline and an approval. The blocking jobs are lint, test, image:check and, if you touched migrations, migrate:lint. None of them may be skipped or retried into submission. A check that does not block is a suggestion, and the survey in ci-cd.md is what happens when a team has those.

Re-stamp your migrations before you merge. If the branch adds migrations, the last step after the final rebase is restamp.sh, in a commit of its own, so their versions are newer than everything already on main. Otherwise golang-migrate skips them in silence. See ci-cd.md.

Rebase; do not merge main into your branch. git rebase origin/main, not git merge main. A branch that has absorbed three merges from main has a diff nobody can read, which means nobody reviews it.

A merge leaves exactly one commit on main. Configure the project for fast-forward merge with squash so the history stays linear.

This is not a style preference. The pipeline keys the image on sha-<commit>, the staged-sha-<commit> tag records that that digest passed staging, and a release tag points at a single commit. One change mapping to one commit is what makes tagging, git bisect and git revert mean what you expect. A merge commit that drags in a stale branch produces a tree that no pipeline has ever built and no one has ever tested.

Merging is the deploy to staging. There is no separate step and no button. Watch the pipeline: image:build, migrate:staging, deploy:staging, compat:check. The staging deploy is yours until it is green. Do not merge at 18:00 and go home.

Worked example: a change, start to finish ​

The Story AST-410 is Ready, and your BE subtask under it is AST-412.

git fetch origin
git switch -c feat/AST-412-invoice-export origin/main

Work, committing as you go. On the second morning, before pushing:

git fetch origin
git rebase origin/main
git push -u origin feat/AST-412-invoice-export

You have already run its manual scenarios against your local stack, and the PM has confirmed it goes out this month. That confirmation is what makes this mergeable at all; a feature still waiting on it stays parked.

Open the merge request into main, titled AST-412 feat(usage): add invoice export. Description ends with Closes AST-412, and the export is behind features.invoice_export, default off until the announced launch date. Pipeline goes green, one approval, merge with squash.

main now has one new commit, b2c3d4e, and the pipeline does the rest:

image:build      -> pushes sha-b2c3d4e
migrate:staging  -> golang-migrate up
deploy:staging   -> health check passes
mark:staged      -> tags the same digest staged-sha-b2c3d4e
compat:check     -> last release's binary still works against this schema

Staging now reports build version #486-b2c3d4e. That string is what the verification comments on the ticket name, and it is what you look for on release day. The branch is deleted on merge; nothing else has to happen to it.

That commit is the one the release example below ships, still with its flag off.


Releasing with several branches in flight ​

This is the question that Gitflow answers with a release branch, and it is worth understanding why the answer changes here.

On a linear main, a release is a point, not a set.

Tagging v1.4.0 ships every commit at or below that point. You are not choosing a basket of features; you are choosing a cut line. So the question "how do I release A and C but not B" has no version of "yes" in it, and that is deliberate: the alternative is assembling a commit set that no environment has ever run.

Choosing the commit ​

  1. Walk back from main HEAD to the newest commit that satisfies the pre-release checklist in release.md: its work was verified and accepted on that build version on staging, it has been on staging for at least one working day, and compat:check is green.
  2. Everything below it ships. That is safe by construction, not by luck: staging deployed each of those commits in order, so nothing untested can be sitting underneath a tested commit.
  3. Work still in flight is not a release problem. It is either unmerged, in which case it is not on main at all, or merged behind an off flag, in which case it ships as dead code and changes nothing. Neither one blocks the tag.
  4. If something merged that must not reach users, in order of preference: turn its flag off; revert it on main and tag above the revert; or tag an earlier commit. The last option is legal but it holds back everything merged after that commit, so it is a last resort rather than a tool.
  5. Never cherry-pick a commit into a release. A cherry-pick produces a new SHA, that SHA has no staged-sha- tag, image:resolve fails, and nothing ships. That failure is the system working.

In practice this walk-back lands on HEAD almost every time, because the release cutoff is the end of the previous working day. A commit merged on the morning of a release day is not in that release. That rule exists precisely so that "the newest verified commit" and "main HEAD" are usually the same commit.

Worked example: a Monday release with work in flight ​

It is Monday morning. main looks like this, newest first:

e5f6a7b  feat: bulk upload UI                merged Mon 09:40
c3d4e5f  fix: invoice rounding               merged Fri 16:10
b2c3d4e  feat: invoice export (flag off)     merged Fri 11:25
a1b2c3d  chore: bump aioz-sdk to v1.9.0      merged Thu 15:02
9a8b7c6  <- v1.3.7, what production is running

Meanwhile three feature branches are open and unmerged, and one engineer is midway through a migration.

You tag c3d4e5f, not HEAD:

  • e5f6a7b merged this morning, after the end-of-Friday cutoff. It has not had its night on staging, so it waits for Wednesday. Nobody cut a branch to hold it back; the cutoff held it back.
  • c3d4e5f is the newest commit that passes the checklist. The owner and the BA verified build version #489-c3d4e5f on Friday afternoon and it has been on staging all weekend.
  • Everything below it ships with it, including b2c3d4e, whose flag is off. The code is in production and does nothing. That is the point of the flag.
  • The three open branches are not on main, so they are not in the conversation at all.
git tag v1.4.0 c3d4e5f
git push origin v1.4.0

The tag pipeline finds staged-sha-c3d4e5f in the registry, so it promotes the exact digest staging ran. The lead presses approve:prod. Notice what did not happen: no branch was cut, nothing was cherry-picked, and no feature was held out of the build.

Worked example: something merged that must not ship ​

Friday 18:00. c3d4e5f turns out to round the wrong way for one currency, and The BA finds it during acceptance, after everyone has gone home. Monday's release is in two days.

In order of preference:

  1. If it were behind a flag, turn the flag off. Nothing to merge, nothing to tag, and the release goes ahead as planned. This is why the flag rule exists.
  2. It is not, so revert it on main. Open a normal merge request with git revert c3d4e5f, review it, merge it. main gets d4e5f6a, staging deploys it, and on Monday you tag d4e5f6a. The release still ships a1b2c3d and b2c3d4e.
  3. Tag b2c3d4e instead, which is below the bad commit. Legal, and it takes ten seconds, but it also holds back the rounding fix's neighbours and anything merged after them. Use it when the revert is not obvious and the clock is the binding constraint.

What you do not do is build a release out of a1b2c3d plus b2c3d4e plus a cherry-picked e5f6a7b. That commit set has never existed on any branch and has never been deployed anywhere. image:resolve would reject it, correctly.


When you do not create a release branch ​

Three Gitflow reflexes, and what replaces them here.

ReflexReplacement
Cut release/1.4 to freeze the release while work continuesTag the commit. The tag is the freeze, and main never has to stop moving.
Cut a branch to hold a feature backFeature flag, off.
Cut a branch to stabilise before shippingThe commit already spent a night on staging. Anything still wrong is fixed forward on main and re-tagged.

There is no standing release/* branch and no develop. A second long-lived branch drifts from main permanently, every fix has to land on both, and the back-merge that everyone forgets is how a hotfix silently reverts itself in the next release.


The one branch that is allowed: support/vX.Y ​

There is exactly one situation that a tag cannot express: production is running v1.4.x, it needs a fix, and main cannot ship. Not "should not" - cannot. Typically main is carrying a migration partway through expand, migrate, contract, or an in-progress major version, and the reason it cannot go out is not something a flag can switch off.

If main can ship, this is not a support branch. It is a hotfix: merge to main, verify on staging, tag a patch bump. That is almost always the right answer, and it is faster.

Procedure ​

  1. The lead agrees and the reason is recorded, the same bar as any off-cadence release.

  2. Cut from the tag, never from main:

    git checkout -b support/v1.4 v1.4.3
    git push -u origin support/v1.4

    Protect the branch. It is now a release-bearing ref.

  3. The fix lands on main first, through a normal merge request, and is then cherry-picked onto support/v1.4.

    The direction is one-way and not negotiable. main is the branch that must never miss a fix, because every future release comes from it. Fixing the support branch first and back-merging later is the single most common way teams lose a hotfix: the back-merge is forgotten, the next release quietly reintroduces the bug, and nothing in CI notices.

    If the fix genuinely does not apply to main - the code it touches no longer exists there - say so in the merge request description and have the reviewer confirm it.

  4. Verify on staging before tagging. The cherry-picked commit is a new SHA that nothing has deployed, so the assurance you normally get from main having spent a night on staging does not exist here. This is the expensive part, and it is why the trigger above is narrow.

  5. Tag a patch bump on the support branch (v1.4.4) and release it through the normal single approval.

  6. Delete the branch when that minor version leaves production.

Worked example ​

Production runs v1.4.3. main is three commits past it, and the newest of those drops users.legacy_email, the contract step of an expand-migrate-contract that started two releases ago. That drop is irreversible and the rollback path for the current release still reads the column, so main cannot go out today.

Then support reports that password reset emails are going to the wrong address for accounts created before March. This cannot wait for the contract migration to finish its cycle.

The lead agrees, and the reason goes in the release channel.

git fetch origin
git checkout -b support/v1.4 v1.4.3
git push -u origin support/v1.4

Protect the branch in the project settings.

Fix main first, through an ordinary merge request. It merges as f7a8b9c, deploys to staging, and is now queued for whenever main can ship again.

git checkout support/v1.4
git cherry-pick f7a8b9c
git push

The cherry-pick is a new SHA, so nothing has deployed it. Deploy support/v1.4 to staging and have the owner and the BA confirm the reset email, exactly as they would for a normal release. Only then:

git tag v1.4.4
git push origin v1.4.4

Approve, watch for 30 minutes, confirm in the channel. When the contract migration completes and main ships as v1.5.0, v1.4 leaves production and the branch is deleted.

Count the steps: this is four more than a hotfix on main, and one of them is a manual staging verification that the normal flow gets for free. That is the price, and it is why the trigger is written as narrowly as it is.

Open item. The staged-sha- gate in ci-cd.md is specified for main only, so the staging path for a support-branch release is not defined yet. Settle it before the first one, not during. Keeping the trigger above narrow is what keeps this gap theoretical.


Anti-patterns ​

PatternWhat it costs
A long-lived developStaging runs a superset of the release, so "verified on staging" stops being true for the artifact that ships.
A standing release/* branchTwo branches to fix, one back-merge to forget, and a merge commit to main that no pipeline built.
Cherry-picking into a releaseA new SHA with no staged-sha- tag. image:resolve fails and nothing ships.
Merging main into your branch instead of rebasingAn unreadable diff, which means an unreviewed change.
A merge commit dragging in a stale branchmain gets a tree nobody tested, and git bisect stops being useful.
Holding a feature on a branch instead of behind a flagTurning it off needs a merge and a deploy, at the hour when you least want either.
Branching a hotfix from support/* instead of mainmain misses the fix. The next release reintroduces the bug.
Merging a feature to get it testedmain fills with code nobody has agreed to release, and removing it later is somebody's unplanned week.
A parked branch nobody rebasesAt a month old it is a rewrite. The business says yes and the answer is "give us a week".
A local stack that does not start from a clean checkoutParked features cannot be tested, so people merge them to get them looked at, and the row above happens.