Skip to content

Choose a branch workflow

  • Lesson 2
  • 45 min
  • Module 1: Understand the delivery path
Lesson 2 of 15 · CI/CD, Testing and Safe ReleasesChoose a branch workflow

What you will be able to do

A pipeline must know which branch holds the code that can go to production. Choose a branch workflow that answers that question. Then write every pull request with its source and its target, so that nobody merges in the wrong direction.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

Run with GitHub Actions (ubuntu-24.04 runners) in the public repository Nextia-Intelligence/ticket-api-ci. The Git commands were run with Git 2.54 on macOS. The Windows steps are not tested.

Before Amira writes a line of code, she answers two questions. Where does the change start? Where does it go? A pull request writes the answers as its source branch and its target branch: “feature/classifier-1.1 into main”.

A branch workflow is the team’s rule for these answers: which branches exist, what each is for, where each one starts, and where it merges. A pipeline depends on that rule. It must know which branch to build for production, and which branches only get checks. Two workflows are common.

In mainline development (also called trunk-based development), main is the only long-lived branch, and it is always releasable. Every change starts on a short-lived branch from main, and goes back into main by a pull request. A release is a tag on a commit of main.

This is the real history of the reference repository, from the start to version 1.2.0:

A Git graph. On main: df1d2a8, da4e3f3, 399c0d2, c862935. Then four feature branches, one after another. feature/deploy from c862935, commit 017f908, merged as 43de57b, PR #1. feature/release-tags, commit 568e2d8, merged as 92fff66, PR #2, with the tag v1.1.0. feature/classifier-1.1, commit 19d172b, merged as 7324d7b, PR #3. feature/score-field, commit aa8b83e, merged as c3c7135, PR #5, with the tag v1.2.0.
The real history of Nextia-Intelligence/ticket-api-ci, from the start to v1.2.0. Each feature branch lives for minutes to hours and holds one change. The tags are on merge commits of main.
Text description of the diagram

main starts with four commits. Then each change has its own branch from main: feature/deploy, feature/release-tags, feature/classifier-1.1 and feature/score-field. Each branch has one commit and goes back into main with a merge commit, from pull requests 1, 2, 3 and 5. The tag v1.1.0 is on the merge of PR #2. The tag v1.2.0 is on the merge of PR #5.

Read three things in it:

  • One change per branch. Each feature branch has one commit and goes back within the hour. A short branch has few conflicts, and its pull request is small enough to review.
  • No branch waits for a release. main gets every finished change. The pipeline builds every commit on main once, so every commit on main has an image that can go to production.
  • A release is a tag, not a branch. v1.1.0 and v1.2.0 point to commits of main. Nothing was copied to another branch to make them.

The first commits (da4e3f3, 399c0d2, c862935) went to main without a pull request. At that time, the repository had no ruleset yet: Module 2 adds it. From PR #1 on, every change came through a pull request.

PR #4 is not in the graph. Its source was feature/fewer-urgent-tickets, its target main, and its checks failed, so Amira closed it without a merge (Lesson 3 shows why). Its commits never reached main, so git log main does not show them. That is the point of the gate.

With release branches, the team cuts a branch such as release/1.2 from main when the next version is ready. The release branch gets only fixes, while new work continues on main. Each release is a tag on the release branch, and the fixes are merged back into main. The Git course taught this workflow. GitFlow is a well-known variant: it adds a develop branch for new work, and keeps main for released versions only.

The same kind of history with a release branch would look like this. It is an example, not the reference repository:

An example Git graph, not the reference repository. main has feature A and feature B. feature/C starts from feature B with C, part 1. release/1.2 also starts from feature B, with the commit test fix, tagged v1.2.0, and the commit urgent fix, tagged v1.2.1. feature/C gets C, part 2, and is merged into main as merge C. Then release/1.2 is merged into main as fixes back, and main continues with feature D.
An example of release branches. Version 1.2 lives on release/1.2, without feature C. Every fix on the release must be merged back into main.
Text description of the diagram

In this example, main has features A and B. The team cuts release/1.2 from feature B. So the release does not contain feature C, which is still in progress on its own branch. The release branch gets a test fix, tagged v1.2.0, and later an urgent fix, tagged v1.2.1. Feature C is merged into main. Then the release branch is merged into main, so that the two fixes are not lost, and main continues with feature D.

Mainline development Release branches
Branches that live long main only main and one branch for each supported version
Where a release comes from A tag on main A tag on release/x.y
A fix for production A normal pull request into main, or a hotfix branch from the tag A pull request into release/x.y, then merged back into main
What the pipeline builds for production Every commit on main Every commit on each release branch
Fits when One deployed service, small changes, many deployments a week Several versions in use at the same time, or slow releases: an app that waits for store review, software that customers install themselves, a long test phase
Risk Unfinished work on main must not reach users: keep it small, or hide it behind a setting A fix on the release is forgotten on main and comes back as a bug in the next version

