Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .ai/commands/backport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Backport to Stable Branch(es)

Backport the current branch's commits to one or more stable release branches.

## Arguments

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`).

## Conventions

- **Branch naming:** If the current feature branch is `foo` and the target is `0.81-stable`, the backport branch is `0.81/foo`.
- **PR title transformation:**
- `type(scope): description` becomes `type(0.81, scope): description`
- `type: description` (no scope) becomes `type(0.81): description`
- The version number is extracted by stripping `-stable` from the target branch name.

## Steps

### 1. Validate

- Parse the arguments for one or more target branch names. If no arguments were provided, ask the user which stable branch(es) to target.
- Run `git branch --show-current` to get the current branch name. Call this `FEATURE_BRANCH`.
- **Refuse** if the current branch is `main` or matches `*-stable` — the user should be on a feature branch.
- 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.

### 2. Identify commits to cherry-pick

- Find the merge base: `git merge-base main HEAD`
- List commits: `git log --reverse --format="%H" <merge-base>..HEAD`
- If there are no commits, warn the user and stop.
- Show the user the list of commits that will be cherry-picked and confirm before proceeding.

### 3. For each target branch

For each target branch (e.g., `0.81-stable`):

#### a. Extract version
Strip the `-stable` suffix to get the version number (e.g., `0.81`).

#### b. Fetch and create the backport branch
```bash
git fetch upstream <target-branch>
git checkout -b <version>/<FEATURE_BRANCH> upstream/<target-branch>
```

If the branch `<version>/<FEATURE_BRANCH>` already exists, ask the user whether to overwrite it or skip this target.

#### c. Cherry-pick commits
```bash
git cherry-pick <commit1> <commit2> ...
```

If a cherry-pick fails due to conflicts:
- Show the user the conflicting files (`git diff --name-only --diff-filter=U`)
- Read the conflicting files and help resolve the conflicts interactively
- After resolution, run `git add .` and `git cherry-pick --continue`

#### d. Transform the PR title

Take the title from the most recent commit message (or ask the user for the PR title). Apply the transformation:
- If it matches `type(scope): description` → `type(<version>, scope): description`
- If it matches `type: description` → `type(<version>): description`

#### e. Push and create PR
```bash
git push -u origin <version>/<FEATURE_BRANCH>
```

Then create the PR:
```bash
gh pr create \
--repo microsoft/react-native-macos \
--base <target-branch> \
--title "<transformed-title>" \
--body "## Summary
Backport of the changes from branch \`<FEATURE_BRANCH>\` to \`<target-branch>\`.

## Test Plan
Same as the original PR."
```

### 4. Return to original branch

After processing all target branches:
```bash
git checkout <FEATURE_BRANCH>
```

Report a summary of what was done: which backport PRs were created, and any that were skipped or had issues.
3 changes: 3 additions & 0 deletions .claude/commands/backport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Follow the instructions in .ai/commands/backport.md

Target branches: $ARGUMENTS
292 changes: 292 additions & 0 deletions .github/workflows/microsoft-backport.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
name: Backport
# Creates backport PRs when someone comments "/backport <branch>" on a PR.
# Also auto-updates existing backport PRs when the source PR is updated.

on:
issue_comment:
types: [created]
pull_request:
types: [synchronize]

permissions:
contents: read

jobs:
# ─── Job 1: Create backport PR(s) from a /backport comment ───
backport:
name: Create backport
if: >
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != '' &&
startsWith(github.event.comment.body, '/backport ')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Generate GitHub App token
uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

- name: React to comment
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
gh api \
--method POST \
repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
-f content='+1'

- name: Parse target branches
id: parse
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
# Extract everything after "/backport " and split into branch names
BRANCHES=$(echo "$COMMENT_BODY" | head -1 | sed 's|^/backport ||' | xargs)
if [ -z "$BRANCHES" ]; then
echo "::error::No target branches specified"
exit 1
fi
echo "branches=$BRANCHES" >> "$GITHUB_OUTPUT"

