Skip to content

Commit 1db0687

Browse files
Saadnajmiclaude
andauthored
docs: add backporting guide and automation (#2863)
## Summary Add backporting infrastructure for cherry-picking changes across release branches. This includes: - **`.ai/commands/backport.md`** — Shared AI-tool-agnostic backport instructions (works with Claude Code, Copilot, or any AI assistant) - **`.claude/commands/backport.md`** — Claude Code `/backport` slash command wrapper - **`.github/workflows/microsoft-backport.yml`** — GitHub Actions workflow triggered by `/backport <branch>` comments on PRs, with auto-update support when source PR changes - **`docsite/docs/contributing/backporting.md`** — Documentation page covering all backport methods ### Features - Multi-branch support: `/backport 0.81-stable 0.82-stable` - Works on both open and merged PRs - Auto-updates backport PRs when source PR gets new commits - GitHub App authentication via `actions/create-github-app-token@v2` - Handles conflicts gracefully with manual instructions ## Test plan - [ ] Verify docsite builds: `cd docsite && yarn start` — check "Backporting" page under Contributing - [ ] Test `/backport` slash command locally in Claude Code - [ ] Merge a test PR, comment `/backport 0.81-stable`, verify workflow creates backport PR 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 888b052 commit 1db0687

File tree

4 files changed

+506
-0
lines changed

4 files changed

+506
-0
lines changed

.ai/commands/backport.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Backport to Stable Branch(es)
2+
3+
Backport the current branch's commits to one or more stable release branches.
4+
5+
## Arguments
6+
7+
The user provides one or more target stable branches as space-separated arguments (e.g., `0.81-stable` or `0.81-stable 0.82-stable`).
8+
9+
## Conventions
10+
11+
- **Branch naming:** If the current feature branch is `foo` and the target is `0.81-stable`, the backport branch is `0.81/foo`.
12+
- **PR title transformation:**
13+
- `type(scope): description` becomes `type(0.81, scope): description`
14+
- `type: description` (no scope) becomes `type(0.81): description`
15+
- The version number is extracted by stripping `-stable` from the target branch name.
16+
17+
## Steps
18+
19+
### 1. Validate
20+
21+
- Parse the arguments for one or more target branch names. If no arguments were provided, ask the user which stable branch(es) to target.
22+
- Run `git branch --show-current` to get the current branch name. Call this `FEATURE_BRANCH`.
23+
- **Refuse** if the current branch is `main` or matches `*-stable` — the user should be on a feature branch.
24+
- Verify each target branch exists on the `upstream` remote by running `git ls-remote --heads upstream <target-branch>`. If a target doesn't exist, warn the user and skip it.
25+
26+
### 2. Identify commits to cherry-pick
27+
28+
- Find the merge base: `git merge-base main HEAD`
29+
- List commits: `git log --reverse --format="%H" <merge-base>..HEAD`
30+
- If there are no commits, warn the user and stop.
31+
- Show the user the list of commits that will be cherry-picked and confirm before proceeding.
32+
33+
### 3. For each target branch
34+
35+
For each target branch (e.g., `0.81-stable`):
36+
37+
#### a. Extract version
38+
Strip the `-stable` suffix to get the version number (e.g., `0.81`).
39+
40+
#### b. Fetch and create the backport branch
41+
```bash
42+
git fetch upstream <target-branch>
43+
git checkout -b <version>/<FEATURE_BRANCH> upstream/<target-branch>
44+
```
45+
46+
If the branch `<version>/<FEATURE_BRANCH>` already exists, ask the user whether to overwrite it or skip this target.
47+
48+
#### c. Cherry-pick commits
49+
```bash
50+
git cherry-pick <commit1> <commit2> ...
51+
```
52+
53+
If a cherry-pick fails due to conflicts:
54+
- Show the user the conflicting files (`git diff --name-only --diff-filter=U`)
55+
- Read the conflicting files and help resolve the conflicts interactively
56+
- After resolution, run `git add .` and `git cherry-pick --continue`
57+
58+
#### d. Transform the PR title
59+
60+
Take the title from the most recent commit message (or ask the user for the PR title). Apply the transformation:
61+
- If it matches `type(scope): description``type(<version>, scope): description`
62+
- If it matches `type: description``type(<version>): description`
63+
64+
#### e. Push and create PR
65+
```bash
66+
git push -u origin <version>/<FEATURE_BRANCH>
67+
```
68+
69+
Then create the PR:
70+
```bash
71+
gh pr create \
72+
--repo microsoft/react-native-macos \
73+
--base <target-branch> \
74+
--title "<transformed-title>" \
75+
--body "## Summary
76+
Backport of the changes from branch \`<FEATURE_BRANCH>\` to \`<target-branch>\`.
77+
78+
## Test Plan
79+
Same as the original PR."
80+
```
81+
82+
### 4. Return to original branch
83+
84+
After processing all target branches:
85+
```bash
86+
git checkout <FEATURE_BRANCH>
87+
```
88+
89+
Report a summary of what was done: which backport PRs were created, and any that were skipped or had issues.

.claude/commands/backport.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Follow the instructions in .ai/commands/backport.md
2+
3+
Target branches: $ARGUMENTS
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
name: Backport
2+
# Creates backport PRs when someone comments "/backport <branch>" on a PR.
3+
# Also auto-updates existing backport PRs when the source PR is updated.
4+
5+
on:
6+
issue_comment:
7+
types: [created]
8+
pull_request:
9+
types: [synchronize]
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
# ─── Job 1: Create backport PR(s) from a /backport comment ───
16+
backport:
17+
name: Create backport
18+
if: >
19+
github.event_name == 'issue_comment' &&
20+
github.event.issue.pull_request != '' &&
21+
startsWith(github.event.comment.body, '/backport ')
22+
runs-on: ubuntu-latest
23+
permissions:
24+
contents: write
25+
pull-requests: write
26+
steps:
27+
- name: Generate GitHub App token
28+
uses: actions/create-github-app-token@v2
29+
id: app-token
30+
with:
31+
app-id: ${{ vars.APP_ID }}
32+
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
33+
34+
- name: React to comment
35+
env:
36+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
37+
run: |
38+
gh api \
39+
--method POST \
40+
repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
41+
-f content='+1'
42+
43+
- name: Parse target branches
44+
id: parse
45+
env:
46+
COMMENT_BODY: ${{ github.event.comment.body }}
47+
run: |
48+
# Extract everything after "/backport " and split into branch names
49+
BRANCHES=$(echo "$COMMENT_BODY" | head -1 | sed 's|^/backport ||' | xargs)
50+
if [ -z "$BRANCHES" ]; then
51+
echo "::error::No target branches specified"
52+
exit 1
53+
fi
54+
echo "branches=$BRANCHES" >> "$GITHUB_OUTPUT"
55+
56+
- name: Get PR details
57+
id: pr
58+
env:
59+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
60+
PR_URL: ${{ github.event.issue.pull_request.url }}
61+
run: |
62+
PR_DATA=$(gh api "$PR_URL")
63+
echo "number=$(echo "$PR_DATA" | jq -r '.number')" >> "$GITHUB_OUTPUT"
64+
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> "$GITHUB_OUTPUT"
65+
echo "head_branch=$(echo "$PR_DATA" | jq -r '.head.ref')" >> "$GITHUB_OUTPUT"
66+
echo "merged=$(echo "$PR_DATA" | jq -r '.merged')" >> "$GITHUB_OUTPUT"
67+
echo "state=$(echo "$PR_DATA" | jq -r '.state')" >> "$GITHUB_OUTPUT"
68+
69+
- name: Checkout
70+
uses: actions/checkout@v4
71+
with:
72+
token: ${{ steps.app-token.outputs.token }}
73+
fetch-depth: 0
74+
75+
- name: Configure git
76+
run: |
77+
git config user.name "github-actions[bot]"
78+
git config user.email "github-actions[bot]@users.noreply.github.com"
79+
80+
- name: Create backport PRs
81+
env:
82+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
83+
BRANCHES: ${{ steps.parse.outputs.branches }}
84+
PR_NUMBER: ${{ steps.pr.outputs.number }}
85+
PR_TITLE: ${{ steps.pr.outputs.title }}
86+
HEAD_BRANCH: ${{ steps.pr.outputs.head_branch }}
87+
PR_MERGED: ${{ steps.pr.outputs.merged }}
88+
REPO: ${{ github.repository }}
89+
run: |
90+
RESULTS=""
91+
ANY_FAILED=false
92+
93+
# Get commits from the PR
94+
COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/commits" --jq '.[].sha')
95+
if [ -z "$COMMITS" ]; then
96+
echo "::error::No commits found in PR #$PR_NUMBER"
97+
exit 1
98+
fi
99+
100+
for TARGET_BRANCH in $BRANCHES; do
101+
echo "::group::Backporting to $TARGET_BRANCH"
102+
103+
# Extract version from branch name (e.g., 0.81-stable -> 0.81)
104+
VERSION=$(echo "$TARGET_BRANCH" | sed 's/-stable$//')
105+
BACKPORT_BRANCH="$VERSION/$HEAD_BRANCH"
106+
107+
# Transform PR title
108+
if echo "$PR_TITLE" | grep -qP '^\w+\([^)]+\):'; then
109+
# Has scope: type(scope): desc -> type(version, scope): desc
110+
NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/^(\w+)\(([^)]+)\):/\1($VERSION, \2):/")
111+
else
112+
# No scope: type: desc -> type(version): desc
113+
NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/^(\w+):/\1($VERSION):/")
114+
fi
115+
116+
# Check if target branch exists
117+
if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then
118+
echo "::warning::Target branch $TARGET_BRANCH does not exist, skipping"
119+
RESULTS="$RESULTS\n- :warning: \`$TARGET_BRANCH\`: branch does not exist"
120+
ANY_FAILED=true
121+
echo "::endgroup::"
122+
continue
123+
fi
124+
125+
# Create backport branch
126+
git checkout "origin/$TARGET_BRANCH"
127+
git checkout -B "$BACKPORT_BRANCH"
128+
129+
# Cherry-pick commits
130+
CHERRY_PICK_FAILED=false
131+
for COMMIT in $COMMITS; do
132+
if ! git cherry-pick "$COMMIT" --no-edit; then
133+
CHERRY_PICK_FAILED=true
134+
git cherry-pick --abort || true
135+
break
136+
fi
137+
done
138+
139+
if [ "$CHERRY_PICK_FAILED" = true ]; then
140+
echo "::warning::Cherry-pick failed for $TARGET_BRANCH"
141+
RESULTS="$RESULTS\n- :x: \`$TARGET_BRANCH\`: cherry-pick conflicts (manual backport needed)"
142+
ANY_FAILED=true
143+
echo "::endgroup::"
144+
continue
145+
fi
146+
147+
# Push the backport branch
148+
git push -f origin "$BACKPORT_BRANCH"
149+
150+
# Check if a backport PR already exists
151+
EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BACKPORT_BRANCH" --base "$TARGET_BRANCH" --json number --jq '.[0].number // empty')
152+
153+
if [ -n "$EXISTING_PR" ]; then
154+
echo "Backport PR #$EXISTING_PR already exists, updated via force-push"
155+
RESULTS="$RESULTS\n- :arrows_counterclockwise: \`$TARGET_BRANCH\`: updated existing PR #$EXISTING_PR"
156+
else
157+
# Create backport PR
158+
BACKPORT_PR_URL=$(gh pr create \
159+
--repo "$REPO" \
160+
--base "$TARGET_BRANCH" \
161+
--head "$BACKPORT_BRANCH" \
162+
--title "$NEW_TITLE" \
163+
--body "$(cat <<EOF
164+
## Summary
165+
Backport of #$PR_NUMBER to \`$TARGET_BRANCH\`.
166+
167+
## Test Plan
168+
Same as #$PR_NUMBER.
169+
EOF
170+
)")
171+
echo "Created backport PR: $BACKPORT_PR_URL"
172+
BACKPORT_PR_NUMBER=$(echo "$BACKPORT_PR_URL" | grep -oP '\d+$')
173+
RESULTS="$RESULTS\n- :white_check_mark: \`$TARGET_BRANCH\`: #$BACKPORT_PR_NUMBER"
174+
fi
175+
176+
echo "::endgroup::"
177+
done
178+
179+
# Post summary comment on the original PR
180+
COMMENT_BODY="## Backport results\n$RESULTS"
181+
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$(echo -e "$COMMENT_BODY")"
182+
183+
if [ "$ANY_FAILED" = true ]; then
184+
echo ""
185+
echo "Some backports failed. To backport manually:"
186+
echo " 1. git fetch origin <target-branch>"
187+
echo " 2. git checkout -b <version>/<branch> origin/<target-branch>"
188+
echo " 3. git cherry-pick <commits>"
189+
echo " 4. git push -u origin <version>/<branch>"
190+
echo " 5. gh pr create --base <target-branch>"
191+
fi
192+
193+
# ─── Job 2: Auto-update backport PRs when source PR is updated ───
194+
update-backport:
195+
name: Update backport PRs
196+
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
197+
runs-on: ubuntu-latest
198+
permissions:
199+
contents: write
200+
pull-requests: write
201+
steps:
202+
- name: Generate GitHub App token
203+
uses: actions/create-github-app-token@v2
204+
id: app-token
205+
with:
206+
app-id: ${{ vars.APP_ID }}
207+
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
208+
209+
- name: Find linked backport PRs
210+
id: find-backports
211+
env:
212+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
213+
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
214+
PR_NUMBER: ${{ github.event.pull_request.number }}
215+
REPO: ${{ github.repository }}
216+
run: |
217+
# Search for open PRs whose branch matches the pattern <version>/<head-branch>
218+
# e.g., if head_branch is "fix-focus", look for "*/fix-focus"
219+
BACKPORT_PRS=$(gh pr list \
220+
--repo "$REPO" \
221+
--state open \
222+
--json number,headRefName,baseRefName \
223+
--jq ".[] | select(.headRefName | test(\"^[0-9]+\\\\.[0-9]+/$HEAD_BRANCH\$\")) | \"\(.number) \(.headRefName) \(.baseRefName)\"")
224+
225+
if [ -z "$BACKPORT_PRS" ]; then
226+
echo "No backport PRs found for branch $HEAD_BRANCH"
227+
echo "found=false" >> "$GITHUB_OUTPUT"
228+
else
229+
echo "Found backport PRs:"
230+
echo "$BACKPORT_PRS"
231+
echo "found=true" >> "$GITHUB_OUTPUT"
232+
# Write to a file to handle multiline
233+
echo "$BACKPORT_PRS" > /tmp/backport-prs.txt
234+
fi
235+
236+
- name: Checkout
237+
if: steps.find-backports.outputs.found == 'true'
238+
uses: actions/checkout@v4
239+
with:
240+
token: ${{ steps.app-token.outputs.token }}
241+
fetch-depth: 0
242+
243+
- name: Configure git
244+
if: steps.find-backports.outputs.found == 'true'
245+
run: |
246+
git config user.name "github-actions[bot]"
247+
git config user.email "github-actions[bot]@users.noreply.github.com"
248+
249+
- name: Update backport PRs
250+
if: steps.find-backports.outputs.found == 'true'
251+
env:
252+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
253+
PR_NUMBER: ${{ github.event.pull_request.number }}
254+
REPO: ${{ github.repository }}
255+
run: |
256+
# Get current commits from the source PR
257+
COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/commits" --jq '.[].sha')
258+
259+
while IFS= read -r LINE; do
260+
BP_NUMBER=$(echo "$LINE" | awk '{print $1}')
261+
BP_BRANCH=$(echo "$LINE" | awk '{print $2}')
262+
BP_BASE=$(echo "$LINE" | awk '{print $3}')
263+
264+
echo "::group::Updating backport PR #$BP_NUMBER ($BP_BRANCH -> $BP_BASE)"
265+
266+
# Reset the backport branch to the target stable branch
267+
git checkout "origin/$BP_BASE"
268+
git checkout -B "$BP_BRANCH"
269+
270+
# Re-cherry-pick all commits
271+
CHERRY_PICK_FAILED=false
272+
for COMMIT in $COMMITS; do
273+
if ! git cherry-pick "$COMMIT" --no-edit; then
274+
CHERRY_PICK_FAILED=true
275+
git cherry-pick --abort || true
276+
break
277+
fi
278+
done
279+
280+
if [ "$CHERRY_PICK_FAILED" = true ]; then
281+
echo "::warning::Cherry-pick failed while updating backport PR #$BP_NUMBER"
282+
gh pr comment "$PR_NUMBER" --repo "$REPO" \
283+
--body ":warning: Failed to auto-update backport PR #$BP_NUMBER to \`$BP_BASE\` due to conflicts. Manual update needed."
284+
gh pr comment "$BP_NUMBER" --repo "$REPO" \
285+
--body ":warning: Auto-update from source PR #$PR_NUMBER failed due to cherry-pick conflicts. Manual update needed."
286+
else
287+
git push -f origin "$BP_BRANCH"
288+
echo "Successfully updated backport PR #$BP_NUMBER"
289+
fi
290+
291+
echo "::endgroup::"
292+
done < /tmp/backport-prs.txt

0 commit comments

Comments
 (0)