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"
One week of a train, from cut to shipThe branch is cut on a fixed day. What follows is verification and fixes, each of which lands on the default branch first and is then cherry-picked onto the train. The release ships on schedule with whatever boarded before the cut.Cutbranch from mainTue 09:00Verifycandidate testingTue-WedFixes boardmain first, then pickWed-ThuFreezeno more boardingFri 09:00Shiptag and deployFri 14:00anything not ready on Tuesday morning travels next week, which is the point

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.

How commits reach a trainEvery commit originates on the default branch. Fixes for the candidate are cherry-picked onto the train branch with their source recorded, so each one exists in both places and nothing on the train is unique to it.nothing exists only on the trainmainABCF1F2release/2026.38BR1R2R1 and R2 are cherry-picks of F1 and F2, recorded with -x

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
A train against shipping on demandA train makes the release date predictable and batches coordination cost into one event, at the price of latency for anything that just missed a cut. Shipping on demand minimises latency but makes coordination continuous.Release trainShip on demandrelease dateknown weeks aheadwhenever readylatency for a changeup to one cadenceminutescoordination costonce per traincontinuousbranch divergence riskreal, needs disciplinenonetrains suit coordination-heavy releases; on demand suits everything else

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.