Running a scheduled release train Jump to heading
A release train ships whatever is ready at a fixed time and leaves the rest for the next departure. The appeal is predictability: everyone knows when the cut happens, integration work has a deadline that is not negotiable, and the release itself stops being an event that consumes a week. The risk is that the train branch becomes a second line of development, accumulating fixes that never return to the default branch. This recipe runs a train that keeps its guarantees, within environment and deployment branches.
When to use this approach Jump to heading
- Releases require coordination β app store review, a customer notice period, a regulated change window.
- Several teams contribute to one shippable artefact.
- Continuous deployment is not available and a batch is unavoidable.
- The current release process is ad hoc and consumes a disproportionate amount of attention.
- If you can deploy any commit at any time, a train adds latency for no benefit; use trunk-based development setup instead.
Step 1 β Cut the branch automatically, on the schedule Jump to heading
A cut that requires someone to remember is a cut that slips.
# .github/workflows/cut-release.yml
name: cut-release
on:
schedule: [{ cron: '0 9 * * 2' }] # 09:00 UTC every Tuesday
jobs:
cut:
runs-on: ubuntu-latest
permissions: { contents: write }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: |
version=$(date -u +%Y.%V)
git switch -c "release/$version" origin/main
git push origin "release/$version"
git tag -a "v$version-rc1" -m "Release candidate 1 for $version"
git push origin "v$version-rc1" # Verification: the branch exists and points at the default branch tip
git ls-remote --heads origin 'release/*' | tail -1
git merge-base --is-ancestor "origin/release/$(date -u +%Y.%V)" origin/main && echo "cut cleanly" Step 2 β Decide the boarding rule before the first departure Jump to heading
The rule needs to be unambiguous, because it will be tested during the first week by someone who is nearly finished.
# The rule, written where people will find it:
# Merged to main before the cut β on this train, automatically
# Merged after the cut β next train
# Exception: a fix for something broken IN this train's candidate # What boarded this week?
git log --oneline "origin/release/$(date -u -d 'last tuesday' +%Y.%V)..origin/release/$(date -u +%Y.%V)" The exception is deliberately narrow. βFixes a problem in this candidateβ is verifiable; βis really importantβ is not, and the second version is how a train becomes a negotiation every week.
Step 3 β Land fixes on the default branch first, then cherry-pick Jump to heading
Every fix for the candidate follows the same order as a hotfix, for the same reason.
# Fix on the default branch
git switch main && git switch -c fix/train-regression
# ... commit ...
gh pr create --base main && gh pr merge --squash
# Then board it
git fetch origin
git switch "release/$(date -u +%Y.%V)"
git cherry-pick -x "$(git rev-parse origin/main)"
git push origin HEAD # Verification: everything on the train exists on the default branch
git log --format='%H' "origin/main..origin/release/$(date -u +%Y.%V)" | while read -r sha; do
src=$(git log -1 --format='%b' "$sha" | sed -n 's/^(cherry picked from commit \(.*\))$/\1/p')
[ -n "$src" ] && git merge-base --is-ancestor "$src" origin/main \
|| echo "NOT ON MAIN: $sha"
done The -x flag records the source commit in the message, which is what makes that verification possible. Without it, tracing a train commit back to the default branch is manual work β the reasoning is in when to cherry-pick vs backport a full branch.
Step 4 β Make missing the train cheap Jump to heading
The behaviour a train produces depends entirely on the cost of waiting. If the next departure is a month away, people will rush work to board; if it is a week, they will not.
# How often does the train actually depart?
git tag -l 'v20*' --sort=-creatordate --format='%(creatordate:short) %(refname:short)' | head -8 # And how much boards each week?
for t in $(git tag -l 'v20*' --sort=-creatordate | head -5); do
prev=$(git describe --tags --abbrev=0 "$t^" 2>/dev/null) || continue
printf '%-12s %s commits\n' "$t" "$(git rev-list --count "$prev..$t")"
done SAFETY WARNING β resist adding a second train for urgent work. Two cadences means every change needs a routing decision, the urgent line attracts anything anyone cares about, and within a quarter the ordinary train carries nothing. Keep one train and one genuine emergency path β the hotfix procedure β and make the emergency path visibly exceptional.
Step 5 β Ship, tag, and merge the train back Jump to heading
version=$(date -u +%Y.%V)
git tag -s "v$version" -m "Release $version" "origin/release/$version"
git push origin "v$version" # Merge the train back so any fix made only there is preserved
git switch main
git merge --no-ff "origin/release/$version" -m "chore(release): merge $version back into main"
git push origin main # Verification: nothing is left on the train that is not on the default branch
git log --oneline "origin/main..origin/release/$version" | wc -l # expect 0 Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
What cadence should we choose? Jump to heading
The shortest one your coordination costs allow. Weekly works for most teams; fortnightly is the point at which people start negotiating to board. If the cadence has to be monthly because releasing is expensive, the expense of releasing is the thing to fix.
Do we need a branch at all, or can we tag the default branch? Jump to heading
If nothing needs to change after the cut, a tag is enough and is simpler. A branch earns its place only when the candidate receives fixes while the default branch continues moving β which is the usual case for anything with a verification period.
How do we handle a change that must not ship yet? Jump to heading
Behind a flag, merged and dormant, which is the technique in feature flags vs feature branches for unfinished work. Holding the change out of the default branch to keep it off the train reintroduces long-lived branches, and with them the integration cost the train was meant to bound.
Related Jump to heading
- Environment & Deployment Branches β the parent topic and the promotion invariant.
- Hotfix Branches That Do Not Drift From Main β the one exception to the boarding rule.
- Choosing a Branching Model for a Mobile Release Train β the case where a train is genuinely unavoidable.