diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..24e2573546f53 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,213 @@ +# Configure Dependabot scanning. +version: 2 + +updates: + # Check for updates to GitHub Actions. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" + + # Check for updates to Composer packages. + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + ignore: + # These dependencies do not currently need to be managed with Dependabot. + - dependency-name: "squizlabs/php_codesniffer" + - dependency-name: "wp-coding-standards/wpcs" + - dependency-name: "phpcompatibility/php-compatibility" + - dependency-name: "yoast/phpunit-polyfills" + groups: + composer-packages: + patterns: + - "composer/ca-bundle" + + # Monitor some npm dependencies for updates in groups. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 20 + ignore: + - dependency-name: "@wordpress/*" + groups: + ## + # Groups for updating devDependencies. + ## + + # Dependencies related to Playwright testing (E2E, performance). + tests-playwright: + patterns: + - "*playwright*" + # Dependencies related to JavaScript testing with QUnit. + tests-qunit: + patterns: + - "*qunit*" + - "sinon*" + # Dependencies related to CSS and SASS building and manilupating. + dev-css-sass: + patterns: + - "autoprefixer" + # postcss and css related dependencies. + - "*css*" + - "*sass" + # Dependencies related to the Webpack build process. + dev-webpack: + patterns: + - "*webpack*" + - "react-refresh" + - "source-map-loader" + # Dependencies related to the local Docker development environment. + dev-docker: + patterns: + - "dotenv*" + - "wait-on" + # Dependencies that do not fall into a specific grouping. + dev-miscellaneous: + patterns: + - "chalk" + - "check-node-version" + - "ink-docstrap" + - "install-changed" + - "matchdep" + - "uuid" + # Dependencies related to JavaScript minification. + dev-uglify: + patterns: + - "*uglify*" + # All GruntJS related dependencies that do not relate to another group. + dev-grunt: + patterns: + - "*grunt*" + + ## + # Groups for updating production dependencies. + ## + + # Dependencies related to jQuery and its ecosystem. + external-jquery: + patterns: + - "jquery*" + # Dependencies related to React and its ecosystem. + external-react: + patterns: + - "react*" + - "!react-refresh" + # Dependencies used for bundling polyfill libraries into WordPress. + external-polyfills: + patterns: + - "core-js-url-browser" + - "element-closest" + - "formdata-polyfill" + - "imagesloaded" + - "objectFitPolyfill" + - "polyfill-library" + - "regenerator-runtime" + - "whatwg-fetch" + - "wicg-inert" + # Dependencies related to the Masonry library. + external-masonry: + patterns: + - "masonry-layout" + # Dependencies that do not fall into a specific grouping. + external-miscellaneous: + patterns: + - "backbone" + - "clipboard" + - "hoverintent" + - "json2php" + - "lodash" + - "moment" + - "underscore" + + # Monitor npm dependencies within default themes. + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentyfive" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentyfive-css: + patterns: + - "**browserslist*" + - "*css*" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentytwo" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentytwo-css: + patterns: + - "**browserslist*" + - "*css*" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentyone" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentyone-sass-css: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + - "*sass*" + - "!*stylelint*" + twentytwentyone-eslint: + patterns: + - "**eslint*" + twentytwentyone-stylelint: + patterns: + - "**stylelint*" + twentytwentyone-miscellaneous: + patterns: + - "chokidar-cli" + - "minimist" + - "npm-run-all" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwenty" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwenty-css: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + twentytwenty-stylelint: + patterns: + - "*stylelint*" + twentytwenty-miscellaneous: + patterns: + - "concurrently" + - "@wordpress/scripts" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentynineteen" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentynineteen-css-sass: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + - "*sass*" + twentynineteen-miscellaneous: + patterns: + - "chokidar-cli" + - "npm-run-all" diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000000..a6091199be590 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,369 @@ +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 " - 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: + validate-inputs: + name: Validate inputs + runs-on: ubuntu-24.04 + steps: + - name: Ensure a commit SHA or PR numbers are provided + env: + INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }} + INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }} + run: | + if [ -z "${INPUTS_COMMIT_SHA}" ] && [ -z "${INPUTS_PR_NUMBERS}" ]; then + echo "::error::A commit SHA or PR number(s) must be included." + exit 1 + fi + + 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 + + # Read keys from .version-support-php.json, filter to those >= end_branch, + # convert dashes to dots, and sort numerically descending. + # The first key is always the version in active development on trunk, so skip it. + - name: Get target branches + id: branches + env: + INPUTS_END_BRANCH: ${{ inputs.end_branch }} + run: | + END_X=$(echo "${INPUTS_END_BRANCH}" | cut -d. -f1) + END_Y=$(echo "${INPUTS_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" + + backport: + name: 'Backport to ${{ matrix.branch }}' + needs: [ 'validate-inputs', 'get-branches' ] + if: ${{ needs.get-branches.outputs.branches != '[]' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: 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: Set up git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + 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 + git remote add upstream "https://github.com/${UPSTREAM}.git" + git fetch upstream + echo "repo=$UPSTREAM" >> "$GITHUB_OUTPUT" + else + echo "repo=${{ github.repository }}" >> "$GITHUB_OUTPUT" + fi + + # Determine the name of the branch for the pull request. + # + # 1. pr-name (normalized to alphanumeric, hyphens, and periods only) + # 2. commit-sha + # 3. pr_numbers with commas replaced by hyphens + - name: Determine backport branch name + id: backport-branch + env: + INPUTS_PR_NAME: ${{ inputs.pr-name }} + MATRIX_BRANCH: ${{ matrix.branch }} + INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }} + INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }} + run: | + if [ -n "${INPUTS_PR_NAME}" ]; then + echo "name=backport/${MATRIX_BRANCH}-$(echo "${INPUTS_PR_NAME}" | tr -cs '[:alnum:].-' '-' | sed 's/^-//;s/-$//')" >> "$GITHUB_OUTPUT" + elif [ -n "${INPUTS_COMMIT_SHA}" ]; then + echo "name=backport/${MATRIX_BRANCH}-${INPUTS_COMMIT_SHA}" >> "$GITHUB_OUTPUT" + else + echo "name=backport/${MATRIX_BRANCH}-$(echo "${INPUTS_PR_NUMBERS}" | tr -d ' ' | tr ',' '-')" >> "$GITHUB_OUTPUT" + fi + + - name: Create backport branch + env: + STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }} + MATRIX_BRANCH: ${{ matrix.branch }} + run: | + if git ls-remote --exit-code --heads origin "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" > /dev/null 2>&1; then + echo "::error::Branch '${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}' already exists on origin." + exit 1 + fi + + git checkout -b "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" "origin/${MATRIX_BRANCH}" + + - name: Cherry-pick commit + if: ${{ inputs['commit-sha'] != '' }} + env: + INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }} + run: | + COMMIT="${INPUTS_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 }} + STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }} + INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }} + INPUTS_REPO_SOURCE: ${{ inputs.repo-source }} + run: | + if [ "${INPUTS_REPO_SOURCE}" = "upstream" ]; then + PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}" + else + PR_REPO="${GITHUB_REPOSITORY}" + fi + + IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}" + + UPSTREAM_URL="https://github.com/${STEPS_UPSTREAM_OUTPUTS_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: + STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }} + run: git push -u origin "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" + + - name: Create pull request + id: create-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }} + INPUTS_PR_NAME: ${{ inputs.pr-name }} + MATRIX_BRANCH: ${{ matrix.branch }} + INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }} + INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }} + INPUTS_REPO_SOURCE: ${{ inputs.repo-source }} + INPUTS_PR_TARGET: ${{ inputs.pr-target }} + STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }} + run: | + if [ "${INPUTS_REPO_SOURCE}" = "upstream" ]; then + PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}" + else + PR_REPO="${GITHUB_REPOSITORY}" + fi + + if [ -n "${INPUTS_PR_NAME}" ]; then + PR_TITLE="${INPUTS_PR_NAME} - ${MATRIX_BRANCH} branch" + else + PR_TITLE="Backport to ${MATRIX_BRANCH}" + fi + + if [ -n "${INPUTS_COMMIT_SHA}" ]; then + BODY="This pull request backports \`${INPUTS_COMMIT_SHA}\` (https://github.com/${STEPS_UPSTREAM_OUTPUTS_REPO}/commit/${INPUTS_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 "${INPUTS_COMMIT_SHA}" ]; then + COMMIT_MESSAGE=$(git log --format=%B -n 1 "${INPUTS_COMMIT_SHA}") + BLOCKQUOTE=$(echo "${COMMIT_MESSAGE}" | sed 's/^/> /') + BODY="${BODY}\n${BLOCKQUOTE}" + fi + + if [ -n "${INPUTS_PR_NUMBERS}" ] && [ -z "${INPUTS_COMMIT_SHA}" ]; then + IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}" + for PR_NUMBER in "${PR_LIST[@]}"; do + PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ') + BODY="${BODY}\n- ${PR_REPO}#${PR_NUMBER}" + done + fi + + if [ "${INPUTS_PR_TARGET}" = "upstream" ]; then + PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}" + PR_HEAD="${GITHUB_REPOSITORY_OWNER}:${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" + else + PR_REPO="${GITHUB_REPOSITORY}" + PR_HEAD="${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" + 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: Write job summary + if: always() + env: + MATRIX_BRANCH: ${{ matrix.branch }} + INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }} + INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }} + STEPS_MERGE_PRS_OUTPUTS_RESULTS: ${{ steps.merge-prs.outputs.results }} + STEPS_CREATE_PR_OUTPUTS_URL: ${{ steps.create-pr.outputs.url }} + run: | + PR_DISPLAY="${STEPS_CREATE_PR_OUTPUTS_URL:-N/A}" + + if [ -n "${INPUTS_PR_NUMBERS}" ] && [ -z "${INPUTS_COMMIT_SHA}" ]; then + IFS=',' read -ra PR_LIST <<< "${INPUTS_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} :--- |" + + ROW="| \`${MATRIX_BRANCH}\` |" + for PR_NUM in "${PR_LIST[@]}"; do + PR_NUM=$(echo "$PR_NUM" | tr -d ' ') + STATUS=$(echo "${STEPS_MERGE_PRS_OUTPUTS_RESULTS}" | tr ' ' '\n' | grep "^${PR_NUM}=" | cut -d= -f2) + ROW="${ROW} ${STATUS:-❌} |" + done + ROW="${ROW} ${PR_DISPLAY} |" + + printf '%s\n%s\n%s\n' "$HEADER" "$SEPARATOR" "$ROW" >> "$GITHUB_STEP_SUMMARY" + else + printf '| Branch | Pull Request |\n| :--- | :--- |\n| `%s` | %s |\n' \ + "${MATRIX_BRANCH}" "${PR_DISPLAY}" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/create-backport-prs.yml b/.github/workflows/create-backport-prs.yml new file mode 100644 index 0000000000000..f0ad03ecc9636 --- /dev/null +++ b/.github/workflows/create-backport-prs.yml @@ -0,0 +1,532 @@ +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 " - 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 + 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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 + + BODY="${BODY}\n\n---\n\nThis PR was generated from a [GitHub Actions workflow run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) triggered by @${GITHUB_ACTOR}." + + 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 }} + PR_NAME: ${{ inputs.pr-name }} + 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 + + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + if [ -n "${PR_URL}" ]; then + if [ -n "${PR_NUMBERS}" ] && [ -z "${COMMIT_SHA}" ]; then + PR_DISPLAY="${PR_URL}" + else + if [ -n "${PR_NAME}" ]; then + PR_TITLE="${PR_NAME} - ${MATRIX_BRANCH} branch" + else + PR_TITLE="Backport to ${MATRIX_BRANCH}" + fi + PR_DISPLAY="[${PR_TITLE}](${PR_URL})" + fi + else + PR_DISPLAY="[View log](${RUN_URL})" + fi + + 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + 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: [ 'validate-inputs', 'backport', 'create-pr' ] + runs-on: ubuntu-24.04 + if: ${{ always() && needs.validate-inputs.result != 'failure' }} + steps: + - name: Download summary rows + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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 diff --git a/package-lock.json b/package-lock.json index a48bff6270b72..fb9297f9faf34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "source-map-loader": "5.0.0", "terser-webpack-plugin": "5.4.0", "typescript": "5.9.3", - "uuid": "13.0.0", + "uuid": "14.0.0", "wait-on": "9.0.4", "webpack": "5.105.4" }, @@ -32528,9 +32528,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", diff --git a/package.json b/package.json index 6f3cd1fbe6c3e..256d41147b07a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "source-map-loader": "5.0.0", "terser-webpack-plugin": "5.4.0", "typescript": "5.9.3", - "uuid": "13.0.0", + "uuid": "14.0.0", "wait-on": "9.0.4", "webpack": "5.105.4" },