# Team workflows

> Compare GitHub flow, trunk-based development and Git flow, tag releases, and write commits that follow Conventional Commits.

Git & Collaboration roadmap · Going further · 17 min read · https://levelcode.org/roadmaps/git/workflows

Git gives a team endless freedom: any number of branches, any merge style, any message format. Freedom without agreement produces chaos — long-lived branches nobody dares merge, a history full of "fix stuff", and releases that depend on one person's memory. A **workflow** is a team's shared answer to three questions: where does work happen, how does it reach `main`, and how does it get released? This lesson walks through the three workflows you are most likely to meet and the commit conventions that make any of them work better.

## What a workflow decides

Every workflow is a set of choices along a few axes:

| Question | Typical options |
| --- | --- |
| How long do branches live? | Hours, days, or weeks |
| Which branches are permanent? | Only `main`, or `main` plus `develop` and release branches |
| How does code reach `main`? | Direct commits, or pull requests with review and CI |
| How are PRs merged? | Merge commit, squash, or rebase |
| How is a release identified? | Every merge deploys, or a version tag such as `v2.3.0` |

The single most important variable is **branch lifetime**. The longer a branch lives apart from `main`, the more both drift, and the bigger and riskier the eventual merge. Every modern workflow is, at heart, a strategy for integrating often.

## GitHub flow

GitHub flow is the simplest workflow that works with pull requests, and it is the default for most web applications.

1. `main` is always deployable.
2. For any change, create a descriptively named branch from `main`.
3. Commit, push, and open a pull request early.
4. Discuss, review and let CI run; push fixes to the same branch.
5. Merge into `main` once approved and green.
6. Deploy `main` (often automatically on every merge).

```text
main      ●───────●───────────●──────────●   (each merge is deployed)
           \     /             \        /
feature/a   ●───●               \      /
                                 ●────●   fix/login-redirect
```

There are no develop or release branches to maintain. The rule "`main` is always deployable" carries the weight: anything merged must be finished, tested and safe to ship. Branch protection enforces it by requiring reviews and passing checks.

Branch names usually include a type prefix, which makes `git branch -a` readable and lets tools act on them:

```text
feature/search-notes
fix/login-redirect
docs/install-steps
chore/upgrade-node-22
```

GitHub flow suits teams that deploy continuously and only ever run one version in production. Its weakness is that a slow review process turns "short-lived branches" into week-long ones.

## Trunk-based development

Trunk-based development pushes the same idea further: everyone integrates into the trunk (`main`) at least daily. Branches, if used at all, live for hours, and PRs are small enough to review in minutes. Some teams with strong pairing and test suites commit straight to `main`.

```text
main   ●──●──●──●──●──●──●──●──●   (many small merges a day)
        \   /    \   /    \   /
          ●        ●        ●        (each branch lives for hours)
```

How do you build a two-week feature if every change must land daily? With **feature flags**: the incomplete code ships to production switched off, and you flip it on when it is ready.

```ts
type Cart = { items: { name: string; price: number }[] };

const flags = { newCheckout: process.env.FLAG_NEW_CHECKOUT === "true" };

export function renderCheckout(cart: Cart): string {
  if (flags.newCheckout) {
    // New flow: merged to main in small PRs, hidden until the flag is on.
    return `New checkout: ${cart.items.length} items`;
  }
  return `Checkout: ${cart.items.length} items`;
}
```

This turns *releasing* a feature into a configuration change rather than a risky merge, and it means nobody holds a large, diverging branch. The costs are real: you need a fast, trustworthy CI suite, discipline about small changes, and a habit of deleting flags once features are fully rolled out. Industry research on software delivery consistently associates trunk-based development with high-performing teams, and GitHub flow with very short branches is effectively the same thing.

> [!TIP]
> Whatever workflow you use, "merge or rebase from `main` often" is the cheapest conflict prevention there is. A branch that is two days behind conflicts rarely; a branch that is two months behind conflicts always.

## Git flow and release branches

Git flow, popularised in 2010, uses two permanent branches and three kinds of temporary ones:

- `main` holds only released code, with a tag for every release.
- `develop` collects finished features for the next release.
- `feature/*` branches start from and merge back into `develop`.
- `release/*` branches are cut from `develop` to stabilise a version, then merged into both `main` and `develop`.
- `hotfix/*` branches start from `main` for urgent fixes, and merge into both.

