Create backport pull requests #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |