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
108 changes: 103 additions & 5 deletions .github/workflows/backport.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Create a backport PR to branch "foo" from a merged PR by adding a PR label "backport foo"
name: Backport merged pull request
on:
pull_request_target:
Expand All @@ -14,15 +13,114 @@ on:
required: true
type: string
permissions:
contents: write # so it can comment
pull-requests: write # so it can create pull requests
contents: write
pull-requests: write
id-token: write
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is id-token: write needed here? The original workflow only had contents: write and pull-requests: write. Neither the korthout action, the dispatch script, nor the Claude action appear to require OIDC tokens. If this was added for a future need, it'd be worth commenting why — otherwise removing it would tighten the permission scope.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jobs:
backport:
name: Backport pull request
runs-on: ubuntu-latest
# Don't run on closed unmerged pull requests
if: github.event.pull_request.merged
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly
components: rustfmt

- uses: taiki-e/install-action@just

- uses: taiki-e/install-action@v2
with:
tool: mergiraf

- name: Configure git identity and mergiraf merge driver
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
git config --global merge.mergiraf.name mergiraf
git config --global merge.mergiraf.driver 'mergiraf merge --git %O %A %B -s %S -x %X -y %Y -p %P'
mkdir -p ~/.config/git
echo '* merge=mergiraf' > ~/.config/git/attributes

- name: Cherry-pick via workflow_dispatch
id: dispatch_backport
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr_number }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
scripts/backport-dispatch "$PR_NUMBER" "$TARGET_BRANCH"

- name: Create backport pull requests
id: backport
if: github.event_name == 'pull_request_target'
uses: korthout/backport-action@v3
with:
experimental: '{"conflict_resolution":"draft_commit_conflicts"}'

- name: Detect remaining conflicts
id: resolve
if: |
steps.backport.outputs.created_pull_numbers != '' ||
steps.dispatch_backport.outputs.created_pull_numbers != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
pr_numbers="${{ steps.backport.outputs.created_pull_numbers }} ${{ steps.dispatch_backport.outputs.created_pull_numbers }}"
needs_claude=""
for pr in $pr_numbers; do
branch=$(gh pr view "$pr" --json headRefName -q .headRefName)
git fetch origin "$branch"
if git grep -lE '^<<<<<<< ' "origin/$branch" -- >/dev/null; then
needs_claude="$needs_claude $pr"
else
gh pr ready "$pr" || true
fi
done
echo "needs_claude=$needs_claude" >> "$GITHUB_OUTPUT"

- name: Claude conflict resolution
if: steps.resolve.outputs.needs_claude != ''
timeout-minutes: 30
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
Mergiraf left conflict markers in these backport PRs:
${{ steps.resolve.outputs.needs_claude }}

For each PR: create a local branch tracking origin/<branch>, resolve the markers, run `just fmt`, commit. Do not push and do not use the `gh` CLI; the workflow will verify compilation and push.

Commit attribution: only create new commits authored by the current git identity (the bot). Do not use --amend, --author=, --reuse-message=, git rebase, or `git cherry-pick --continue`; those preserve the original PR author's identity, which must never happen here.
claude_args: "--model claude-opus-4-6"

- uses: Swatinem/rust-cache@v2
if: steps.resolve.outputs.needs_claude != ''
with:
prefix-key: v3-rust
shared-key: backport-claude

- name: Verify Claude-resolved backports compile, label, mark ready
if: steps.resolve.outputs.needs_claude != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh label create claude-resolved --color FF6B35 --description "Backport conflicts resolved by Claude" 2>/dev/null || true
for pr in ${{ steps.resolve.outputs.needs_claude }}; do
branch=$(gh pr view "$pr" --json headRefName -q .headRefName)
git checkout "$branch"
cargo check --tests --examples
git push origin "$branch"
gh pr edit "$pr" --add-label claude-resolved
gh pr ready "$pr"
done
68 changes: 68 additions & 0 deletions scripts/backport-dispatch
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 2 ]]; then
echo "usage: $0 PR_NUMBER TARGET_BRANCH" >&2
exit 1
fi

pr_number="$1"
target_branch="$2"

: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is not set}"
: "${GH_TOKEN:?GH_TOKEN is not set}"

meta=$(gh pr view "${pr_number}" --json state,title,mergeCommit \
-q '{state,title,sha:.mergeCommit.oid}')
if [[ "$(jq -r '.state' <<<"${meta}")" != "MERGED" ]]; then
echo "PR #${pr_number} is not merged; refusing to backport" >&2
exit 1
fi
title=$(jq -r '.title' <<<"${meta}")
sha=$(jq -r '.sha' <<<"${meta}")

echo "backporting PR #${pr_number} (${sha}) to ${target_branch}"

branch="backport-${pr_number}-to-${target_branch}"
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null; then
existing_pr=$(gh pr list --head "${branch}" --state open --json number,url -q '.[0]')
echo "branch '${branch}' already exists on origin: ${existing_pr:-no open PR}" >&2
echo "delete the branch (and close its PR) and re-run to retry" >&2
exit 1
fi

git fetch origin "${target_branch}"
git checkout -b "${branch}" "origin/${target_branch}"

cherry_status=0
git cherry-pick -x "${sha}" || cherry_status=$?

if [[ "${cherry_status}" -ne 0 ]]; then
mapfile -t conflicted < <(git diff --name-only --diff-filter=U)

if [[ ${#conflicted[@]} -eq 0 ]]; then
echo "cherry-pick failed (status ${cherry_status}) with no conflicted paths" >&2
exit 1
fi

git add -- "${conflicted[@]}"
Comment on lines +40 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When cherry-pick fails due to conflicts, non-conflicted changes are already staged but conflicted files have markers in the working tree. The git add -- "${conflicted[@]}" stages those marker files, then git commit captures everything. This is correct and mirrors what korthout's draft_commit_conflicts does.

One edge case to consider: if the cherry-pick fails for a reason other than conflicts (e.g., the commit doesn't exist, or an empty commit), git diff --name-only --diff-filter=U returns nothing, and the script correctly exits with an error on line 57. Good error handling.

git commit -m "backport: cherry-pick #${pr_number} to ${target_branch}" \
-m "Cherry-picked from ${sha}; conflict markers committed for resolution."
fi

git push -u origin "${branch}"

pr_url=$(gh pr create \
--base "${target_branch}" \
--head "${branch}" \
--draft \
--title "[Backport ${target_branch}] ${title}" \
--body "Backport of #${pr_number} to \`${target_branch}\`.")
Comment on lines +55 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding --assignee "${login}" to attribute the backport PR to the original author. The login is already captured on line 29 but only used in the log message. The korthout action typically does this attribution automatically for its path.

pr_number_new="${pr_url##*/}"

if [[ -z "${pr_number_new}" || ! "${pr_number_new}" =~ ^[0-9]+$ ]]; then
echo "Could not parse new PR number from gh output: ${pr_url}" >&2
exit 1
fi

echo "created_pull_numbers=${pr_number_new}" >>"${GITHUB_OUTPUT}"
Loading