```text
feature/* ──merge──▶ develop ──cut──▶ release/1.1 ──merge──▶ main  (tag v1.1.0)
                        ▲                  │
                        └──────merge───────┘

main ──branch──▶ hotfix/1.1.1 ──merge──▶ main (tag v1.1.1) and develop
```

That is a lot of ceremony, and for a web app that deploys several times a day it is overhead with no benefit. But it fits software that ships **versions** customers install and that must be supported in parallel: mobile apps, desktop software, libraries, firmware. Many such teams use a lighter variant: trunk-based work on `main`, plus a `release/2.3` branch cut for each version, with fixes made on `main` and cherry-picked onto the release branch.

### Tagging releases

Whichever workflow you use, mark releases with **annotated tags**, which store a tagger, date and message (lightweight tags are just names):

```bash
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
git tag -l "v1.*"
```

```text
v1.4.0
```

Tags are not pushed by a normal `git push`, which is why the explicit push matters (or use `git push --follow-tags` to push annotated tags reachable from the commits you push). `git describe` names any commit relative to the latest tag:

```text
v1.4.0-1-g290ba86
```

That reads "one commit after `v1.4.0`, at commit `290ba86`". Version numbers usually follow **semantic versioning**: `MAJOR.MINOR.PATCH`, where a breaking change bumps MAJOR, a new backward-compatible feature bumps MINOR, and a bug fix bumps PATCH.

## Commit conventions

A workflow decides how commits travel; conventions decide what each commit says.

### Atomic commits

Each commit should be one logical change that leaves the project working: tests pass before and after. Atomic commits are easy to review, easy to revert, and make `git bisect` effective. "Rename `User` to `Account`" and "Add password reset" are two commits, even if you wrote them in one afternoon — `git add -p` exists for exactly this.

### Conventional Commits

Many teams adopt the **Conventional Commits** format, which makes the history machine-readable:

```text
<type>(<optional scope>): <summary>

<optional body>

<optional footers>
```

| Type | Use for | Semver effect |
| --- | --- | --- |
| `feat` | A new feature | MINOR |
| `fix` | A bug fix | PATCH |
| `docs` | Documentation only | none |
| `refactor` | Code change that neither fixes a bug nor adds a feature | none |
| `test` | Adding or correcting tests | none |
| `perf` | Performance improvement | PATCH (by common convention) |
| `chore` / `build` / `ci` | Tooling, dependencies, pipelines | none |

A breaking change is marked with `!` after the type or scope, or a `BREAKING CHANGE:` footer, and bumps MAJOR:

```text
feat(api)!: return pagination metadata with every list response

List endpoints now return { items, nextCursor } instead of a bare
array, so clients can page through large result sets.

BREAKING CHANGE: clients must read `items` from list responses.
Refs: #212
```

Real `git log --oneline` from a team using it:

```text
9c41d07 fix(cart): round totals to two decimal places
e2b8a15 feat(search): filter notes as the user types
71f0c3a docs: add installation steps to README
4d6aa92 chore(deps): upgrade vite to 6.2
```

The payoff is automation: release tools read these messages to choose the next version number and generate a changelog grouped by type. Even without the tooling, a reader can scan for every `fix` in a release in seconds. A `commit-msg` hook or a CI check can enforce the format, as the power tools lesson shows.

> [!NOTE]
> If your team squash-merges pull requests, the **PR title** becomes the commit message on `main`. Apply the convention to PR titles, and individual branch commits can stay informal.

## Choosing and writing it down

Match the workflow to how you ship:

| Situation | Good fit |
| --- | --- |
| Web app or service, deployed continuously, one live version | GitHub flow or trunk-based |
| Mature team with strong CI wanting maximum speed | Trunk-based with feature flags |
| Installed software with several supported versions | Trunk-based plus release branches, or Git flow |
| Open-source project with outside contributors | GitHub flow via forks and PRs |

Then decide the history policy. **Squash merging** gives one commit per PR on `main` — easy to read and revert, but branch-level detail is lost. **Merge commits** preserve everything, including noise. **Rebase merging** keeps curated commits linear but requires authors to tidy their branches. Pick one, configure the repository to allow only that button, and write the whole agreement — branch naming, merge style, commit format, release process — in a `CONTRIBUTING.md` so new teammates learn it on day one.

