Skip to content

Create backport pull requests #1

Create backport pull requests

Create backport pull requests #1

name: Create backport pull requests
on:
workflow_dispatch:
inputs:
end_branch:
description: 'The branch to end at (e.g. 6.9). Defaults to the current supported branch.'
required: false
type: string
default: '7.0'
pr-name:
description: 'Pull request name (format is "<pr-name> - <branch> branch".'
required: false
type: string
default: ''
commit-sha:
description: 'Full length commit hash to stage for backport.'
required: false
type: string
default: ''
pr_numbers:
description: 'Comma-separated PR numbers. Ignored when a SHA is provided.'
required: false
type: string
default: ''
repo-source:
description: 'Repository to merge changes from.'
required: false
type: choice
default: 'upstream'
options:
- upstream
- current
pr-target:
description: 'Repository to submit pull requests to.'
required: false
type: choice
default: 'current'
options:
- upstream
- current
# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
permissions: {}
jobs:
# Confirms that enough information is included to attempt a backport.
validate-inputs:
name: Validate inputs
runs-on: ubuntu-24.04
steps:
- name: Ensure a commit SHA or PR numbers are provided
env:
COMMIT_SHA: ${{ inputs.commit-sha }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
run: |
if [ -z "${COMMIT_SHA}" ] && [ -z "${PR_NUMBERS}" ]; then
echo "::error::A commit SHA or PR number(s) must be included."
exit 1
fi
# Generates a list of branches to create backport PRs for.
#
# The keys are read from .version-support-php.json, filtered to only include
# any after the specified end branch, and sort numerically descending.
#
# The first key in the file is always skipped because it represents the next
# version of WordPress in active development in trunk.
#
# Performs the following steps:
# - Checks out the repository.
# - Reads branch versions from .version-support-php.json and outputs a filtered, sorted list.
get-branches:
name: Get target branches
needs: [ 'validate-inputs' ]
runs-on: ubuntu-24.04
outputs:
branches: ${{ steps.branches.outputs.result }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get target branches
id: branches
env:
END_BRANCH: ${{ inputs.end_branch }}
run: |
END_X=$(echo "${END_BRANCH}" | cut -d. -f1)
END_Y=$(echo "${END_BRANCH}" | cut -d. -f2)
BRANCHES=$(jq -c \
--argjson x "$END_X" \
--argjson y "$END_Y" \
'[ keys[] |
. as $k | ($k | split("-")) as $p |
select( ($p[0]|tonumber) > $x or
(($p[0]|tonumber) == $x and ($p[1]|tonumber) >= $y) ) |
{ v: ($k | gsub("-"; ".")), x: ($p[0]|tonumber), y: ($p[1]|tonumber) }
] | sort_by(.x, .y) | reverse | .[1:] | map(.v)' \
.version-support-php.json)
echo "result=$BRANCHES" >> "$GITHUB_OUTPUT"
# Resolves shared context and variables used by all matrix jobs.
#
# The branch name suffix is determined in the following order:
# 1. pr-name (normalized to alphanumeric, hyphens, and periods only)
# 2. commit-sha
# 3. pr_numbers with commas replaced by hyphens
#
# Performs the following steps:
# - Determines whether the repository is a fork.
# - Constructs the branch name suffix.
resolve-context:
name: Resolve context
needs: [ 'validate-inputs', 'get-branches' ]
if: ${{ needs.get-branches.outputs.branches != '[]' }}
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
upstream-repo: ${{ steps.upstream.outputs.repo }}
branch-suffix: ${{ steps.branch-suffix.outputs.value }}
steps:
- name: Detect upstream repository
id: upstream
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
UPSTREAM=$(gh repo view "${{ github.repository }}" --json parent --jq 'if .parent then "\(.parent.owner.login)/\(.parent.name)" else empty end')
if [ -n "$UPSTREAM" ]; then
echo "This repository is a fork of ${UPSTREAM}. Original repository configured as \`upstream\` remote."
echo "repo=$UPSTREAM" >> "$GITHUB_OUTPUT"
else
echo "This repository is not a fork. No \`upstream\` remote configured."
echo "repo=${{ github.repository }}" >> "$GITHUB_OUTPUT"
fi
- name: Determine branch name suffix
id: branch-suffix
env:
PR_NAME: ${{ inputs.pr-name }}
COMMIT_SHA: ${{ inputs.commit-sha }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
run: |
if [ -n "${PR_NAME}" ]; then
echo "value=$(echo "${PR_NAME}" | tr -cs '[:alnum:].-' '-' | sed 's/^-//;s/-$//')" >> "$GITHUB_OUTPUT"
elif [ -n "${COMMIT_SHA}" ]; then
echo "value=${COMMIT_SHA}" >> "$GITHUB_OUTPUT"
else
echo "value=$(echo "${PR_NUMBERS}" | tr -d ' ' | tr ',' '-')" >> "$GITHUB_OUTPUT"
fi
# Attempts to backport the specified changes in the desired branches.
#
# Performs the following steps:
# - Checks out the repository.
# - Configures the Git author.
# - Configures the upstream remote (forks only).
# - Creates a new branch.
# - Performs a `git cherry-pick` when a SHA value is specified.
# - Attempts to merge changes from the pull requests specified.
# - Pushes the new branch to the origin remote.
backport:
name: 'Backport to ${{ matrix.branch }}'
needs: [ 'validate-inputs', 'get-branches', 'resolve-context' ]
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
fail-fast: false
matrix:
branch: ${{ fromJson( needs.get-branches.outputs.branches ) }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: 'true'
- name: Configure git user name and email
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream remote
env:
UPSTREAM_REPO: ${{ needs.resolve-context.outputs.upstream-repo }}
run: |
if [ "${UPSTREAM_REPO}" != "${{ github.repository }}" ]; then
git remote add upstream "https://github.com/${UPSTREAM_REPO}.git"
git fetch upstream
fi
- name: Create backport branch
env:
MATRIX_BRANCH: ${{ matrix.branch }}
HEAD_BRANCH_SUFFIX: ${{ needs.resolve-context.outputs.branch-suffix }}
run: |
BRANCH_NAME="backport/${MATRIX_BRANCH}-${HEAD_BRANCH_SUFFIX}"
if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" > /dev/null 2>&1; then
echo "::error::Branch '${BRANCH_NAME}' already exists on origin."
exit 1
fi
git checkout -b "${BRANCH_NAME}" "origin/${MATRIX_BRANCH}"
- name: Cherry-pick commit
if: ${{ inputs['commit-sha'] != '' }}
env:
COMMIT_SHA: ${{ inputs.commit-sha }}
run: |
COMMIT="${COMMIT_SHA}"
PARENTS=$(git cat-file -p "$COMMIT" | grep -c '^parent ' || true)
if [ "$PARENTS" -gt 1 ]; then
git cherry-pick -m 1 "$COMMIT"
else
git cherry-pick "$COMMIT"
fi
- name: Merge PRs
id: merge-prs
if: ${{ inputs['commit-sha'] == '' && inputs.pr_numbers != '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPSTREAM_REPO: ${{ needs.resolve-context.outputs.upstream-repo }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
REPO_SOURCE: ${{ inputs.repo-source }}
run: |
if [ "${REPO_SOURCE}" = "upstream" ]; then
PR_REPO="${UPSTREAM_REPO}"
else
PR_REPO="${GITHUB_REPOSITORY}"
fi
IFS=',' read -ra PR_LIST <<< "${PR_NUMBERS}"
UPSTREAM_URL="https://github.com/${UPSTREAM_REPO}.git"
RESULTS=""
FAILED=false
for PR_NUMBER in "${PR_LIST[@]}"; do
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$PR_REPO" --json title,mergeCommit,baseRefName)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
set +e
if [ -n "$MERGE_COMMIT" ] && [ "$MERGE_COMMIT" != "null" ]; then
# PR is merged: cherry-pick its merge commit.
# Determine if it is a merge commit or squash commit.
PARENTS=$(git cat-file -p "$MERGE_COMMIT" | grep -c '^parent ' || true)
if [ "$PARENTS" -gt 1 ]; then
git cherry-pick -m 1 --no-commit "$MERGE_COMMIT"
else
git cherry-pick --no-commit "$MERGE_COMMIT"
fi
else
# PR is open or closed without merging: apply its changes as a diff
# against the point where it diverged from its base branch.
BASE_REF=$(echo "$PR_DATA" | jq -r '.baseRefName')
git fetch "$UPSTREAM_URL" "$BASE_REF"
BASE_SHA=$(git rev-parse FETCH_HEAD)
git fetch "$UPSTREAM_URL" "refs/pull/${PR_NUMBER}/head"
PR_HEAD_SHA=$(git rev-parse FETCH_HEAD)
MERGE_BASE=$(git merge-base "$PR_HEAD_SHA" "$BASE_SHA")
git diff "$MERGE_BASE" "$PR_HEAD_SHA" | git apply --index
fi
APPLY_EXIT=$?
set -e
if [ $APPLY_EXIT -eq 0 ]; then
git commit -m "$PR_TITLE"
RESULTS="${RESULTS}${PR_NUMBER}=✅ "
else
git cherry-pick --abort 2>/dev/null || git reset --hard HEAD
RESULTS="${RESULTS}${PR_NUMBER}=❌ "
FAILED=true
break
fi
done
echo "results=${RESULTS}" >> "$GITHUB_OUTPUT"
if [ "$FAILED" = "true" ]; then
exit 1
fi
- name: Push backport branch
env:
MATRIX_BRANCH: ${{ matrix.branch }}
HEAD_BRANCH_SUFFIX: ${{ needs.resolve-context.outputs.branch-suffix }}
run: git push -u origin "backport/${MATRIX_BRANCH}-${HEAD_BRANCH_SUFFIX}"
- name: Save results
if: always()
env:
MATRIX_BRANCH: ${{ matrix.branch }}
MERGE_RESULTS: ${{ steps.merge-prs.outputs.results }}
run: |
mkdir -p apply-results
SAFE_BRANCH=$(echo "${MATRIX_BRANCH}" | tr '.' '-')
{
echo "branch=${MATRIX_BRANCH}"
echo "merge_results=${MERGE_RESULTS}"
} > "apply-results/${SAFE_BRANCH}.txt"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: apply-results-${{ matrix.branch }}
path: apply-results/
retention-days: 1
# Creates a draft pull request for each successfully applied backport branch.
# Requires only write access to pull requests, keeping git operations separate.
#
# Performs the following steps:
# - Downloads the apply result artifact to confirm changes were applied successfully.
# - Creates a draft pull request targeting the specified repository.
# - Adds the `Auto-backport` label to the pull request if it exists.
# - Formats and uploads a pre-rendered summary row artifact for the report job.
create-pr:
name: 'Create PR for ${{ matrix.branch }}'
needs: [ 'validate-inputs', 'get-branches', 'resolve-context', 'backport' ]
if: ${{ always() && !cancelled() && needs.resolve-context.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
branch: ${{ fromJson( needs.get-branches.outputs.branches ) }}
steps:
- name: Download apply result
id: apply-result
uses: actions/download-artifact@v4
with:
name: apply-results-${{ matrix.branch }}
path: apply-result/
continue-on-error: true
- name: Create pull request
id: create-pr
if: ${{ steps.apply-result.outcome == 'success' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPSTREAM_REPO: ${{ needs.resolve-context.outputs.upstream-repo }}
HEAD_BRANCH_SUFFIX: ${{ needs.resolve-context.outputs.branch-suffix }}
PR_NAME: ${{ inputs.pr-name }}
MATRIX_BRANCH: ${{ matrix.branch }}
COMMIT_SHA: ${{ inputs.commit-sha }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
REPO_SOURCE: ${{ inputs.repo-source }}
PR_TARGET: ${{ inputs.pr-target }}
run: |
BACKPORT_BRANCH="backport/${MATRIX_BRANCH}-${HEAD_BRANCH_SUFFIX}"
if [ "${REPO_SOURCE}" = "upstream" ]; then
SOURCE_REPO="${UPSTREAM_REPO}"
else
SOURCE_REPO="${GITHUB_REPOSITORY}"
fi
if [ -n "${PR_NAME}" ]; then
PR_TITLE="${PR_NAME} - ${MATRIX_BRANCH} branch"
else
PR_TITLE="Backport to ${MATRIX_BRANCH}"
fi
if [ -n "${COMMIT_SHA}" ]; then
BODY="This pull request backports \`${COMMIT_SHA}\` (https://github.com/${UPSTREAM_REPO}/commit/${COMMIT_SHA}) to the \`${MATRIX_BRANCH}\` branch."
else
BODY="Backports to the \`${MATRIX_BRANCH}\` branch."
fi
BODY="${BODY}\n\n## Changes Included\n"
if [ -n "${COMMIT_SHA}" ]; then
COMMIT_MESSAGE=$(gh api "repos/${UPSTREAM_REPO}/commits/${COMMIT_SHA}" --jq '.commit.message')
BLOCKQUOTE=$(echo "${COMMIT_MESSAGE}" | sed 's/^/> /')
BODY="${BODY}\n${BLOCKQUOTE}"
fi
if [ -n "${PR_NUMBERS}" ] && [ -z "${COMMIT_SHA}" ]; then
IFS=',' read -ra PR_LIST <<< "${PR_NUMBERS}"
for PR_NUMBER in "${PR_LIST[@]}"; do
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
BODY="${BODY}\n- ${SOURCE_REPO}#${PR_NUMBER}"
done
fi
if [ "${PR_TARGET}" = "upstream" ]; then
PR_REPO="${UPSTREAM_REPO}"
PR_HEAD="${GITHUB_REPOSITORY_OWNER}:${BACKPORT_BRANCH}"
else
PR_REPO="${GITHUB_REPOSITORY}"
PR_HEAD="${BACKPORT_BRANCH}"
fi
PR_URL=$(gh pr create \
--repo "${PR_REPO}" \
--base "${MATRIX_BRANCH}" \
--head "${PR_HEAD}" \
--title "$PR_TITLE" \
--assignee "${GITHUB_ACTOR}" \
--draft \
--body "$(echo -e "$BODY")")
if gh label list --repo "${PR_REPO}" --json name --jq '[.[].name] | contains(["Auto-backport"])' | grep -q 'true'; then
gh pr edit "$PR_URL" --repo "${PR_REPO}" --add-label 'Auto-backport'
else
echo "::notice::The 'Auto-backport' label does not exist on ${PR_REPO}. Consider adding it so that backport pull requests can be identified easily."
fi
echo "url=${PR_URL}" >> "$GITHUB_OUTPUT"
- name: Save summary row
if: always()
env:
MATRIX_BRANCH: ${{ matrix.branch }}
PR_URL: ${{ steps.create-pr.outputs.url }}
COMMIT_SHA: ${{ inputs.commit-sha }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
run: |
mkdir -p summary-row
SAFE_BRANCH=$(echo "${MATRIX_BRANCH}" | tr '.' '-')
MERGE_RESULTS=""
if [ -f "apply-result/${SAFE_BRANCH}.txt" ]; then
MERGE_RESULTS=$(grep '^merge_results=' "apply-result/${SAFE_BRANCH}.txt" | cut -d= -f2-)
fi
PR_DISPLAY="${PR_URL:-N/A}"
if [ -n "${PR_NUMBERS}" ] && [ -z "${COMMIT_SHA}" ]; then
IFS=',' read -ra PR_LIST <<< "${PR_NUMBERS}"
ROW="| \`${MATRIX_BRANCH}\` |"
for PR_NUM in "${PR_LIST[@]}"; do
PR_NUM=$(echo "$PR_NUM" | tr -d ' ')
STATUS=$(echo "${MERGE_RESULTS}" | tr ' ' '\n' | grep "^${PR_NUM}=" | cut -d= -f2)
ROW="${ROW} ${STATUS:-❌} |"
done
ROW="${ROW} ${PR_DISPLAY} |"
else
ROW="| \`${MATRIX_BRANCH}\` | ${PR_DISPLAY} |"
fi
printf '%s\n' "$ROW" > "summary-row/${SAFE_BRANCH}.txt"
- name: Upload summary row
if: always()
uses: actions/upload-artifact@v4
with:
name: summary-row-${{ matrix.branch }}
path: summary-row/
retention-days: 1
# Aggregates results from all matrix jobs into a single workflow summary.
#
# Performs the following steps:
# - Downloads pre-rendered summary row artifacts from all create-pr jobs.
# - Writes the table header and appends all rows to the workflow summary.
report:
name: Backport report
needs: [ 'backport', 'create-pr' ]
runs-on: ubuntu-24.04
if: always()
steps:
- name: Download summary rows
uses: actions/download-artifact@v4
with:
pattern: summary-row-*
path: summary-rows/
merge-multiple: true
continue-on-error: true
- name: Write summary
env:
COMMIT_SHA: ${{ inputs.commit-sha }}
PR_NUMBERS: ${{ inputs.pr_numbers }}
run: |
if [ -n "${PR_NUMBERS}" ] && [ -z "${COMMIT_SHA}" ]; then
IFS=',' read -ra PR_LIST <<< "${PR_NUMBERS}"
HEADER="| Branch |"
SEPARATOR="| :--- |"
for PR_NUM in "${PR_LIST[@]}"; do
PR_NUM=$(echo "$PR_NUM" | tr -d ' ')
HEADER="${HEADER} #${PR_NUM} |"
SEPARATOR="${SEPARATOR} :---: |"
done
HEADER="${HEADER} Pull Request |"
SEPARATOR="${SEPARATOR} :--- |"
printf '%s\n%s\n' "$HEADER" "$SEPARATOR" >> "$GITHUB_STEP_SUMMARY"
else
printf '| Branch | Pull Request |\n| :--- | :--- |\n' >> "$GITHUB_STEP_SUMMARY"
fi
for ROW_FILE in $(ls summary-rows/*.txt 2>/dev/null | sort); do
cat "${ROW_FILE}" >> "$GITHUB_STEP_SUMMARY"
done