Running releases with GitHub Flow Jump to heading
GitHub Flow has one long-lived branch. Work happens on short branches, merges to main through pull requests, and main is always deployable. There are no release branches, so the question “how do we release?” has a different answer than in GitFlow: every merge is a release candidate, and releasing is deploying main. That simplicity is the point, and it holds up only if a few supporting practices are in place: continuous deployment or a cadence of tagged deploys, feature flags for work that is not ready for users, a fast rollback, and a clear record of what was deployed when. This page sets those up, within GitFlow vs GitHub Flow comparison.
When to use this approach Jump to heading
- You run a service deployed by your own team, with one version in production at a time.
- You use or are moving to GitHub Flow and need a release process to go with it.
- Release branches feel like overhead for your deploy cadence.
- If you must support several versions at once — installed software, mobile apps — see supporting multiple maintained versions instead.
Step 1 — Make main always deployable Jump to heading
Everything depends on this. Pull requests must pass the full required checks before merging, and those checks must run on the merged result, not the branch head. A merge queue ensures the exact commit that lands has been tested.
# main: required checks, up-to-date or merge queue, no direct pushes
gh api "repos/$OWNER/$REPO/branches/main/protection" --jq '{checks: .required_status_checks.contexts, strict: .required_status_checks.strict}' The merge-result testing is covered in catching semantic conflicts in CI.
Step 2 — Deploy on merge, or on a cadence Jump to heading
Teams deploy either every merge to main (continuous deployment) or main at a fixed cadence — several times a day, or daily. Both are GitHub Flow; the difference is how much changes per deploy.
# Continuous deployment: every push to main deploys
on:
push:
branches: [main]
concurrency: { group: deploy-production, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh production "$GITHUB_SHA" The concurrency group serialises deploys so two merges in quick succession do not race; cancel-in-progress: false lets the earlier deploy finish rather than leaving production half-updated.
Step 3 — Tag what shipped Jump to heading
Without release branches, tags are the record of what was deployed. Tag each production deploy, automatically, with a version or a date-based name.
# After a successful deploy
tag="deploy-$(date -u +%Y%m%d-%H%M)"
git tag -a "$tag" -m "Deployed to production" "$GITHUB_SHA"
git push origin "$tag" # What changed between two deploys?
git log --oneline --first-parent deploy-20261001-0915..deploy-20261001-1440 For semantic versions in GitHub Flow, compute them from commit messages, as in automating changelog generation with semantic-release. Tracking deployments is covered in tracking what is deployed with tags and notes.
Step 4 — Separate deploying from releasing with flags Jump to heading
Merging to main means deploying. Work that is not ready for users must still be safe to deploy — hidden behind a feature flag and turned on separately. That separation is what lets unfinished work merge daily instead of living on a long branch.
if flags.enabled("new-checkout", user=request.user):
return new_checkout(request)
return legacy_checkout(request) The trade-offs are in feature flags vs feature branches for unfinished work.
Step 5 — Roll back by redeploying, fix forward by default Jump to heading
When a deploy goes wrong, the fastest recovery is redeploying the previous tag — not reverting in Git, which needs a review and a pipeline run. Then fix forward with a normal pull request, or revert the offending merge.
# Immediate rollback: redeploy the last good tag
prev=$(git tag --list 'deploy-*' --sort=-creatordate | sed -n 2p)
./deploy.sh production "$(git rev-list -n1 "$prev")"
# Then: revert or fix through a pull request
git revert -m 1 <bad-merge-sha> Reverting merges correctly is covered in undoing a bad merge on a shared branch.
Step 6 — Keep a lightweight release record Jump to heading
Without release branches, it is easy to lose track of what went out together. A short record per deploy — tag, time, commits since the previous deploy — gives operations, support and auditors an answer without digging through CI logs.
prev=$(git tag --list 'deploy-*' --sort=-creatordate | sed -n 2p)
curr=$(git tag --list 'deploy-*' --sort=-creatordate | sed -n 1p)
{ echo "## $curr"; git log --first-parent --format='- %s (%an)' "$prev..$curr"; } >> docs/deploys.md Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can GitHub Flow support release notes and version numbers? Jump to heading
Yes. Versions come from tags, and release notes from commits or trailers between tags. Nothing in GitHub Flow prevents either; it just does not need a branch for them.
What about hotfixes? Jump to heading
There is no separate hotfix path: a hotfix is a small pull request to main that takes the fast route through review and the merge queue, as described in prioritising urgent changes in a merge queue.
When does GitHub Flow stop being enough? Jump to heading
When you must support more than one released version at a time, or when releases need long stabilisation periods. Then add release branches cut from main, which keeps most of GitHub Flow’s simplicity.
Related Jump to heading
- GitFlow vs GitHub Flow Comparison — the parent topic.
- Cutting Release Branches from Trunk — the next step up when needed.
- Keeping Trunk Green — the discipline main-as-release depends on.
- Rolling Back a Deployment with Git — more rollback techniques.