Automating backport pull requests with labels Jump to heading

Manual backporting has a familiar failure mode: the fix merges to main, everyone agrees it needs to go to the release branch, and three weeks later a customer reports the bug again because nobody did it. Label-driven automation makes the decision and the action the same step. A reviewer adds a label such as backport release/2.4 to the pull request; when it merges, a workflow cherry-picks it onto a new branch from release/2.4 and opens a pull request. If the cherry-pick conflicts, the workflow says so on the original pull request and names the person who should finish it. This page builds that workflow without third-party actions, within cherry-pick and backporting.

When to use this approach Jump to heading

  • You maintain one or more release branches that receive fixes from main.
  • Backports are sometimes forgotten, or done inconsistently.
  • Fixes land on main first, and release branches only ever receive cherry-picks β€” the forward-only flow described in cherry-picking hotfixes across release branches.
  • You want backports reviewed and tested like any other change, through pull requests.

Step 1 β€” Define labels that name target branches Jump to heading

One label per supported release branch, with a fixed prefix the workflow can parse. Create them in every repository that has release branches.

for b in release/2.3 release/2.4; do
  gh label create "backport $b" --color FBCA04 --description "Cherry-pick to $b after merge" --force
done
gh label list | grep '^backport '
From label to backport pull requestA reviewer adds a backport label to a pull request. When it merges, the workflow reads the label, creates a branch from the release branch, cherry-picks the merge with -x, pushes it and opens a pull request targeting the release branch, linking back to the original.Labelbackport release/2.4Merge to mainclosed + mergedCherry-pick-x -m 1 onto releasePush branchbackport/2.4/pr-812Open PRlinks originalthe label is the decision; everything after it is mechanical

Step 2 β€” Trigger on merged pull requests with backport labels Jump to heading

Run on pull_request closed events, filter to merged ones that carry at least one backport label, and fan out one job per target branch.

# .github/workflows/backport.yml
on:
  pull_request:
    types: [closed, labeled]
permissions: { contents: write, pull-requests: write }
jobs:
  targets:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    outputs: { branches: "${{ steps.t.outputs.branches }}" }
    steps:
      - id: t
        run: |
          echo "branches=$(jq -c '[.pull_request.labels[].name | select(startswith("backport ")) | sub("^backport ";"")]' "$GITHUB_EVENT_PATH")" >> "$GITHUB_OUTPUT"
  backport:
    needs: targets
    if: needs.targets.outputs.branches != '[]'
    strategy: { matrix: { target: "${{ fromJSON(needs.targets.outputs.branches) }}" }, fail-fast: false }
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: ./ci/backport.sh "${{ matrix.target }}" "${{ github.event.pull_request.number }}" "${{ github.event.pull_request.merge_commit_sha }}"
        env: { GH_TOKEN: "${{ github.token }}" }

Including labeled in the trigger types means adding a label to an already-merged pull request also triggers a backport β€” useful when the need is discovered after merge.

Step 3 β€” Cherry-pick and open the pull request Jump to heading

The script handles merge commits and squash commits alike: it checks the parent count and adds -m 1 only for merges.

#!/bin/sh
# ci/backport.sh <target-branch> <pr-number> <merge-sha>
set -eu
target=$1 pr=$2 sha=$3
branch="backport/${target#release/}/pr-$pr"
git config user.name "backport-bot"; git config user.email "[email protected]"
git switch -c "$branch" "origin/$target"

parents=$(git rev-list --parents -n1 "$sha" | wc -w)
mflag=""; [ "$parents" -gt 2 ] && mflag="-m 1"

if git cherry-pick -x $mflag "$sha"; then
  git push -u origin "$branch"
  title=$(gh pr view "$pr" --json title --jq .title)
  gh pr create --base "$target" --head "$branch" \
    --title "[${target#release/}] $title" \
    --body "Backport of #$pr to \`$target\`, created automatically."
else
  git cherry-pick --abort
  author=$(gh pr view "$pr" --json author --jq .author.login)
  gh pr comment "$pr" --body "@$author automatic backport to \`$target\` hit conflicts. Run:
\`\`\`
git switch -c $branch origin/$target
git cherry-pick -x $mflag $sha
\`\`\`
then resolve and open a PR against \`$target\`."
  exit 1
fi
What the workflow does with each targetFor each backport label, a clean cherry-pick produces a pushed branch and an opened pull request. A conflicting cherry-pick is aborted and the original author is asked, with exact commands, to finish it by hand. A missing target branch is reported as a configuration error.Cherry-pick onto the target branch?applies cleanlyOpen backport PRreview + CI as usualconflictsComment on originalauthor finishes by handtarget missingFail loudlyfix the labelthe job fails on conflict so the red mark is visible on the original pull request
# Verification: label a merged test PR and watch for the new pull request
gh pr edit 812 --add-label "backport release/2.4"
gh run watch && gh pr list --base release/2.4 --head "backport/2.4/pr-812"

Step 4 β€” Make backport pull requests flow through the normal gates Jump to heading

Backports must pass the release branch’s checks, which may differ from main’s. Do not let the bot bypass review on release branches; a cherry-pick that applies cleanly can still be wrong for old code.

# CODEOWNERS on release branches: release managers approve every backport
*   @acme/release-managers

Pull requests opened with the default workflow token do not trigger other workflows on some forges, which would leave the backport without CI. If that applies to you, open the pull request with an app token instead, as described in verified bot commits with a GitHub App identity.

Step 5 β€” Report what was backported where Jump to heading

With every backport going through the same workflow and carrying -x trailers, producing a per-release list is a query, not an investigation.

# Fixes on release/2.4 that came from main, with their source commits
git log --format='%h %s' --grep='cherry picked from commit' v2.4.0..origin/release/2.4
# Pull requests labelled for 2.4 that do not yet have a merged backport
gh pr list --state merged --label "backport release/2.4" --json number,title --jq '.[] | "\(.number) \(.title)"'
What label-driven backporting gives youDecisions are recorded as labels on the original pull request. Clean backports happen within minutes of merge. Conflicts are routed to the author with exact instructions. Every backport carries a trailer naming its source, so reports are queries.Decisiona label, reviewedSpeedminutes after mergeConflictsrouted to authorTraceability-x trailer on eachforgotten backports become visible: a label with no backport PR is a query away

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should backports to several branches be chained, newest first? Jump to heading

The workflow above picks from main to each branch independently, which is simplest. When older branches conflict often, chaining β€” picking the 2.4 backport onto 2.3 β€” reduces conflicts because each step crosses less history. That requires the job for 2.3 to wait for the 2.4 backport to merge.

Can the bot auto-merge clean backports? Jump to heading

It can, if release branch CI is strong and the release managers are comfortable with it. Many teams require one approval anyway, because a clean cherry-pick is not proof that the fix suits old code.

What about existing tools that do this? Jump to heading

Several open-source backport actions and bots exist and handle edge cases well. The script here is short enough to understand fully, which matters for something that writes to release branches; use a tool when you need its extra features.