The ticket API is one service with one production. The team deploys several times a day, and every change is small. Users never run an old version: the help desk uses the one deployment. So the course uses mainline development.

Two rules keep main releasable:

  1. Every change on main passed the checks. The ruleset of Module 2 requires a pull request with green checks.
  2. Unfinished work is switched off. PR #3 put the new classifier on main with CLASSIFIER_VERSION=1.0 as the default, so production behaved as before. Module 4 turns it on step by step.

One exception remains: an urgent fix when main already has changes that are not ready for production. Then the team uses a short-lived hotfix branch, made from the release tag. That is the only time a branch starts from somewhere other than main. The reference repository did it once:

A Git graph from v1.2.0. main: c3c7135, PR #5, with the tag v1.2.0. feature/local-platform from c3c7135, commit 5893093, merged into main as 6ae49dc, PR #6. hotfix/1.2.1 also starts from c3c7135, with commit fc9005a, tagged v1.2.1. Then main is merged into hotfix/1.2.1 as 543b003. Then hotfix/1.2.1 is merged into main as 3464f2c, PR #7. Last, feature/backfill-score, commit 6d66ec6, merged as 30e0c7e, PR #8.
The real hotfix: hotfix/1.2.1 starts at the tag v1.2.0, not at the newest main. The tag v1.2.1 is on the hotfix branch. PR #7 merges the fix back into main.
Text description of the diagram

After v1.2.0, main got PR #6, feature/local-platform. Production still ran v1.2.0 and had a bug. The hotfix branch hotfix/1.2.1 starts at the commit of the tag v1.2.0, so it does not contain PR #6. Its commit fc9005a has the fix and the tag v1.2.1. Then main is merged into the hotfix branch (543b003), because the ruleset required the branch to be up to date. Then PR #7 merges hotfix/1.2.1 into main (3464f2c). Last, PR #8 adds feature/backfill-score.

Why from the tag? Production ran v1.2.0. A hotfix must change only the bug, so it starts from exactly what production runs. If it started from the newest main, it would also take PR #6 to production without its own release. Module 5 teaches the full hotfix procedure; this module only places it in the workflow.

A version number tells users what to expect from a release. The ticket API uses semantic versioning: MAJOR.MINOR.PATCH, as in the Git course. For an API, “breaking” means that a client that worked before can fail now:

Change in the reference repository Version Why
The first release through the pipeline 1.1.0 The starting version from the Docker course.
New field score in every answer; confidence stays, marked as deprecated 1.1.0 → 1.2.0 MINOR: new, and old clients still work.
Fix: GET /v1/history failed with rows from 1.1.0 1.2.0 → 1.2.1 PATCH: a fix only.
Planned: remove confidence → 2.0.0 MAJOR: clients that read confidence break.

Two different things are called a tag in this course:

  • A Git tag is a fixed name for one commit: v1.2.0 → c3c7135. It never moves.
  • An image tag is a name for an image in the registry: ghcr.io/usmanahmed99/ticket-api-ci:1.2.0, and sha-c3c7135 for the same image. The release workflow gives the image the version tag when you push the Git tag. The image’s digest identifies its content. A tag can be moved to another digest.

The reference repository uses annotated tags, which store a message, a date and the person who made them. git tag -a v1.2.0 -m "…" makes one. The guided practice shows their messages.

Amira wants to know which releases contain PR #3, and whether the hotfix reached main. Git answers both from a clone. She clones the public repository into a practice folder:

Terminal
cd ~/projects
git clone https://github.com/Nextia-Intelligence/ticket-api-ci.git c19-reference
cd c19-reference
git log --oneline --first-parent main

--first-parent follows only the commits that main itself made, and leaves out the commits of the branches. The output:

30e0c7e Merge pull request #8 from usmanahmed99/feature/backfill-score
3464f2c Merge pull request #7 from usmanahmed99/hotfix/1.2.1
6ae49dc Merge pull request #6 from usmanahmed99/feature/local-platform
c3c7135 Merge pull request #5 from usmanahmed99/feature/score-field
7324d7b Merge pull request #3 from usmanahmed99/feature/classifier-1.1
92fff66 Merge pull request #2 from usmanahmed99/feature/release-tags
43de57b Merge pull request #1 from usmanahmed99/feature/deploy
c862935 Fix the two lint findings: raise ... from None, and sort the imports
399c0d2 Add the CI pipeline: lint, tests, audit, then build, smoke-test and push the image
da4e3f3 Describe the delivery path: branches, checks and promotion
df1d2a8 Start from C18: ticket-api 1.1.0 in a container

