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.

The GitHub Flow release pathA short branch is opened from main, reviewed and tested on its merge result, merged through the queue, deployed automatically, and tagged with the deployed version. Unfinished features ride along behind flags, and a rollback redeploys the previous tag.Short branchfrom mainPR + checksmerge resultMerge queuetested = landedDeploy mainautomaticallyTagwhat shippedthere is no release branch because main is the release branch

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.

Deploy and release, separatedIn GitHub Flow, a merge deploys code to production immediately. A feature flag decides when users see it. Without flags, every merge is also a release, so unfinished work has to wait on a branch; with flags, it merges and ships dark until it is ready.Without flagsWith flagsmerge to main= release to users= deploy, hiddenunfinished workwaits on a branchmerges dailyturning a feature ona deploya flag changeflags are what keep branches short in a single-branch model
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>
A bad deploy, recoveredA merge deploys and errors rise. Within minutes the previous tag is redeployed, restoring service. A revert pull request is opened, tested and merged, and the next deploy carries the revert. The original change is fixed and re-lands later.Deployerrors rise14:40Redeploy prev tagservice restored14:46Revert PRmerged via queue15:05Next deploymain consistent15:20Fix re-landswith testnext dayredeploying is minutes; reverting in Git is the durable follow-up

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.