- name: Get PR details
id: pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_URL: ${{ github.event.issue.pull_request.url }}
run: |
PR_DATA=$(gh api "$PR_URL")
echo "number=$(echo "$PR_DATA" | jq -r '.number')" >> "$GITHUB_OUTPUT"
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> "$GITHUB_OUTPUT"
echo "head_branch=$(echo "$PR_DATA" | jq -r '.head.ref')" >> "$GITHUB_OUTPUT"
echo "merged=$(echo "$PR_DATA" | jq -r '.merged')" >> "$GITHUB_OUTPUT"
echo "state=$(echo "$PR_DATA" | jq -r '.state')" >> "$GITHUB_OUTPUT"

- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Create backport PRs
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCHES: ${{ steps.parse.outputs.branches }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
PR_TITLE: ${{ steps.pr.outputs.title }}
HEAD_BRANCH: ${{ steps.pr.outputs.head_branch }}
PR_MERGED: ${{ steps.pr.outputs.merged }}
REPO: ${{ github.repository }}
run: |
RESULTS=""
ANY_FAILED=false

# Get commits from the PR
COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/commits" --jq '.[].sha')
if [ -z "$COMMITS" ]; then
echo "::error::No commits found in PR #$PR_NUMBER"
exit 1
fi

for TARGET_BRANCH in $BRANCHES; do
echo "::group::Backporting to $TARGET_BRANCH"

# Extract version from branch name (e.g., 0.81-stable -> 0.81)
VERSION=$(echo "$TARGET_BRANCH" | sed 's/-stable$//')
BACKPORT_BRANCH="$VERSION/$HEAD_BRANCH"

# Transform PR title
if echo "$PR_TITLE" | grep -qP '^\w+\([^)]+\):'; then
# Has scope: type(scope): desc -> type(version, scope): desc
NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/^(\w+)\(([^)]+)\):/\1($VERSION, \2):/")
else
# No scope: type: desc -> type(version): desc
NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/^(\w+):/\1($VERSION):/")
fi

# Check if target branch exists
if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null 2>&1; then
echo "::warning::Target branch $TARGET_BRANCH does not exist, skipping"
RESULTS="$RESULTS\n- :warning: \`$TARGET_BRANCH\`: branch does not exist"
ANY_FAILED=true
echo "::endgroup::"
continue
fi

# Create backport branch
git checkout "origin/$TARGET_BRANCH"
git checkout -B "$BACKPORT_BRANCH"

# Cherry-pick commits
CHERRY_PICK_FAILED=false
for COMMIT in $COMMITS; do
if ! git cherry-pick "$COMMIT" --no-edit; then
CHERRY_PICK_FAILED=true
git cherry-pick --abort || true
break
fi
done

if [ "$CHERRY_PICK_FAILED" = true ]; then
echo "::warning::Cherry-pick failed for $TARGET_BRANCH"
RESULTS="$RESULTS\n- :x: \`$TARGET_BRANCH\`: cherry-pick conflicts (manual backport needed)"
ANY_FAILED=true
echo "::endgroup::"
continue
fi

# Push the backport branch
git push -f origin "$BACKPORT_BRANCH"

# Check if a backport PR already exists
EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BACKPORT_BRANCH" --base "$TARGET_BRANCH" --json number --jq '.[0].number // empty')

if [ -n "$EXISTING_PR" ]; then
echo "Backport PR #$EXISTING_PR already exists, updated via force-push"
RESULTS="$RESULTS\n- :arrows_counterclockwise: \`$TARGET_BRANCH\`: updated existing PR #$EXISTING_PR"
else
# Create backport PR
BACKPORT_PR_URL=$(gh pr create \
--repo "$REPO" \
--base "$TARGET_BRANCH" \
--head "$BACKPORT_BRANCH" \
--title "$NEW_TITLE" \
--body "$(cat <<EOF
## Summary
Backport of #$PR_NUMBER to \`$TARGET_BRANCH\`.

## Test Plan
Same as #$PR_NUMBER.
EOF
)")
echo "Created backport PR: $BACKPORT_PR_URL"
BACKPORT_PR_NUMBER=$(echo "$BACKPORT_PR_URL" | grep -oP '\d+$')
RESULTS="$RESULTS\n- :white_check_mark: \`$TARGET_BRANCH\`: #$BACKPORT_PR_NUMBER"
fi

