From 0c5b4c4c093e374ed7a8a311a0e2adc154ed6e0a Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 16:12:17 -0500 Subject: [PATCH] docs: add backporting guide and automation Add backporting infrastructure for cherry-picking changes across release branches: - .ai/commands/backport.md: shared AI-tool-agnostic backport instructions - .claude/commands/backport.md: Claude Code slash command wrapper - .github/workflows/microsoft-backport.yml: GH Actions workflow for /backport comments with auto-update support - docsite/docs/contributing/backporting.md: documentation page Co-Authored-By: Claude Opus 4.6 --- .ai/commands/backport.md | 89 +++++++ .claude/commands/backport.md | 3 + .github/workflows/microsoft-backport.yml | 292 +++++++++++++++++++++++ docsite/docs/contributing/backporting.md | 122 ++++++++++ 4 files changed, 506 insertions(+) create mode 100644 .ai/commands/backport.md create mode 100644 .claude/commands/backport.md create mode 100644 .github/workflows/microsoft-backport.yml create mode 100644 docsite/docs/contributing/backporting.md diff --git a/.ai/commands/backport.md b/.ai/commands/backport.md new file mode 100644 index 000000000000..5072bba6b693 --- /dev/null +++ b/.ai/commands/backport.md @@ -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 `. 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" ..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 +git checkout -b / upstream/ +``` + +If the branch `/` already exists, ask the user whether to overwrite it or skip this target. + +#### c. Cherry-pick commits +```bash +git cherry-pick ... +``` + +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(, scope): description` +- If it matches `type: description` → `type(): description` + +#### e. Push and create PR +```bash +git push -u origin / +``` + +Then create the PR: +```bash +gh pr create \ + --repo microsoft/react-native-macos \ + --base \ + --title "" \ + --body "## Summary +Backport of the changes from branch \`\` to \`\`. + +## Test Plan +Same as the original PR." +``` + +### 4. Return to original branch + +After processing all target branches: +```bash +git checkout +``` + +Report a summary of what was done: which backport PRs were created, and any that were skipped or had issues. diff --git a/.claude/commands/backport.md b/.claude/commands/backport.md new file mode 100644 index 000000000000..9b4167bdaf39 --- /dev/null +++ b/.claude/commands/backport.md @@ -0,0 +1,3 @@ +Follow the instructions in .ai/commands/backport.md + +Target branches: $ARGUMENTS diff --git a/.github/workflows/microsoft-backport.yml b/.github/workflows/microsoft-backport.yml new file mode 100644 index 000000000000..e970d331bae6 --- /dev/null +++ b/.github/workflows/microsoft-backport.yml @@ -0,0 +1,292 @@ +name: Backport +# Creates backport PRs when someone comments "/backport " 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 <" + echo " 2. git checkout -b / origin/" + echo " 3. git cherry-pick " + echo " 4. git push -u origin /" + echo " 5. gh pr create --base " + 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 / + # 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 diff --git a/docsite/docs/contributing/backporting.md b/docsite/docs/contributing/backporting.md new file mode 100644 index 000000000000..661fa364af43 --- /dev/null +++ b/docsite/docs/contributing/backporting.md @@ -0,0 +1,122 @@ +--- +sidebar_label: 'Backporting' +sidebar_position: 4 +--- + +# Backporting + +React Native macOS maintains multiple active release branches (e.g., `0.81-stable`, `0.82-stable`). When a fix or improvement lands on `main`, it often needs to be cherry-picked to one or more stable branches. This guide explains how to do that. + +## Conventions + +### Branch naming + +Backport branches follow the pattern `/`: + +| Original branch | Target | Backport branch | +|---|---|---| +| `fix-text-input` | `0.81-stable` | `0.81/fix-text-input` | +| `fix-text-input` | `0.82-stable` | `0.82/fix-text-input` | + +### PR title + +Backport PR titles include the version number in the conventional commit scope: + +| Original title | Backport title | +|---|---| +| `fix(textinput): handle focus correctly` | `fix(0.81, textinput): handle focus correctly` | +| `fix: handle focus correctly` | `fix(0.81): handle focus correctly` | + +## Methods + +### Method 1: AI-assisted (Claude Code / Copilot) + +The repo includes a shared backport command at `.ai/commands/backport.md` that any AI coding assistant can follow. + +**In Claude Code**, run: + +``` +/backport 0.81-stable +``` + +Or for multiple branches at once: + +``` +/backport 0.81-stable 0.82-stable +``` + +The assistant will: +1. Identify the commits on your current branch +2. Create backport branches for each target +3. Cherry-pick the commits (and help resolve conflicts interactively) +4. Transform the PR title +5. Push and create PRs + +### Method 2: GitHub comment + +On any PR (open or merged), comment: + +``` +/backport 0.81-stable +``` + +Or for multiple branches: + +``` +/backport 0.81-stable 0.82-stable +``` + +A GitHub Actions workflow will automatically: +1. Cherry-pick the PR's commits onto a new backport branch +2. Create a PR targeting the stable branch with the correct title format +3. React with a thumbs-up on your comment to confirm + +**Auto-updating:** If you use `/backport` on an **open** PR, the backport PR will automatically update whenever you push new commits to the source PR. This is useful when you want to keep the backport in sync during code review. + +**Conflict handling:** If cherry-picking fails due to conflicts, the workflow will comment on the original PR with instructions for manual backporting. + +### Method 3: Manual + +If you prefer to backport by hand: + +```bash +# 1. Make sure you're on your feature branch +git checkout my-feature-branch + +# 2. Note the commits to cherry-pick +git log --oneline $(git merge-base main HEAD)..HEAD + +# 3. Fetch the latest stable branch +git fetch upstream 0.81-stable + +# 4. Create the backport branch +git checkout -b 0.81/my-feature-branch upstream/0.81-stable + +# 5. Cherry-pick your commits +git cherry-pick ... + +# 6. Push and create a PR +git push -u origin 0.81/my-feature-branch +gh pr create \ + --repo microsoft/react-native-macos \ + --base 0.81-stable \ + --title "fix(0.81, scope): description" +``` + +## Handling conflicts + +Cherry-pick conflicts are common when backporting, especially for older release branches. When they occur: + +1. **Inspect the conflicts:** `git diff --name-only --diff-filter=U` +2. **Resolve each file** manually or with AI assistance +3. **Stage and continue:** `git add . && git cherry-pick --continue` + +If conflicts are too complex, consider whether the change needs to be adapted for the older branch rather than cherry-picked directly. + +## Multi-branch backports + +You can backport to multiple branches in a single command. Each backport is independent — if one fails due to conflicts, the others still proceed. Both the AI command and the GitHub comment support space-separated branch names: + +``` +/backport 0.81-stable 0.82-stable +```