Every line after c862935 is a merge of a pull request, and each message names its source. This is mainline development, read from the history. Then the tags:

Terminal
git tag -n1
git tag --contains 7324d7b

The output:

v1.1.0 ticket-api 1.1.0: the first release through the pipeline
v1.2.0 ticket-api 1.2.0: score next to confidence; classifier keywords-1.1 (off by default)
v1.2.1 ticket-api 1.2.1: hotfix for GET /v1/history with rows from 1.1.0
v1.2.0
v1.2.1

The first three lines are the tags with their messages. The last two lines answer the question: PR #3’s merge commit 7324d7b is in v1.2.0 and v1.2.1, but not in v1.1.0. Now the hotfix:

Terminal
git branch -a --contains fc9005a
git tag --contains 5893093

The output:

* main
remotes/origin/HEAD -> origin/main
remotes/origin/hotfix/1.2.1
remotes/origin/main

The hotfix commit fc9005a is on main, so the next release from main keeps the fix. The second command printed nothing: no tag contains 5893093, the commit of PR #6. Version 1.2.1 was made from v1.2.0, so it does not have PR #6. That is correct for a hotfix. It also means that a fix on main reaches users only with the next release from main.

The branch list on GitHub shows the same workflow from another side:

The Branches page of Nextia-Intelligence/ticket-api-ci, tab All, logged out. Two branches: main, the default, check status 2 of 2; and hotfix/1.2.1, check status 4 of 4, 3 commits behind and 0 ahead of main, with pull request #7.
Only two branches are left. Each feature branch was deleted when its pull request was merged or closed. hotfix/1.2.1 is 3 commits behind and 0 ahead of main: everything on it is already on main.

0 ahead is the proof in the browser: the hotfix branch has no commit that main lacks.

A pull request with the wrong target merges a change into the wrong place. Write both names every time, in this order: “source into target”. These are the real pull requests:

PR Source Target Result
#1 feature/deploy main merged, 43de57b
#2 feature/release-tags main merged, 92fff66, tagged v1.1.0
#3 feature/classifier-1.1 main merged, 7324d7b
#4 feature/fewer-urgent-tickets main closed: a check failed
#5 feature/score-field main merged, c3c7135, tagged v1.2.0
#6 feature/local-platform main merged, 6ae49dc
#7 hotfix/1.2.1 main merged, 3464f2c (the tag v1.2.1 is on the branch, fc9005a)
#8 feature/backfill-score main merged, 30e0c7e

Every target is main. Every source except one starts from main. The exception is hotfix/1.2.1, which starts from the tag v1.2.0. On GitHub, the pull request page says it in one line, as on PR #3: “merged 1 commit into main from feature/classifier-1.1”.

The project’s rule, in docs/release-process.md (Lesson 3 writes it):

docs/release-process.md (Branches section)
| Branch | Made from | Merges into | Used for |
|---|---|---|---|
| `main` | | | The current code. Every commit on `main` is built once, and the image can go to production. |
| `feature/<short-name>` | `main` | `main`, by pull request | Every normal change. Short-lived: one change, merged within a few days. |
| `hotfix/<version>` | the release tag, for example `v1.2.0` | `main`, by pull request, after the release | An urgent fix to the version in production, when `main` already has changes that are not ready. |

Guided practice: read the history yourself

Section titled “Guided practice: read the history yourself”
  1. Clone the reference repository into ~/projects/c19-reference, as in the worked example.
  2. Run git branch --show-current. Check that the output is main.
  3. Run git log --oneline --first-parent main. Count the merges of pull requests.
  4. Run git log --oneline --graph --all. Find the commit where hotfix/1.2.1 starts.
  5. Run git tag -n1. Write down each tag and its version type (MAJOR, MINOR or PATCH) compared with the tag before it.
  6. Run git tag --contains 7324d7b. Write down the releases that contain PR #3.
  7. Run git merge-base --is-ancestor fc9005a 6ae49dc && echo yes || echo no. Predict the answer first: was the fix on main at 6ae49dc?
  8. Run the same command with 3464f2c instead of 6ae49dc.
