Step 09 of 10
Team workflows
Compare GitHub flow, trunk-based development and Git flow, tag releases, and write commits that follow Conventional Commits.
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.
mainis always deployable.- For any change, create a descriptively named branch from
main. - Commit, push, and open a pull request early.
- Discuss, review and let CI run; push fixes to the same branch.
- Merge into
mainonce approved and green. - Deploy
main(often automatically on every merge).
main ●───────●───────────●──────────● (each merge is deployed)
\ / \ /
feature/a ●───● \ /
●────● fix/login-redirectThere 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:
feature/search-notes
fix/login-redirect
docs/install-steps
chore/upgrade-node-22GitHub 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.
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.
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.
Git flow and release branches
Git flow, popularised in 2010, uses two permanent branches and three kinds of temporary ones:
mainholds only released code, with a tag for every release.developcollects finished features for the next release.feature/*branches start from and merge back intodevelop.release/*branches are cut fromdevelopto stabilise a version, then merged into bothmainanddevelop.hotfix/*branches start frommainfor urgent fixes, and merge into both.
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 developThat 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):
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
git tag -l "v1.*"v1.4.0Tags 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:
v1.4.0-1-g290ba86That 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:
<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:
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: #212Real git log --oneline from a team using it:
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.2The 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.
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.
Show solution
A strong proposal looks something like this:
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.
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 --decorateKey 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.
Check yourself
Quiz
0/4 correct · 4 left
1.How does trunk-based development let a team build a feature that takes a month without a month-long branch?
2.Under Conventional Commits and semantic versioning, which commit should trigger a MAJOR version bump?
3.You created an annotated tag v2.0.0 and ran git push. Teammates cannot see the tag. Why?
4.Which project is the best fit for Git flow or long-lived release branches?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.