## Practice

Your four-person team builds a web app deployed several times a day. Releases are currently painful: feature branches live for two to three weeks, merges conflict badly, and the history is full of messages like "updates" and "fix".

Write a short proposal (as a text file in a scratch repository) covering: the workflow, the merge style, branch naming, the commit message convention, and how to ship a large feature that takes a month. Then make three commits in that repository whose messages follow your convention, and tag the last one as `v0.1.0`.

**Solution**

A strong proposal looks something like this:

```text
Workflow: GitHub flow with short-lived branches (merge within 1-2 days).
main is always deployable; every merge to main deploys automatically.

Merge style: squash and merge only. The PR title becomes the commit
on main, so PR titles must follow Conventional Commits.

Branches: <type>/<short-description>, e.g. feature/search-notes,
fix/login-redirect. Delete after merge.

Commits: Conventional Commits (feat, fix, docs, refactor, test, chore),
enforced by a CI check on PR titles.

Large features: split into small PRs and merge them behind a feature
flag that stays off in production until the feature is complete.
Remove the flag once it is fully rolled out.

Protection: main requires 1 approval and passing CI; no force pushes.
```

The key points: short branches fix the merge pain, feature flags allow month-long work without month-long branches, and squash merging plus a title convention fixes the history.

```bash
git init workflow-proposal && cd workflow-proposal
# write PROPOSAL.txt
git add PROPOSAL.txt && git commit -m "docs: propose GitHub flow with squash merges"
echo "Protection: require CI" >> PROPOSAL.txt
git commit -am "docs(proposal): add branch protection rules"
echo "Flags: remove after rollout" >> PROPOSAL.txt
git commit -am "docs(proposal): explain feature flag cleanup"
git tag -a v0.1.0 -m "First proposal draft"
git log --oneline --decorate
```

## Key takeaways

- A workflow defines where work happens, how it reaches `main`, and how releases happen; branch lifetime matters most.
- GitHub flow: branch, PR, review, merge to an always-deployable `main`, deploy.
- Trunk-based development integrates at least daily and uses feature flags to hide unfinished work.
- Git flow and release branches suit software with multiple supported versions; mark releases with annotated tags and push them explicitly.
- Atomic commits and Conventional Commits (`feat`, `fix`, `!` for breaking) make history readable and enable automated versioning.
- Choose one merge policy, enforce it in repository settings, and document everything in `CONTRIBUTING.md`.

## Quiz

1. How does trunk-based development let a team build a feature that takes a month without a month-long branch?

   A. By using a develop branch for unfinished work
   B. By merging small pieces to main behind a feature flag that keeps the feature switched off
   C. By rebasing the feature branch every day
   D. By committing everything in one large commit at the end

   **Answer: B.** Feature flags let incomplete code ship safely in the off state, so work integrates daily. Rebasing a long branch daily reduces conflicts but still keeps the work isolated and unreviewed for a month.

2. Under Conventional Commits and semantic versioning, which commit should trigger a MAJOR version bump?

   A. fix(auth): handle expired tokens
   B. feat(search): add fuzzy matching
   C. chore(deps): upgrade all dependencies
   D. feat(api)!: remove the v1 endpoints

   **Answer: D.** The ! marks a breaking change, which bumps MAJOR. feat bumps MINOR, fix bumps PATCH, and chore has no version effect, even if the upgrade is large.

3. You created an annotated tag v2.0.0 and ran git push. Teammates cannot see the tag. Why?

   A. A plain git push does not push tags; push it with git push origin v2.0.0 or use --follow-tags
   B. Annotated tags are local-only by design
   C. Tags are only visible after a git pull --rebase
   D. The tag must be on the main branch to be pushed

   **Answer: A.** git push sends branches, not tags, by default. Push the tag explicitly, or use --follow-tags to include annotated tags reachable from the pushed commits.

4. Which project is the best fit for Git flow or long-lived release branches?

   A. A marketing website redeployed on every merge
   B. An internal API with one production environment and continuous deployment
   C. A desktop application where customers run versions 3.x and 4.x and both receive security fixes
   D. A prototype built by one developer

   **Answer: C.** Release branches earn their overhead when several versions must be maintained at once. Continuously deployed services with one live version are better served by GitHub flow or trunk-based development.