Show the expected results
  1. main.
  2. Seven merges of pull requests: #1, #2, #3, #5, #6, #7 and #8. PR #4 was closed.
  3. hotfix/1.2.1 starts at c3c7135, the commit with the tag v1.2.0. In the graph, fc9005a (tag: v1.2.1) has c3c7135 as its parent.
  4. v1.1.0, the start; v1.2.0, MINOR (new field score, old clients still work); v1.2.1, PATCH (a fix).
  5. v1.2.0 and v1.2.1.
  6. no. At 6ae49dc (PR #6), the fix existed only on the hotfix branch. Production ran it, but main did not have it.
  7. yes. PR #7 merged the fix into main as 3464f2c.

The command git merge-base --is-ancestor A B asks: is commit A in the history of commit B? It prints nothing, and its exit code is 0 for yes and 1 for no. && echo yes || echo no prints the answer. In PowerShell, write git merge-base --is-ancestor fc9005a 6ae49dc; if ($?) { "yes" } else { "no" }.

Your turn: write the branch and the pull request

Section titled “Your turn: write the branch and the pull request”

For each change, write the branch name, where it starts, and the pull request as “source into target”. main already has a change that is not released yet.

  1. Sam reports that “I lost my password” gets priority 1. The team wants a better rule for “lost”, for the next normal release.
  2. Production runs v1.2.1. After a deployment, the help desk sees that every ticket gets the category other. The team wants a fix today.
  3. A spelling mistake in README.md.
Show one answer
  1. feature/lost-password-rule, from main. Pull request: feature/lost-password-rule into main. It goes out with the next release from main. (The real PR #4 tried this, as feature/fewer-urgent-tickets into main. The AI evaluation gate stopped it: Lesson 3.)
  2. hotfix/1.2.2, from the tag v1.2.1 (git switch -c hotfix/1.2.2 v1.2.1). Tag v1.2.2 on the branch after its checks pass, then deploy that image. Then the pull request hotfix/1.2.2 into main, so the next release keeps the fix. It does not start from main, because the change on main is not ready for production.
  3. feature/readme-typo (or docs/readme-typo if the team uses that prefix), from main, into main. It is a normal change. It gets the same checks as every other change, even though it changes no code.

Common mistake

The fix is in production, but not on main

Symptom: a hotfix runs in production and the bug is gone. Two weeks later, the next release from main brings the same bug back. Users see it again, and nobody changed that code.

Cause: the hotfix branch started from the release tag, so its fix is only on that branch. main does not get it by itself. In the reference repository, this was true for a short time. Between the release v1.2.1 (18:51) and the merge of PR #7 (18:58), production had the fix and main did not. Check it in your clone:

Terminal
git merge-base --is-ancestor fc9005a 6ae49dc && echo yes || echo no

The output:

no

A second trap is the direction. The commit 543b003 Merge branch 'main' into hotfix/1.2.1 merged main into the hotfix branch: GitHub’s Update branch button did that, because the ruleset requires an up-to-date branch. It brings main’s changes to the hotfix branch. It does not bring the fix to main. Only PR #7, hotfix/1.2.1 into main, did that.

Fix:

  1. Open the pull request “hotfix branch into main” in the same hour as the release. Do not wait for the next release.
  2. Read the source and the target before you merge: “hotfix/1.2.1 into main”.
  3. Check with Git that the fix is on main: git branch -a --contains <fix commit> must list main.
  4. The fix needs a test that fails without it. The test then stays on main and catches the bug if it comes back. Module 5 shows the test of 1.2.1.

For a real AI service: a model fix can be lost the same way. A hotfix sets a new threshold or a new rule. If it is not on main, the next model release brings the old behaviour back. Users see a regression, and the evaluation results of the hotfix give a false sense of safety.

Check your understanding

Predict: version 2.0 removes the deprecated field confidence. Which part of the version changes, and why not the middle number?

Show the answer

The MAJOR number: 1.2.1 → 2.0.0. A client that reads confidence fails after the change, so it is a breaking change. The middle number is for new things that old clients can ignore, such as adding score in 1.2.0. That is why 1.2.0 kept confidence and only marked it as deprecated: the clients get time to move to score before 2.0.

Check your understanding

Why does the team use mainline development for the ticket API, and why is the hotfix branch made from the tag v1.2.0 instead of from main?

Show the answer

The ticket API is one service with one production. Changes are small and deployed often, and users never run an old version, so the team does not need a branch for each version. main is always releasable: every change passed the checks, and unfinished work is switched off. The hotfix branch starts from v1.2.0 because that is exactly what production runs. A branch from main would also take PR #6 to production, unreleased and untested as a release. After the release, the pull request hotfix/1.2.1 into main brings the fix back to main.

Recap

  • A branch workflow says where each change starts and where it merges. A pull request writes it as “source into target”.
  • Mainline development: one long-lived main, always releasable; short feature branches; a release is a tag on main. Release branches: one branch per supported version; fixes there, merged back into main.
  • The ticket API uses mainline development: one service, small changes, many deployments. Unfinished work is switched off by a setting.
  • A hotfix branch starts from the release tag, and goes back into main by a pull request. Check it with git branch -a --contains <commit>.
  • Semantic versioning: PATCH for a fix, MINOR for something new that old clients can ignore, MAJOR for a breaking change. A Git tag names a commit; an image tag names an image, and the digest is its content.
  • To remove the practice clone: delete the folder ~/projects/c19-reference. Nothing else changed.

Next: Define checks