name: Backport merged pull request # Automates cherry-picking merged PRs onto release branches. # # Pre-merge (open PR): # An authorized /backport or /cherrypick comment adds one `backport ` # label per requested branch. These labels are then picked up automatically # when the PR is merged (see the pull_request_target: closed trigger below). # # Post-merge (merged PR): # - Label a PR with e.g. `backport release-1.17` before merging; the label # triggers the backport automatically when the PR closes as merged. # - Comment `/backport release-1.17` or `/cherrypick release-1.17` on an # already-merged PR to create the backport PR immediately. # # In both cases multiple target branches can be space-delimited in a comment: # /backport release-1.17 release-1.18 # # As a shorthand, a bare release version (e.g. `1.17`) is automatically # expanded to the corresponding `release-1.17` branch, so `/backport 1.17` # and `/backport release-1.17` are equivalent. This works generically for # any `X.Y` version, e.g. `/backport 1.18 1.19`. # # The cherry-picked commit(s) carry over the original PR's changelog file # (changelogs/unreleased/-), which no longer matches the # backport PR's own number. After the backport PR is created, its changelog # file is automatically renamed to - so that # hack/changelog-check.sh passes and release notes cite the correct PR. # # If the source PR is labeled `kind/changelog-not-required` (i.e. it has no # changelog file), that label is copied to the backport PR so it isn't # flagged as missing a changelog either. # # Every commit on a backport branch (the cherry-picked commit(s), even from # the original author, plus the changelog rename commit) is re-signed with # the bot's Signed-off-by trailer via `git rebase --signoff`, so the DCO # check always passes regardless of whether the original commit had one. # # See: https://github.com/velero-io/velero/issues/9603 on: pull_request_target: types: [closed] issue_comment: types: [created] permissions: {} # Shared condition for authorized /backport or /cherrypick comments. # Used by both jobs below to avoid duplicating the gate logic. env: AUTHORIZED_COMMENT: >- ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request != '' && github.event.comment.user.id != 97796249 && contains( fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association ) && ( startsWith(github.event.comment.body, '/backport') || startsWith(github.event.comment.body, '/cherrypick') ) }} jobs: # ── Pre-merge: convert a /backport or /cherrypick comment into labels ─────── # When the PR is still open the backport-action cannot run (it requires a # merged commit). Instead, add one `backport ` label per requested # branch so that the post-merge job picks them up automatically on close. label-for-backport: name: Label PR for deferred backport # Run only when an authorized command is posted on an *open* (unmerged) PR. if: > github.repository == 'velero-io/velero' && github.event_name == 'issue_comment' && github.event.issue.pull_request != '' && github.event.issue.state == 'open' && github.event.comment.user.id != 97796249 && contains( fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association ) && ( startsWith(github.event.comment.body, '/backport') || startsWith(github.event.comment.body, '/cherrypick') ) runs-on: ubuntu-latest permissions: issues: write # apply labels to the PR (PRs share the issues API) steps: - name: Parse branches and apply labels env: COMMENT_BODY: ${{ github.event.comment.body }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} run: | # Extract branch names from the first line of the comment. # Strip the /backport or /cherrypick prefix; what remains is a # space-delimited list of target branch names. line=$(printf '%s' "$COMMENT_BODY" | head -n1 | tr -d '\r') branches=$(printf '%s' "$line" | sed -E 's#^/(backport|cherrypick)[[:space:]]*##') if [ -z "$branches" ]; then echo "No target branches specified in comment; nothing to label." exit 0 fi for branch in $branches; do # Shorthand: a bare version like "1.17" expands to "release-1.17". if [[ "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then branch="release-${branch}" fi label="backport ${branch}" echo "Applying label: '${label}'" # Create the label if it does not exist yet (idempotent). gh label create "${label}" --repo "${REPO}" --color "0075ca" \ --description "Backport to ${branch}" 2>/dev/null || true gh issue edit "${PR_NUMBER}" --repo "${REPO}" --add-label "${label}" done # ── Post-merge: create backport PRs ───────────────────────────────────────── backport: name: Backport pull request # Exclude comments from the backport-action bot (user id 97796249) to prevent # recursive triggers. The bot does not post /backport commands, so startsWith # already blocks recursion; the id check is defense in depth. if: > github.repository == 'velero-io/velero' && ( ( github.event_name == 'pull_request_target' && github.event.pull_request.merged && contains(toJSON(github.event.pull_request.labels.*.name), '"backport ') ) || ( github.event_name == 'issue_comment' && github.event.issue.pull_request != '' && github.event.issue.state == 'closed' && github.event.comment.user.id != 97796249 && contains( fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association ) && ( startsWith(github.event.comment.body, '/backport') || startsWith(github.event.comment.body, '/cherrypick') ) ) ) runs-on: ubuntu-latest permissions: contents: write # push backport branches and comment pull-requests: write # open backport PRs steps: - name: Parse target branches from comment id: parse if: github.event_name == 'issue_comment' env: COMMENT_BODY: ${{ github.event.comment.body }} run: | # First line only; strip /backport or /cherrypick prefix. # Remaining text is a space-delimited list of target branches # (may be empty, falls back to labels). line=$(printf '%s' "$COMMENT_BODY" | head -n1 | tr -d '\r') branches=$(printf '%s' "$line" | sed -E 's#^/(backport|cherrypick)[[:space:]]*##') normalized="" for branch in $branches; do # Shorthand: a bare version like "1.17" expands to "release-1.17". if [[ "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then branch="release-${branch}" fi normalized="${normalized}${normalized:+ }${branch}" done echo "branches=${normalized}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Create backport pull requests id: backport # Pin to commit SHA: workflow has contents/pull-requests write. uses: korthout/backport-action@2e830a1d0b8269505846ddd407a70876913ad1f8 # v4.6.0 with: # Labels like `backport release-1.17` select the target branch. label_pattern: '^backport ([^ ]+)$' # Carry over `kind/changelog-not-required` from the source PR so # backport PRs of changelog-exempt changes aren't flagged as missing one. copy_labels_pattern: '^kind/changelog-not-required$' # Prefer draft PRs with conflict markers over failing the job silently. experimental: | { "conflict_resolution": "draft_commit_conflicts" } # Empty when triggered by merge labels; set when `/backport` or `/cherrypick` includes branches. target_branches: ${{ steps.parse.outputs.branches }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Rename changelog file(s) and ensure DCO signoff # The cherry-picked commit(s) still carry the source PR's changelog # filename (e.g. changelogs/unreleased/9795-kaovilai), which no # longer matches the new backport PR's number. Rename it on each # created backport branch so hack/changelog-check.sh passes and the # release notes cite the correct PR. # # Also ensure every commit on the backport branch passes the DCO # check by re-signing it with the bot's Signed-off-by trailer via # `git rebase --signoff`. This covers the cherry-picked commits # (even when the original author's commit had no trailer) as well # as the changelog rename commit added above; it preserves any # existing Signed-off-by trailers rather than replacing them. if: steps.backport.outputs.created_pull_numbers != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} SOURCE_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} CREATED_PR_NUMBERS: ${{ steps.backport.outputs.created_pull_numbers }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" shopt -s nullglob for new_pr in $CREATED_PR_NUMBERS; do if [ "$new_pr" = "$SOURCE_PR_NUMBER" ]; then continue fi branch=$(gh pr view "$new_pr" --repo "$REPO" --json headRefName -q .headRefName) base_branch=$(gh pr view "$new_pr" --repo "$REPO" --json baseRefName -q .baseRefName) git fetch origin "$branch" "$base_branch" git checkout -B "$branch" "origin/${branch}" files=(changelogs/unreleased/"${SOURCE_PR_NUMBER}"-*) if [ ${#files[@]} -gt 0 ]; then for old_file in "${files[@]}"; do suffix=$(basename "$old_file" | sed -E "s/^${SOURCE_PR_NUMBER}-//") new_file="changelogs/unreleased/${new_pr}-${suffix}" if [ "$old_file" != "$new_file" ]; then git mv "$old_file" "$new_file" fi done if ! git diff --cached --quiet; then git commit -m "Rename changelog to match backport PR #${new_pr}" fi else echo "No changelog file for PR ${SOURCE_PR_NUMBER} found on ${branch}; skipping rename." fi # Add the bot's Signed-off-by trailer to every commit ahead of # the target branch (cherry-picked commits + the rename commit). if ! git rebase --signoff "origin/${base_branch}"; then echo "::error::git rebase --signoff failed for PR #${new_pr} on branch ${branch}; aborting rebase, branch left unchanged." >&2 git rebase --abort exit 1 fi git push --force-with-lease origin "HEAD:${branch}" done