echo "::endgroup::"
done

# Post summary comment on the original PR
COMMENT_BODY="## Backport results\n$RESULTS"
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$(echo -e "$COMMENT_BODY")"

if [ "$ANY_FAILED" = true ]; then
echo ""
echo "Some backports failed. To backport manually:"
echo " 1. git fetch origin <target-branch>"
echo " 2. git checkout -b <version>/<branch> origin/<target-branch>"
echo " 3. git cherry-pick <commits>"
echo " 4. git push -u origin <version>/<branch>"
echo " 5. gh pr create --base <target-branch>"
fi

# ─── Job 2: Auto-update backport PRs when source PR is updated ───
update-backport:
name: Update backport PRs
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Generate GitHub App token
uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

- name: Find linked backport PRs
id: find-backports
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
# Search for open PRs whose branch matches the pattern <version>/<head-branch>
# e.g., if head_branch is "fix-focus", look for "*/fix-focus"
BACKPORT_PRS=$(gh pr list \
--repo "$REPO" \
--state open \
--json number,headRefName,baseRefName \
--jq ".[] | select(.headRefName | test(\"^[0-9]+\\\\.[0-9]+/$HEAD_BRANCH\$\")) | \"\(.number) \(.headRefName) \(.baseRefName)\"")

if [ -z "$BACKPORT_PRS" ]; then
echo "No backport PRs found for branch $HEAD_BRANCH"
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "Found backport PRs:"
echo "$BACKPORT_PRS"
echo "found=true" >> "$GITHUB_OUTPUT"
# Write to a file to handle multiline
echo "$BACKPORT_PRS" > /tmp/backport-prs.txt
fi

- name: Checkout
if: steps.find-backports.outputs.found == 'true'
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0

- name: Configure git
if: steps.find-backports.outputs.found == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Update backport PRs
if: steps.find-backports.outputs.found == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
# Get current commits from the source PR
COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/commits" --jq '.[].sha')

while IFS= read -r LINE; do
BP_NUMBER=$(echo "$LINE" | awk '{print $1}')
BP_BRANCH=$(echo "$LINE" | awk '{print $2}')
BP_BASE=$(echo "$LINE" | awk '{print $3}')

echo "::group::Updating backport PR #$BP_NUMBER ($BP_BRANCH -> $BP_BASE)"

# Reset the backport branch to the target stable branch
git checkout "origin/$BP_BASE"
git checkout -B "$BP_BRANCH"

# Re-cherry-pick all commits
CHERRY_PICK_FAILED=false
for COMMIT in $COMMITS; do
if ! git cherry-pick "$COMMIT" --no-edit; then
CHERRY_PICK_FAILED=true
git cherry-pick --abort || true
break
fi
done

if [ "$CHERRY_PICK_FAILED" = true ]; then
echo "::warning::Cherry-pick failed while updating backport PR #$BP_NUMBER"
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body ":warning: Failed to auto-update backport PR #$BP_NUMBER to \`$BP_BASE\` due to conflicts. Manual update needed."
gh pr comment "$BP_NUMBER" --repo "$REPO" \
--body ":warning: Auto-update from source PR #$PR_NUMBER failed due to cherry-pick conflicts. Manual update needed."
else
git push -f origin "$BP_BRANCH"
echo "Successfully updated backport PR #$BP_NUMBER"
fi

echo "::endgroup::"
done < /tmp/backport-prs.txt
Loading
Loading