diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000000..8cdbc7440b705
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,331 @@
+# Configure Dependabot scanning.
+version: 2
+
+updates:
+ # Check for updates to GitHub Actions.
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ groups:
+ github-actions:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+
+ # Check for updates to Composer packages.
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ 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:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "composer/ca-bundle"
+
+ # Monitor some npm dependencies for updates in groups.
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ ignore:
+ - dependency-name: "@wordpress/*"
+ groups:
+ ##
+ # Groups for updating devDependencies.
+ ##
+
+ # Dependencies related to Playwright testing (E2E, performance).
+ tests-playwright:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*playwright*"
+ # Dependencies related to JavaScript testing with QUnit.
+ tests-qunit:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*qunit*"
+ - "sinon*"
+ # Dependencies related to CSS and SASS building and manilupating.
+ dev-css-sass:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "autoprefixer"
+ # postcss and css related dependencies.
+ - "*css*"
+ - "*sass"
+ # Dependencies related to the Webpack build process.
+ dev-webpack:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*webpack*"
+ - "react-refresh"
+ - "source-map-loader"
+ # Dependencies related to the local Docker development environment.
+ dev-docker:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "dotenv*"
+ - "wait-on"
+ # Dependencies that do not fall into a specific grouping.
+ dev-miscellaneous:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "chalk"
+ - "check-node-version"
+ - "ink-docstrap"
+ - "install-changed"
+ - "matchdep"
+ - "uuid"
+ # Dependencies related to JavaScript minification.
+ dev-uglify:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*uglify*"
+ # All GruntJS related dependencies that do not relate to another group.
+ dev-grunt:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*grunt*"
+
+ ##
+ # Groups for updating production dependencies.
+ ##
+
+ # Dependencies related to jQuery and its ecosystem.
+ external-jquery:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "jquery*"
+ # Dependencies related to React and its ecosystem.
+ external-react:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "react*"
+ - "!react-refresh"
+ # Dependencies used for bundling polyfill libraries into WordPress.
+ external-polyfills:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ 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:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "masonry-layout"
+ # Dependencies that do not fall into a specific grouping.
+ external-miscellaneous:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ 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: 50
+ cooldown:
+ default-days: 7
+ groups:
+ twentytwentyfive-css:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**browserslist*"
+ - "*css*"
+
+ - package-ecosystem: "npm"
+ directory: "/src/wp-content/themes/twentytwentytwo"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ groups:
+ twentytwentytwo-css:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**browserslist*"
+ - "*css*"
+
+ - package-ecosystem: "npm"
+ directory: "/src/wp-content/themes/twentytwentyone"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ groups:
+ twentytwentyone-sass-css:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**browserslist*"
+ - "autoprefixer"
+ - "*css*"
+ - "*sass*"
+ - "!*stylelint*"
+ twentytwentyone-eslint:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**eslint*"
+ twentytwentyone-stylelint:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**stylelint*"
+ twentytwentyone-miscellaneous:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "chokidar-cli"
+ - "minimist"
+ - "npm-run-all"
+
+ - package-ecosystem: "npm"
+ directory: "/src/wp-content/themes/twentytwenty"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ groups:
+ twentytwenty-css:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**browserslist*"
+ - "autoprefixer"
+ - "*css*"
+ twentytwenty-stylelint:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "*stylelint*"
+ twentytwenty-miscellaneous:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "concurrently"
+ - "@wordpress/scripts"
+
+ - package-ecosystem: "npm"
+ directory: "/src/wp-content/themes/twentynineteen"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 50
+ cooldown:
+ default-days: 7
+ groups:
+ twentynineteen-css-sass:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "**browserslist*"
+ - "autoprefixer"
+ - "*css*"
+ - "*sass*"
+ twentynineteen-miscellaneous:
+ applies-to: version-updates
+ update-types:
+ - minor
+ - patch
+ patterns:
+ - "chokidar-cli"
+ - "npm-run-all"
diff --git a/.github/template-gutenberg-sync-pr-body.md b/.github/template-gutenberg-sync-pr-body.md
new file mode 100644
index 0000000000000..4c8dce3f9293f
--- /dev/null
+++ b/.github/template-gutenberg-sync-pr-body.md
@@ -0,0 +1,24 @@
+This updates the pinned Gutenberg repository commit hash for `__BRANCH__` from `__OLD_SHA__` to `__NEW_SHA__`.
+
+
+Changes
+
+__COMMIT_LIST__
+
+__SOURCE_LINE__
+
+
+
+
+Changelog
+
+__CHANGELOG__
+
+
+
+**Note:** This pull request was created by the `gutenberg-sync.yml` workflow.
+
+Trac ticket: Core-
+
+---
+**This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See [GitHub Pull Requests for Code Review](https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/) in the Core Handbook for more details.**
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/.github/workflows/gutenberg-sync-label.yml b/.github/workflows/gutenberg-sync-label.yml
new file mode 100644
index 0000000000000..c441202c448da
--- /dev/null
+++ b/.github/workflows/gutenberg-sync-label.yml
@@ -0,0 +1,38 @@
+# Applies the "Gutenberg Sync" label to pull requests opened from a
+# gutenberg-sync/* branch (the branch name the Gutenberg Sync workflow creates).
+#
+# Labeling lives here rather than in gutenberg-sync.yml because that workflow
+# runs from a fork, whose GITHUB_TOKEN cannot label a pull request on upstream.
+# This workflow runs on upstream in response to the pull request being opened,
+# where its GITHUB_TOKEN has permission to label it. It only reads the branch
+# name and never checks out the pull request's code, so pull_request_target is
+# safe here.
+name: Label Gutenberg Sync PRs
+
+on:
+ # This workflow only reads the head branch name and labels the PR; it never
+ # checks out or runs the pull request's code, so pull_request_target is safe.
+ pull_request_target: # zizmor: ignore[dangerous-triggers]
+ types:
+ - opened
+ - reopened
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions are configured at the job level.
+permissions: {}
+
+jobs:
+ label:
+ name: Apply the Gutenberg Sync label
+ runs-on: ubuntu-24.04
+ if: ${{ github.repository == 'WordPress/wordpress-develop' && startsWith( github.head_ref, 'gutenberg-sync/' ) }}
+ timeout-minutes: 5
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Add the Gutenberg Sync label
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "Gutenberg Sync"
diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml
new file mode 100644
index 0000000000000..f4e35e3cbd31b
--- /dev/null
+++ b/.github/workflows/gutenberg-sync.yml
@@ -0,0 +1,372 @@
+# Prepares a branch that updates the bundled Gutenberg commit reference
+# (package.json -> gutenberg.sha) for one branch:
+# - "trunk": the commit for Gutenberg's latest public (non-prerelease) release.
+# - an X.Y branch: the head of Gutenberg's matching wp/X.Y branch.
+#
+# WordPress/wordpress-develop does not accept pushed branches, so this workflow
+# runs from a FORK: it checks out the target branch from upstream, pushes a
+# gutenberg-sync/ branch to the fork it runs in, and writes the pull
+# request title and body to the job summary. Open the pull request manually
+# using the compare link in the summary, then copy the title and body into it.
+# Each run targets one branch.
+#
+# The work is split across three jobs: "research" resolves the SHAs and the
+# changelog (read-only), "create-update-branch" pushes the branch to the fork
+# (the only job that needs write access), and "build-pr-body" assembles the
+# pull request body. Resolved values flow downstream through job outputs; the
+# changelog and commit list are passed as artifacts.
+#
+# The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml
+# when a gutenberg-sync/* pull request is opened.
+name: Gutenberg Sync
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch to update.'
+ required: true
+ type: choice
+ # Update this list as Gutenberg cuts new wp/X.Y branches.
+ options:
+ - 'trunk'
+ - '7.0'
+ default: 'trunk'
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions are configured at the job level.
+permissions: {}
+
+env:
+ # Where pull requests are opened and the target branch is read from.
+ UPSTREAM_REPO: WordPress/wordpress-develop
+ # The target branch and the bot-managed PR head branch, derived from the input.
+ BASE_BRANCH: ${{ inputs.branch }}
+ HEAD_BRANCH: gutenberg-sync/${{ inputs.branch }}
+
+# Serialize runs for the same target branch; allow different branches in parallel.
+concurrency:
+ group: ${{ github.workflow }}-${{ inputs.branch }}
+ cancel-in-progress: false
+
+jobs:
+ # Resolves the new and current SHAs, the commit list, and (for trunk) the
+ # release changelog.
+ research:
+ name: Determine context
+ runs-on: ubuntu-24.04
+ # Runs from a fork; the canonical repository does not accept pushed branches.
+ if: ${{ github.repository != 'WordPress/wordpress-develop' }}
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ outputs:
+ new-sha: ${{ steps.export.outputs.new-sha }}
+ old-sha: ${{ steps.export.outputs.old-sha }}
+ pr-title: ${{ steps.export.outputs.pr-title }}
+ tag: ${{ steps.export.outputs.tag }}
+ release-url: ${{ steps.export.outputs.release-url }}
+ proceed: ${{ steps.export.outputs.proceed }}
+
+ steps:
+ - name: Checkout the upstream target branch
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: WordPress/wordpress-develop
+ ref: ${{ env.BASE_BRANCH }}
+ fetch-depth: 1
+ persist-credentials: false
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Retrieve the latest release details (trunk only)
+ if: ${{ inputs.branch == 'trunk' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ release=$(gh api repos/WordPress/gutenberg/releases/latest)
+ TAG=$(jq -r '.tag_name' <<<"$release")
+ RELEASE_URL=$(jq -r '.html_url' <<<"$release")
+ jq -r '.body // ""' <<<"$release" > release_body.md
+
+ # Keep only the "## Changelog" section: everything from that heading to
+ # the next top-level heading. This drops the heading itself and the
+ # trailing "## First-time contributors"/"## Contributors" sections.
+ awk '
+ /^## Changelog[[:space:]]*$/ { in_changelog = 1; next }
+ /^## / && in_changelog { in_changelog = 0 }
+ in_changelog { print }
+ ' release_body.md > release_changelog.md
+
+ {
+ echo "TAG=${TAG}"
+ echo "RELEASE_URL=${RELEASE_URL}"
+ echo "PR_TITLE=Bump \`gutenberg.sha\` to version ${TAG}"
+ } >> "$GITHUB_ENV"
+
+ - name: Checkout the Gutenberg repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: WordPress/gutenberg
+ # Trunk targets the release tag; version branches target wp/X.Y.
+ ref: ${{ inputs.branch == 'trunk' && env.TAG || format( 'wp/{0}', env.BASE_BRANCH ) }}
+ path: gutenberg-src
+ fetch-depth: 0
+ persist-credentials: false
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Determine the new commit SHA
+ run: |
+ echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV"
+
+ - name: Determine the current SHA (trunk)
+ if: ${{ inputs.branch == 'trunk' }}
+ run: |
+ set -euo pipefail
+ OLD_SHA=$(jq -r '.gutenberg.sha' package.json)
+ {
+ echo "OLD_SHA=${OLD_SHA}"
+ if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi
+ } >> "$GITHUB_ENV"
+
+ - name: Determine the current SHA (version branches)
+ if: ${{ inputs.branch != 'trunk' }}
+ run: |
+ set -euo pipefail
+ OLD_SHA=$(jq -r '.gutenberg.sha' package.json)
+ {
+ echo "OLD_SHA=${OLD_SHA}"
+ echo "PR_TITLE=WP ${BASE_BRANCH}: bump \`gutenberg.sha\` to the latest from \`wp/${BASE_BRANCH}\`"
+ if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi
+ } >> "$GITHUB_ENV"
+
+ - name: Build the list of changes
+ run: |
+ set -euo pipefail
+ if ! git -C gutenberg-src cat-file -e "${OLD_SHA}^{commit}" 2>/dev/null; then
+ # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions.
+ printf -- '_Previous reference `%s` was not found in Gutenberg history; the list of changes was omitted._\n' "${OLD_SHA}" > commit_list.md
+ else
+ git -C gutenberg-src log --reverse --format="- %s" "${OLD_SHA}..${NEW_SHA}" \
+ | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' \
+ > commit_list_full.md
+
+ # Cap the list so the PR body cannot exceed GitHub's body limit.
+ total=$(wc -l < commit_list_full.md)
+ if [ "${total}" -gt 300 ]; then
+ head -n 300 commit_list_full.md > commit_list.md
+ printf -- '- _…and %s more commits._\n' "$(( total - 300 ))" >> commit_list.md
+ else
+ cp commit_list_full.md commit_list.md
+ fi
+ fi
+
+ - name: Export the resolved values
+ id: export
+ run: |
+ set -euo pipefail
+ {
+ echo "new-sha=${NEW_SHA:-}"
+ echo "old-sha=${OLD_SHA:-}"
+ echo "pr-title=${PR_TITLE:-}"
+ echo "tag=${TAG:-}"
+ echo "release-url=${RELEASE_URL:-}"
+ echo "proceed=${PROCEED:-false}"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Upload the list of changes
+ if: ${{ env.PROCEED == 'true' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: gutenberg-sync-commit-list
+ path: commit_list.md
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Upload the release changelog (trunk only)
+ if: ${{ env.PROCEED == 'true' && inputs.branch == 'trunk' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: gutenberg-sync-changelog
+ path: release_changelog.md
+ if-no-files-found: error
+ retention-days: 1
+
+ # Pushes the gutenberg-sync/ branch to the fork. The only job with write access.
+ create-update-branch:
+ name: Update pinned hash value and push
+ runs-on: ubuntu-24.04
+ needs: [ 'research' ]
+ if: ${{ needs.research.outputs.proceed == 'true' }}
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout the upstream target branch
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: WordPress/wordpress-develop
+ ref: ${{ env.BASE_BRANCH }}
+ fetch-depth: 0
+ token: ${{ github.token }}
+ persist-credentials: true
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Configure git author
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Create a sync branch
+ run: |
+ git checkout -B "${HEAD_BRANCH}"
+
+ - name: Update the pinned hash value in a temporary file
+ run: jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp
+ env:
+ NEW_SHA: ${{ needs.research.outputs.new-sha }}
+
+ - name: Move temporary file
+ run: mv package.json.tmp package.json
+
+ - name: Stage changes
+ run: |
+ git add package.json
+
+ - name: Commit changes
+ run: |
+ git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference."
+
+ - name: Configure fork as a remote
+ env:
+ FORK_REPO: ${{ github.repository }}
+ run: |
+ git remote add fork "https://github.com/${FORK_REPO}.git"
+
+ - name: Push changes to fork
+ run: |
+ git push --force fork "HEAD:${HEAD_BRANCH}"
+
+ # Assembles the pull request body and writes it to the job summary.
+ build-pr-body:
+ name: Build pull request body
+ runs-on: ubuntu-24.04
+ needs: [ 'research', 'create-update-branch' ]
+ if: ${{ needs.research.outputs.proceed == 'true' }}
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout the pull request body template
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: .github/template-gutenberg-sync-pr-body.md
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Download the list of changes
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: gutenberg-sync-commit-list
+
+ - name: Download the release changelog (trunk only)
+ if: ${{ inputs.branch == 'trunk' }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: gutenberg-sync-changelog
+
+ - name: Copy the pull request body template
+ run: |
+ cp .github/template-gutenberg-sync-pr-body.md pr_body.md
+
+ - name: Add the branch name to the body
+ run: |
+ sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md
+
+ - name: Add the new SHA to the body
+ env:
+ NEW_SHA: ${{ needs.research.outputs.new-sha }}
+ run: |
+ sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md
+
+ - name: Add the old SHA to the body
+ env:
+ OLD_SHA: ${{ needs.research.outputs.old-sha }}
+ run: |
+ sed -i "s|__OLD_SHA__|${OLD_SHA}|g" pr_body.md
+
+ - name: Add the list of changes to the body
+ run: |
+ awk '/__COMMIT_LIST__/ { while ( ( getline line < "commit_list.md" ) > 0 ) print line; close( "commit_list.md" ); next } 1' pr_body.md > pr_body.tmp
+ mv pr_body.tmp pr_body.md
+
+ - name: Add the release link to the body (trunk only)
+ if: ${{ inputs.branch == 'trunk' }}
+ env:
+ TAG: ${{ needs.research.outputs.tag }}
+ RELEASE_URL: ${{ needs.research.outputs.release-url }}
+ run: |
+ sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md
+
+ - name: Add the compare link to the body (version branches)
+ if: ${{ inputs.branch != 'trunk' }}
+ env:
+ NEW_SHA: ${{ needs.research.outputs.new-sha }}
+ OLD_SHA: ${{ needs.research.outputs.old-sha }}
+ run: |
+ sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md
+
+ - name: Add the changelog to the body (trunk only)
+ if: ${{ inputs.branch == 'trunk' }}
+ run: |
+ awk '/__CHANGELOG__/ { while ( ( getline line < "release_changelog.md" ) > 0 ) print line; close( "release_changelog.md" ); next } 1' pr_body.md > pr_body.tmp
+ mv pr_body.tmp pr_body.md
+
+ - name: Remove the changelog section (version branches)
+ if: ${{ inputs.branch != 'trunk' }}
+ run: |
+ # Version branches have no release changelog, so drop the whole
+ # block that contains the __CHANGELOG__ placeholder.
+ awk '
+ /^$/ { block = $0 ORS; in_block = 1; drop = 0; next }
+ in_block {
+ block = block $0 ORS
+ if ( $0 ~ /__CHANGELOG__/ ) { drop = 1 }
+ if ( $0 == " " ) {
+ if ( ! drop ) { printf "%s", block }
+ in_block = 0
+ }
+ next
+ }
+ { print }
+ ' pr_body.md > pr_body.tmp
+ mv pr_body.tmp pr_body.md
+
+ - name: Output pull request details
+ env:
+ FORK_OWNER: ${{ github.repository_owner }}
+ PR_TITLE: ${{ needs.research.outputs.pr-title }}
+ run: |
+ set -euo pipefail
+ compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1"
+ {
+ echo "## Gutenberg Sync"
+ echo ""
+ echo "Pushed \`${HEAD_BRANCH}\` to \`${FORK_OWNER}\`."
+ echo ""
+ echo "[Open a pull request](${compare_url}) against \`${UPSTREAM_REPO}\`, then copy the title and body below into it."
+ echo ""
+ echo "### PR Title"
+ echo ""
+ echo '```'
+ echo "${PR_TITLE}"
+ echo '```'
+ echo ""
+ echo "### PR Body"
+ echo ""
+ # A four-backtick fence so a body containing triple-backtick code blocks copies intact.
+ echo '````markdown'
+ cat pr_body.md
+ echo '````'
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/docs/superpowers/plans/2026-06-26-gutenberg-sha-sync.md b/docs/superpowers/plans/2026-06-26-gutenberg-sha-sync.md
new file mode 100644
index 0000000000000..2b0da90f4d1e4
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-26-gutenberg-sha-sync.md
@@ -0,0 +1,605 @@
+# Gutenberg SHA Sync Workflow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a GitHub Actions workflow that opens draft PRs updating `package.json` → `gutenberg.sha` for `trunk` (latest public Gutenberg release) and the most recent version branch (daily, from `wp/X.Y` head).
+
+**Architecture:** A single workflow file `.github/workflows/gutenberg-sync.yml` with three jobs: a `setup` job that detects the latest `X.Y` version branch and whether its `wp/X.Y` counterpart exists in Gutenberg; an `update-trunk` job; and an `update-version-branch` job. Each update job mints its own GitHub App installation token (reusing the app from `commit-built-file-changes.yml`), resolves the target SHA + changelog, and runs a shared inline "find-or-update labeled draft PR" routine. Logic is inline (`run:`/`gh` CLI) per the chosen Option C; shared values flow through job outputs.
+
+**Tech Stack:** GitHub Actions (YAML), `gh` CLI, `jq`, `git`, `python3` (PyJWT) for the app-token JWT, `actionlint` + `zizmor` for linting.
+
+## Global Constraints
+
+- Repository guard on every job: `if: ${{ github.repository == 'WordPress/wordpress-develop' }}` (combined with other conditions where present).
+- Top-level `permissions: {}`; each job grants only the minimum (`contents: read` for `GITHUB_TOKEN`). All writes use the app installation token so opened/updated PRs trigger CI.
+- Pin every external action to a full commit SHA (reuse SHAs already vetted in this repo: `actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`).
+- **Zizmor-safe:** never interpolate API-fetched or untrusted data (`${{ ... }}`) directly into `run:` scripts. Pass values via `env:` or write multiline content (release notes, commit lists) to files. Set `persist-credentials: false` on checkouts that do not push.
+- App credentials: `vars.GH_PR_BUILT_FILES_APP_ID` and `secrets.GH_PR_BUILT_FILES_PRIVATE_KEY`.
+- Bot git identity: name `wordpress-develop-pr-bot[bot]`, email `${GH_APP_ID}+wordpress-develop-pr-bot[bot]@users.noreply.github.com`.
+- PR label (already exists): `Gutenberg Sync`.
+- Commit message: `Build/Test Tools: Update the bundled Gutenberg commit reference.`
+- `package.json` uses **tab** indentation — edit the SHA with `jq --tab` to keep the diff to a single line.
+- Version → Gutenberg branch mapping is identity: WP `X.Y` ↔ Gutenberg `wp/X.Y`.
+- Daily schedule (`cron: '0 6 * * *'`) plus `workflow_dispatch` with `target` (both|trunk|version-branch) and `dry_run` inputs.
+
+---
+
+### Task 1: Workflow skeleton, triggers, and `setup` job
+
+**Files:**
+- Create: `.github/workflows/gutenberg-sync.yml`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: job `setup` with outputs `latest_version_branch` (e.g. `7.0`) and `wp_branch_exists` (`true`/`false`), consumed by Task 3's `update-version-branch` job via `needs.setup.outputs.*`.
+
+- [ ] **Step 1: Write the branch-detection logic as a local test first**
+
+Create a throwaway script to prove the detection picks the correct latest `X.Y` branch against the live repo. Save as `/tmp/detect-branch.sh`:
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+REPO="WordPress/wordpress-develop"
+latest=$(gh api --paginate "repos/${REPO}/branches" --jq '.[].name' \
+ | grep -E '^[0-9]+\.[0-9]+$' | sort -V | tail -1)
+echo "latest_version_branch=$latest"
+if git ls-remote --exit-code --heads https://github.com/WordPress/gutenberg.git "wp/${latest}" >/dev/null 2>&1; then
+ echo "wp_branch_exists=true"
+else
+ echo "wp_branch_exists=false"
+fi
+```
+
+- [ ] **Step 2: Run the detection test to confirm it returns a sane result**
+
+Run: `bash /tmp/detect-branch.sh`
+Expected: prints `latest_version_branch=` (e.g. `latest_version_branch=7.0`) and `wp_branch_exists=true` or `false`. Cross-check the branch against `gh api repos/WordPress/wordpress-develop/branches --jq '.[].name' | grep -E '^[0-9]+\.[0-9]+$' | sort -V | tail -1`. If they match, the logic is correct.
+
+- [ ] **Step 3: Create the workflow file with triggers, permissions, concurrency, and the `setup` job**
+
+Create `.github/workflows/gutenberg-sync.yml`:
+
+```yaml
+# Opens draft pull requests that update the bundled Gutenberg commit reference
+# (package.json -> gutenberg.sha):
+# - trunk: the commit for Gutenberg's latest public (non-prerelease) release.
+# - the most recent X.Y version branch: the head of Gutenberg's wp/X.Y branch.
+name: Gutenberg Sync
+
+on:
+ schedule:
+ # Daily. Drives the version-branch update and the trunk release poll.
+ - cron: '0 6 * * *'
+ workflow_dispatch:
+ inputs:
+ target:
+ description: 'Which branches to update.'
+ type: choice
+ options:
+ - both
+ - trunk
+ - version-branch
+ default: both
+ dry_run:
+ description: 'Log intended actions without creating or updating PRs.'
+ type: boolean
+ default: false
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions are configured at the job level.
+permissions: {}
+
+# Prevent overlapping daily/manual runs from racing on the same PR branches.
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
+jobs:
+ # Detects the most recent X.Y version branch and whether Gutenberg has a
+ # matching wp/X.Y branch. Shared by the version-branch job.
+ setup:
+ name: Determine target branches
+ runs-on: ubuntu-24.04
+ if: ${{ github.repository == 'WordPress/wordpress-develop' }}
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ latest_version_branch: ${{ steps.detect.outputs.latest_version_branch }}
+ wp_branch_exists: ${{ steps.detect.outputs.wp_branch_exists }}
+ steps:
+ - name: Detect latest version branch and wp/X.Y existence
+ id: detect
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ run: |
+ latest=$(gh api --paginate "repos/${REPO}/branches" --jq '.[].name' \
+ | grep -E '^[0-9]+\.[0-9]+$' | sort -V | tail -1)
+ echo "latest_version_branch=${latest}" >> "$GITHUB_OUTPUT"
+
+ if [ -n "${latest}" ] && git ls-remote --exit-code --heads \
+ https://github.com/WordPress/gutenberg.git "wp/${latest}" >/dev/null 2>&1; then
+ echo "wp_branch_exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "wp_branch_exists=false" >> "$GITHUB_OUTPUT"
+ fi
+```
+
+- [ ] **Step 4: Lint the workflow file**
+
+Run: `actionlint .github/workflows/gutenberg-sync.yml`
+Expected: no output (exit 0). actionlint also runs `shellcheck` on the `run:` block — fix any reported issues.
+
+- [ ] **Step 5: Confirm the file parses as valid YAML**
+
+Run: `yq '.jobs.setup.outputs' .github/workflows/gutenberg-sync.yml`
+Expected: prints the two output keys (`latest_version_branch`, `wp_branch_exists`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add .github/workflows/gutenberg-sync.yml
+git commit -m "Build/Test Tools: Add Gutenberg Sync workflow skeleton and setup job."
+```
+
+---
+
+### Task 2: `update-trunk` job
+
+**Files:**
+- Modify: `.github/workflows/gutenberg-sync.yml` (append the `update-trunk` job)
+
+**Interfaces:**
+- Consumes: app credentials `vars.GH_PR_BUILT_FILES_APP_ID`, `secrets.GH_PR_BUILT_FILES_PRIVATE_KEY`.
+- Produces: a draft PR to `trunk` labeled `Gutenberg Sync`, head branch `gutenberg-sync/trunk`. Establishes the shared "create or update labeled draft PR" `run:` block reused verbatim in Task 3.
+
+- [ ] **Step 1: Verify the SHA edit keeps a one-line diff (local test)**
+
+Prove `jq --tab` changes only the SHA line in the real `package.json`:
+
+```bash
+cp package.json /tmp/pkg.bak
+jq --tab --arg sha "0000000000000000000000000000000000000000" '.gutenberg.sha = $sha' package.json > /tmp/pkg.new
+diff <(cat package.json) /tmp/pkg.new
+```
+
+Run the above.
+Expected: the `diff` shows exactly one changed line (the `"sha":` line). If more lines differ, indentation/key-order is being altered — do not proceed until only one line changes.
+
+- [ ] **Step 2: Verify trunk SHA resolution against the live Gutenberg API (local test)**
+
+```bash
+release=$(gh api repos/WordPress/gutenberg/releases/latest)
+TAG=$(jq -r '.tag_name' <<<"$release")
+ref=$(gh api "repos/WordPress/gutenberg/git/refs/tags/${TAG}")
+obj_type=$(jq -r '.object.type' <<<"$ref")
+obj_sha=$(jq -r '.object.sha' <<<"$ref")
+if [ "$obj_type" = "tag" ]; then
+ NEW_SHA=$(gh api "repos/WordPress/gutenberg/git/tags/${obj_sha}" --jq '.object.sha')
+else
+ NEW_SHA="$obj_sha"
+fi
+echo "TAG=$TAG NEW_SHA=$NEW_SHA"
+```
+
+Run the above.
+Expected: prints a tag like `v21.x` and a 40-character `NEW_SHA`. Confirms the annotated-tag dereference path works.
+
+- [ ] **Step 3: Append the `update-trunk` job**
+
+Add this job to `.github/workflows/gutenberg-sync.yml` (after `setup`):
+
+```yaml
+ # Updates trunk to the commit referenced by Gutenberg's latest public release.
+ update-trunk:
+ name: Update trunk to latest release
+ runs-on: ubuntu-24.04
+ needs: setup
+ if: >-
+ ${{ github.repository == 'WordPress/wordpress-develop' &&
+ ( github.event_name != 'workflow_dispatch' ||
+ inputs.target == 'both' || inputs.target == 'trunk' ) }}
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: Generate installation token
+ id: generate_token
+ env:
+ GH_APP_ID: ${{ vars.GH_PR_BUILT_FILES_APP_ID }}
+ GH_APP_PRIVATE_KEY: ${{ secrets.GH_PR_BUILT_FILES_PRIVATE_KEY }}
+ run: |
+ JWT=$(python3 - <<'EOF'
+ import jwt, time, os
+ payload = {
+ "iat": int(time.time()),
+ "exp": int(time.time()) + 600, # 10-minute expiration
+ "iss": int(os.environ["GH_APP_ID"]),
+ }
+ print(jwt.encode(payload, os.environ["GH_APP_PRIVATE_KEY"], algorithm="RS256"))
+ EOF
+ )
+ INSTALLATION_ID=$(curl -s -X GET -H "Authorization: Bearer $JWT" \
+ -H "Accept: application/vnd.github.v3+json" \
+ https://api.github.com/app/installations | jq -r '.[0].id')
+ ACCESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer $JWT" \
+ -H "Accept: application/vnd.github.v3+json" \
+ "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens" | jq -r '.token')
+ echo "::add-mask::$ACCESS_TOKEN"
+ echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout trunk
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: trunk
+ fetch-depth: 0
+ token: ${{ steps.generate_token.outputs.access-token }}
+ persist-credentials: true
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Configure git author
+ env:
+ GH_APP_ID: ${{ vars.GH_PR_BUILT_FILES_APP_ID }}
+ run: |
+ git config user.name "wordpress-develop-pr-bot[bot]"
+ git config user.email "${GH_APP_ID}+wordpress-develop-pr-bot[bot]@users.noreply.github.com"
+
+ - name: Resolve release SHA and build PR body
+ env:
+ GH_TOKEN: ${{ steps.generate_token.outputs.access-token }}
+ run: |
+ release=$(gh api repos/WordPress/gutenberg/releases/latest)
+ TAG=$(jq -r '.tag_name' <<<"$release")
+ RELEASE_URL=$(jq -r '.html_url' <<<"$release")
+
+ ref=$(gh api "repos/WordPress/gutenberg/git/refs/tags/${TAG}")
+ obj_type=$(jq -r '.object.type' <<<"$ref")
+ obj_sha=$(jq -r '.object.sha' <<<"$ref")
+ if [ "$obj_type" = "tag" ]; then
+ NEW_SHA=$(gh api "repos/WordPress/gutenberg/git/tags/${obj_sha}" --jq '.object.sha')
+ else
+ NEW_SHA="$obj_sha"
+ fi
+
+ OLD_SHA=$(jq -r '.gutenberg.sha' package.json)
+
+ # Write the (untrusted) release notes to a file rather than env.
+ jq -r '.body // ""' <<<"$release" > release_notes.md
+ {
+ printf 'Updates the bundled Gutenberg commit reference to `%s`.\n\n' "$NEW_SHA"
+ printf 'Release: [%s](%s)\n\n' "$TAG" "$RELEASE_URL"
+ printf '## Changelog\n\n'
+ cat release_notes.md
+ } > pr_body.md
+
+ {
+ echo "BASE=trunk"
+ echo "HEAD_BRANCH=gutenberg-sync/trunk"
+ echo "NEW_SHA=${NEW_SHA}"
+ echo "PR_TITLE=Gutenberg Sync: Update trunk to ${TAG}"
+ if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi
+ } >> "$GITHUB_ENV"
+
+ - name: Create or update the draft pull request
+ if: ${{ env.PROCEED == 'true' }}
+ env:
+ GH_TOKEN: ${{ steps.generate_token.outputs.access-token }}
+ REPO: ${{ github.repository }}
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}
+ COMMIT_MSG: 'Build/Test Tools: Update the bundled Gutenberg commit reference.'
+ run: |
+ set -euo pipefail
+
+ pr_json=$(gh pr list --repo "$REPO" --state open --base "$BASE" \
+ --label "Gutenberg Sync" --json number,headRefName,isCrossRepository \
+ --jq '.[0] // empty')
+
+ if [ "${DRY_RUN}" = "true" ]; then
+ echo "DRY RUN: base '$BASE' -> sha '$NEW_SHA'."
+ if [ -n "$pr_json" ]; then
+ echo "DRY RUN: would update existing PR #$(jq -r '.number' <<<"$pr_json")."
+ else
+ echo "DRY RUN: would create draft PR from '$HEAD_BRANCH'."
+ fi
+ exit 0
+ fi
+
+ if [ -n "$pr_json" ]; then
+ number=$(jq -r '.number' <<<"$pr_json")
+ head=$(jq -r '.headRefName' <<<"$pr_json")
+ cross=$(jq -r '.isCrossRepository' <<<"$pr_json")
+
+ if [ "$cross" = "true" ]; then
+ echo "::warning::PR #$number is from a fork; updating metadata only."
+ printf '\n\n> Note: the SHA could not be pushed automatically (PR is from a fork). Please set `gutenberg.sha` to `%s`.\n' "$NEW_SHA" >> pr_body.md
+ gh pr edit "$number" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md
+ exit 0
+ fi
+
+ git fetch origin "$head"
+ git checkout -B "$head" "origin/$head"
+ else
+ number=""
+ git checkout -B "$HEAD_BRANCH" "origin/$BASE"
+ fi
+
+ jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp
+ mv package.json.tmp package.json
+
+ if git diff --quiet -- package.json; then
+ echo "package.json already at $NEW_SHA; no commit needed."
+ else
+ git add package.json
+ git commit -m "$COMMIT_MSG"
+ fi
+
+ if [ -n "$number" ]; then
+ git push origin "HEAD:$head"
+ gh pr edit "$number" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md
+ else
+ git push --force origin "HEAD:$HEAD_BRANCH"
+ gh pr create --repo "$REPO" --draft --base "$BASE" --head "$HEAD_BRANCH" \
+ --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync"
+ fi
+```
+
+- [ ] **Step 4: Lint the workflow file**
+
+Run: `actionlint .github/workflows/gutenberg-sync.yml`
+Expected: no output (exit 0). Resolve any actionlint/shellcheck findings.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .github/workflows/gutenberg-sync.yml
+git commit -m "Build/Test Tools: Add trunk update job to Gutenberg Sync workflow."
+```
+
+---
+
+### Task 3: `update-version-branch` job
+
+**Files:**
+- Modify: `.github/workflows/gutenberg-sync.yml` (append the `update-version-branch` job)
+
+**Interfaces:**
+- Consumes: `needs.setup.outputs.latest_version_branch` and `needs.setup.outputs.wp_branch_exists` from Task 1; app credentials.
+- Produces: a draft PR to the `X.Y` branch labeled `Gutenberg Sync`, head branch `gutenberg-sync/`.
+
+- [ ] **Step 1: Verify the commit-list pipeline against real Gutenberg history (local test)**
+
+Confirm the user's pipeline produces a linkified, PR-only commit list. Pick two real `wp/X.Y` SHAs (any older→newer pair on a wp branch):
+
+```bash
+git clone --filter=tree:0 --no-checkout https://github.com/WordPress/gutenberg.git /tmp/gb-src
+OLD=$(git -C /tmp/gb-src rev-parse wp/6.8~20)
+NEW=$(git -C /tmp/gb-src rev-parse wp/6.8)
+git -C /tmp/gb-src log --reverse --format="- %s" "$OLD...$NEW" \
+ | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d'
+```
+
+Run the above.
+Expected: a Markdown bullet list where each line ends in a `https://github.com/WordPress/gutenberg/pull/` link, and lines without a PR reference are omitted. Confirms the treeless clone exposes `wp/*` history and the `sed` filter works.
+
+- [ ] **Step 2: Append the `update-version-branch` job**
+
+Add this job to `.github/workflows/gutenberg-sync.yml` (after `update-trunk`):
+
+```yaml
+ # Updates the most recent X.Y version branch to the head of Gutenberg's wp/X.Y.
+ update-version-branch:
+ name: Update version branch to wp/X.Y head
+ runs-on: ubuntu-24.04
+ needs: setup
+ if: >-
+ ${{ github.repository == 'WordPress/wordpress-develop' &&
+ needs.setup.outputs.wp_branch_exists == 'true' &&
+ ( github.event_name != 'workflow_dispatch' ||
+ inputs.target == 'both' || inputs.target == 'version-branch' ) }}
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ env:
+ BASE_BRANCH: ${{ needs.setup.outputs.latest_version_branch }}
+ steps:
+ - name: Generate installation token
+ id: generate_token
+ env:
+ GH_APP_ID: ${{ vars.GH_PR_BUILT_FILES_APP_ID }}
+ GH_APP_PRIVATE_KEY: ${{ secrets.GH_PR_BUILT_FILES_PRIVATE_KEY }}
+ run: |
+ JWT=$(python3 - <<'EOF'
+ import jwt, time, os
+ payload = {
+ "iat": int(time.time()),
+ "exp": int(time.time()) + 600, # 10-minute expiration
+ "iss": int(os.environ["GH_APP_ID"]),
+ }
+ print(jwt.encode(payload, os.environ["GH_APP_PRIVATE_KEY"], algorithm="RS256"))
+ EOF
+ )
+ INSTALLATION_ID=$(curl -s -X GET -H "Authorization: Bearer $JWT" \
+ -H "Accept: application/vnd.github.v3+json" \
+ https://api.github.com/app/installations | jq -r '.[0].id')
+ ACCESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer $JWT" \
+ -H "Accept: application/vnd.github.v3+json" \
+ "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens" | jq -r '.token')
+ echo "::add-mask::$ACCESS_TOKEN"
+ echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout version branch
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ needs.setup.outputs.latest_version_branch }}
+ fetch-depth: 0
+ token: ${{ steps.generate_token.outputs.access-token }}
+ persist-credentials: true
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+
+ - name: Configure git author
+ env:
+ GH_APP_ID: ${{ vars.GH_PR_BUILT_FILES_APP_ID }}
+ run: |
+ git config user.name "wordpress-develop-pr-bot[bot]"
+ git config user.email "${GH_APP_ID}+wordpress-develop-pr-bot[bot]@users.noreply.github.com"
+
+ - name: Resolve wp/X.Y head and build PR body
+ env:
+ GH_TOKEN: ${{ steps.generate_token.outputs.access-token }}
+ run: |
+ set -euo pipefail
+ NEW_SHA=$(gh api "repos/WordPress/gutenberg/commits/wp/${BASE_BRANCH}" --jq '.sha')
+ OLD_SHA=$(jq -r '.gutenberg.sha' package.json)
+
+ git clone --filter=tree:0 --no-checkout \
+ https://github.com/WordPress/gutenberg.git gutenberg-src
+ git -C gutenberg-src log --reverse --format="- %s" "${OLD_SHA}...${NEW_SHA}" \
+ | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' \
+ > commit_list.md
+
+ {
+ printf 'Updates the bundled Gutenberg commit reference for `%s` to the latest `wp/%s` commit `%s`.\n\n' \
+ "$BASE_BRANCH" "$BASE_BRANCH" "$NEW_SHA"
+ printf 'Compare: https://github.com/WordPress/gutenberg/compare/%s...%s\n\n' "$OLD_SHA" "$NEW_SHA"
+ printf '## Changes\n\n'
+ cat commit_list.md
+ } > pr_body.md
+
+ {
+ echo "BASE=${BASE_BRANCH}"
+ echo "HEAD_BRANCH=gutenberg-sync/${BASE_BRANCH}"
+ echo "NEW_SHA=${NEW_SHA}"
+ echo "PR_TITLE=Gutenberg Sync: Update ${BASE_BRANCH} to latest wp/${BASE_BRANCH}"
+ if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi
+ } >> "$GITHUB_ENV"
+
+ - name: Create or update the draft pull request
+ if: ${{ env.PROCEED == 'true' }}
+ env:
+ GH_TOKEN: ${{ steps.generate_token.outputs.access-token }}
+ REPO: ${{ github.repository }}
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}
+ COMMIT_MSG: 'Build/Test Tools: Update the bundled Gutenberg commit reference.'
+ run: |
+ set -euo pipefail
+
+ pr_json=$(gh pr list --repo "$REPO" --state open --base "$BASE" \
+ --label "Gutenberg Sync" --json number,headRefName,isCrossRepository \
+ --jq '.[0] // empty')
+
+ if [ "${DRY_RUN}" = "true" ]; then
+ echo "DRY RUN: base '$BASE' -> sha '$NEW_SHA'."
+ if [ -n "$pr_json" ]; then
+ echo "DRY RUN: would update existing PR #$(jq -r '.number' <<<"$pr_json")."
+ else
+ echo "DRY RUN: would create draft PR from '$HEAD_BRANCH'."
+ fi
+ exit 0
+ fi
+
+ if [ -n "$pr_json" ]; then
+ number=$(jq -r '.number' <<<"$pr_json")
+ head=$(jq -r '.headRefName' <<<"$pr_json")
+ cross=$(jq -r '.isCrossRepository' <<<"$pr_json")
+
+ if [ "$cross" = "true" ]; then
+ echo "::warning::PR #$number is from a fork; updating metadata only."
+ printf '\n\n> Note: the SHA could not be pushed automatically (PR is from a fork). Please set `gutenberg.sha` to `%s`.\n' "$NEW_SHA" >> pr_body.md
+ gh pr edit "$number" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md
+ exit 0
+ fi
+
+ git fetch origin "$head"
+ git checkout -B "$head" "origin/$head"
+ else
+ number=""
+ git checkout -B "$HEAD_BRANCH" "origin/$BASE"
+ fi
+
+ jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp
+ mv package.json.tmp package.json
+
+ if git diff --quiet -- package.json; then
+ echo "package.json already at $NEW_SHA; no commit needed."
+ else
+ git add package.json
+ git commit -m "$COMMIT_MSG"
+ fi
+
+ if [ -n "$number" ]; then
+ git push origin "HEAD:$head"
+ gh pr edit "$number" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md
+ else
+ git push --force origin "HEAD:$HEAD_BRANCH"
+ gh pr create --repo "$REPO" --draft --base "$BASE" --head "$HEAD_BRANCH" \
+ --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync"
+ fi
+```
+
+- [ ] **Step 3: Lint the workflow file**
+
+Run: `actionlint .github/workflows/gutenberg-sync.yml`
+Expected: no output (exit 0). Resolve any actionlint/shellcheck findings.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .github/workflows/gutenberg-sync.yml
+git commit -m "Build/Test Tools: Add version branch update job to Gutenberg Sync workflow."
+```
+
+---
+
+### Task 4: Security lint and live dry-run verification
+
+**Files:**
+- None (verification only; may apply small fixes to `.github/workflows/gutenberg-sync.yml`).
+
+**Interfaces:**
+- Consumes: the complete workflow from Tasks 1–3.
+- Produces: a lint-clean, dry-run-verified workflow on a pushed branch.
+
+- [ ] **Step 1: Run the security linter (zizmor)**
+
+Run: `uvx zizmor@1.24.1 --persona=regular .github/workflows/gutenberg-sync.yml`
+Expected: no high/error findings. If zizmor reports template-injection, unpinned-action, or excessive-permission issues, fix them (move interpolations into `env:`, pin SHAs, narrow `permissions:`). If `uvx` is unavailable locally, note that this runs in CI via `workflow-lint.yml` and re-verify the constraints in the Global Constraints section by inspection.
+
+- [ ] **Step 2: Run actionlint one final time on the whole file**
+
+Run: `actionlint .github/workflows/gutenberg-sync.yml`
+Expected: no output (exit 0).
+
+- [ ] **Step 3: Push the branch and trigger a dry-run**
+
+The workflow can only execute on GitHub. Push the working branch, then dispatch with `dry_run: true`:
+
+```bash
+git push origin add/gutenberg-sha-bump
+gh workflow run "Gutenberg Sync" --ref add/gutenberg-sha-bump -f target=both -f dry_run=true
+```
+
+Expected: the run is queued. Note: `workflow_dispatch` only works once the workflow file exists on the pushed branch.
+
+- [ ] **Step 4: Inspect the dry-run output**
+
+Run: `gh run list --workflow "Gutenberg Sync" --limit 1` then `gh run view --log`
+Expected: the `setup` job resolves the latest `X.Y` branch; `update-trunk` and `update-version-branch` each log a `DRY RUN: ...` line indicating whether they would create or update a PR. No PRs are created, no branches pushed. Confirm no step errored.
+
+- [ ] **Step 5: Final commit (if Step 1/2 required fixes)**
+
+```bash
+git add .github/workflows/gutenberg-sync.yml
+git commit -m "Build/Test Tools: Address lint findings in Gutenberg Sync workflow."
+```
+
+---
+
+## Notes for the implementer
+
+- **Why the app token, not `GITHUB_TOKEN`:** PRs/commits made with the default `GITHUB_TOKEN` do not trigger downstream CI workflows. The app installation token (same app as `commit-built-file-changes.yml`) makes opened PRs run tests.
+- **Why files for changelog content:** release notes and commit subjects are untrusted input; writing them to `pr_body.md`/`commit_list.md` and using `--body-file` avoids shell/template injection (a zizmor requirement).
+- **Forked-PR fallback:** an installation token cannot push to a contributor's fork branch, so for cross-repo PRs the job updates the title/body and label only and notes the SHA in the body. This is an accepted limitation per the spec.
+- **Duplication:** the token-mint step and the create/update-PR step are intentionally duplicated across the two jobs (Option C, inline). If this becomes hard to maintain, extracting a composite action is the documented next step (out of scope here).
+- **Future trigger:** a `repository_dispatch` (type `gutenberg-release`) can later be added to the `on:` block to make trunk near-instant without restructuring.
diff --git a/docs/superpowers/specs/2026-06-26-gutenberg-sha-sync-design.md b/docs/superpowers/specs/2026-06-26-gutenberg-sha-sync-design.md
new file mode 100644
index 0000000000000..8252404fd7156
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-26-gutenberg-sha-sync-design.md
@@ -0,0 +1,172 @@
+# Gutenberg SHA Sync Workflow — Design
+
+**Date:** 2026-06-26
+**Status:** Approved
+**Branch:** `add/gutenberg-sha-bump`
+
+## Summary
+
+Add a GitHub Actions workflow that keeps `package.json` → `gutenberg.sha` (a
+full-length commit hash in the `WordPress/gutenberg` repository) up to date by
+opening draft pull requests in `WordPress/wordpress-develop`:
+
+- **`trunk`** is updated to the commit referenced by Gutenberg's most recent
+ **public release** (non-prerelease, non-draft).
+- The **most recent version branch** (`X.Y`) is updated daily to the head
+ commit of the corresponding `wp/X.Y` branch in Gutenberg.
+
+Every PR is created as a **draft**, labeled **`Gutenberg Sync`**, and carries a
+changelog in its body.
+
+## Requirements
+
+1. Open PRs against `trunk` when a new public (non-prerelease) Gutenberg release
+ is published. The SHA used is the commit associated with the release's tag.
+2. Version branches (`X.Y`) are **not** updated to release SHAs. They are updated
+ to the head commit of the matching `wp/X.Y` branch in Gutenberg.
+3. Version branches are updated **daily**.
+4. `trunk` is always updated to the most recent release.
+5. PRs are created as **drafts**.
+6. PR bodies list a changelog:
+ - **trunk:** the Gutenberg release notes (release `body`).
+ - **version branch:** the commit list produced by the pipeline in
+ [Changelog generation](#changelog-generation).
+
+## Decisions (resolved during brainstorming)
+
+| Topic | Decision |
+| --- | --- |
+| Release detection for `trunk` | **Scheduled poll now**, designed so a `repository_dispatch` can drive it later without rework. |
+| Which version branches | The **single most recent** `X.Y` branch (auto-detected; `N = 1`). |
+| `X.Y` → Gutenberg branch | **Identity mapping:** WP `X.Y` ↔ Gutenberg `wp/X.Y`. |
+| Version-branch PR body | Commit list via the user's `git log --reverse` + `sed` pipeline. |
+| Trunk PR body | The release notes (`body`). |
+| Existing open PR | **Update in place** the open PR that has the `Gutenberg Sync` label and the same base branch — even if a contributor opened it first. Otherwise create a new draft PR. |
+| Auth identity | Reuse the existing GitHub App from `commit-built-file-changes.yml` (`vars.GH_PR_BUILT_FILES_APP_ID` + `secrets.GH_PR_BUILT_FILES_PRIVATE_KEY`, committing as `wordpress-develop-pr-bot[bot]`). |
+| Structure | **One workflow file**, logic inline in `github-script`/bash steps, sharing values across jobs via outputs and `needs`. May be refactored to a composite action later. |
+| Trunk cadence | **Daily** (same cron as version branches) for now. |
+
+## Architecture
+
+A single workflow file: `.github/workflows/gutenberg-sync.yml`.
+
+### Triggers
+
+```yaml
+on:
+ schedule:
+ - cron: '0 6 * * *' # daily — drives version-branch updates and the trunk release poll
+ workflow_dispatch:
+ inputs:
+ target:
+ type: choice
+ options: [both, trunk, version-branch]
+ default: both
+ dry_run:
+ type: boolean
+ default: false # log intended actions; do not push or open/update PRs
+ # Future seam: repository_dispatch (type: gutenberg-release) to make trunk near-instant.
+```
+
+### Top-level configuration
+
+- Every job guarded with `if: ${{ github.repository == 'WordPress/wordpress-develop' }}`.
+- Top-level `permissions: {}`. Jobs request only `contents: read` for
+ `GITHUB_TOKEN`. **All writes** (push, PR create/update, labels) use the **app
+ installation token** so that opened/updated PRs trigger downstream CI.
+- A `concurrency` group keyed on the workflow name prevents overlapping
+ daily/manual runs.
+
+### Jobs
+
+#### `setup` (shared, non-secret outputs)
+
+Computes values both downstream jobs need and exposes them as job outputs:
+
+- `latest_version_branch` — list this repo's branches, filter to `^[0-9]+\.[0-9]+$`,
+ sort by semantic version descending, take the top entry (e.g. `7.0`).
+- `wp_branch_exists` — whether `wp/` exists in `WordPress/gutenberg`.
+
+Each downstream job mints its **own** app token rather than passing a token
+through a job output (which would expose an unmasked credential).
+
+#### `update-trunk`
+
+1. Mint an app installation token (reuse the Python JWT → installation-token
+ block from `commit-built-file-changes.yml`).
+2. `GET /repos/WordPress/gutenberg/releases/latest` — this endpoint already
+ excludes drafts **and** prereleases — yielding `tag_name`, `body`, `html_url`.
+3. Resolve `tag_name` → commit SHA (dereference annotated tags via the git refs API).
+4. Read the current `gutenberg.sha` from `package.json` on `trunk`. If it equals
+ the resolved SHA → clean no-op.
+5. Changelog = the release `body`, with a header link to `html_url`.
+6. Hand off to the shared find-or-update-PR routine with `base = trunk`.
+
+#### `update-version-branch`
+
+1. Mint an app installation token.
+2. Read `latest_version_branch` and `wp_branch_exists` from `setup`. If
+ `wp/` does not exist yet → clean no-op (expected early in a release cycle).
+3. `GET /repos/WordPress/gutenberg/commits/wp/` → head commit SHA.
+4. Read the current `gutenberg.sha` from `package.json` on the `X.Y` branch. If
+ equal → clean no-op.
+5. Changelog = the commit-list pipeline (see below), prefixed with an
+ `OLD...NEW` compare link.
+6. Hand off to the shared find-or-update-PR routine with `base = X.Y`.
+
+### Changelog generation
+
+For version branches, treeless-clone Gutenberg so the full commit graph is cheap,
+then run the user's established pipeline (without `pbcopy`):
+
+```bash
+git clone --filter=tree:0 https://github.com/WordPress/gutenberg.git
+git -C gutenberg log --reverse --format="- %s" OLD...NEW \
+ | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d'
+```
+
+This keeps only commits that reference a Gutenberg PR and linkifies `#N`
+references. The captured output becomes the PR body, prefixed with a
+`https://github.com/WordPress/gutenberg/compare/OLD...NEW` link.
+
+### Shared find-or-update labeled draft PR
+
+Inputs: base branch, new SHA, title, body.
+
+1. Search for an **open PR** with label `Gutenberg Sync` **and** matching base
+ branch (catches PRs a contributor opened first).
+2. **If found:**
+ - Head branch in this repo → check it out, `npm pkg set gutenberg.sha=`,
+ commit as `wordpress-develop-pr-bot[bot]`, push a commit on top (no
+ force-push), then refresh the PR title/body.
+ - Head branch on a fork (an installation token cannot push to forks) → update
+ the PR body and ensure the label/draft state via the API, and note in the
+ body that the SHA could not be pushed automatically. *(Accepted fallback.)*
+3. **If not found:** create head branch `gutenberg-sync/`,
+ `npm pkg set gutenberg.sha=`, commit, push, and open a **draft** PR
+ (base = target) with the `Gutenberg Sync` label.
+4. `dry_run` short-circuits before any push or PR write, logging the intended action.
+
+The `gutenberg.sha` value is edited with `npm pkg set gutenberg.sha=`.
+
+## Error handling & edge cases
+
+- No new release / `wp/X.Y` missing / SHA unchanged → clean no-op with a log line,
+ not a failed run.
+- `/releases/latest` inherently filters prereleases and drafts; no manual tag parsing.
+- Forked-PR fallback as described above (update metadata, cannot push the commit).
+- Commit message: `Build/Test Tools: Update the bundled Gutenberg commit reference.`
+
+## Testing
+
+- `actionlint` via the existing `workflow-lint.yml`.
+- Manual `workflow_dispatch` runs with `dry_run: true` to verify branch
+ detection, SHA resolution, and changelog generation without real PR side-effects.
+
+## Out of scope (for now)
+
+- The `repository_dispatch` trigger that would make `trunk` near-instant (a seam
+ is left, but it is not wired).
+- Updating more than one version branch (`N > 1`).
+- Refactoring shared logic into a composite action (may follow if the inline
+ approach grows unwieldy).
diff --git a/tests/phpunit/tests/actions.php b/tests/phpunit/tests/actions.php
index 6e7f292b6b71b..6833ad48b48bc 100644
--- a/tests/phpunit/tests/actions.php
+++ b/tests/phpunit/tests/actions.php
@@ -266,14 +266,36 @@ public function test_priority_callback_order( $priorities, $expected_call_order,
$mock = new MockAction();
$hook_name = __FUNCTION__;
- if ( $expected_deprecation && PHP_VERSION_ID >= 80100 ) {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( $expected_deprecation );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ $expect_warning = $expected_deprecation && PHP_VERSION_ID >= 80100;
+ if ( $expect_warning ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
}
- add_action( $hook_name, array( $mock, 'action' ), $priorities[0] );
- add_action( $hook_name, array( $mock, 'action2' ), $priorities[1] );
- do_action( $hook_name );
+ try {
+ // The deprecation notice can be triggered by add_action() coercing a non-integer priority.
+ add_action( $hook_name, array( $mock, 'action' ), $priorities[0] );
+ add_action( $hook_name, array( $mock, 'action2' ), $priorities[1] );
+ do_action( $hook_name );
+ } finally {
+ if ( $expect_warning ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( $expect_warning ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertStringContainsString( $expected_deprecation, $deprecation['errstr'] );
+ }
+ }
$this->assertSame( 2, $mock->get_call_count(), 'The number of call counts does not match' );
diff --git a/tests/phpunit/tests/admin/includesPost.php b/tests/phpunit/tests/admin/includesPost.php
index d9d39d8da727d..0c8c2d4a3ff0e 100644
--- a/tests/phpunit/tests/admin/includesPost.php
+++ b/tests/phpunit/tests/admin/includesPost.php
@@ -1169,16 +1169,6 @@ public function test_add_meta_allows_empty_values() {
* @ticket 37406
*/
public function test_post_exists_should_support_post_type() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'page';
$post_id = self::factory()->post->create(
@@ -1187,7 +1177,39 @@ public function test_post_exists_should_support_post_type() {
'post_type' => $post_type,
)
);
- $this->assertSame( $post_id, post_exists( $title, null, null, $post_type ) );
+
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( $post_id, post_exists( $title, null, null, $post_type ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
@@ -1196,16 +1218,6 @@ public function test_post_exists_should_support_post_type() {
* @ticket 37406
*/
public function test_post_exists_should_not_match_a_page_for_post() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'page';
$post_id = self::factory()->post->create(
@@ -1214,7 +1226,39 @@ public function test_post_exists_should_not_match_a_page_for_post() {
'post_type' => $post_type,
)
);
- $this->assertSame( 0, post_exists( $title, null, null, 'post' ) );
+
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( 0, post_exists( $title, null, null, 'post' ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
@@ -1223,16 +1267,6 @@ public function test_post_exists_should_not_match_a_page_for_post() {
* @ticket 34012
*/
public function test_post_exists_should_support_post_status() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'post';
$post_status = 'publish';
@@ -1243,7 +1277,39 @@ public function test_post_exists_should_support_post_status() {
'post_status' => $post_status,
)
);
- $this->assertSame( $post_id, post_exists( $title, null, null, null, $post_status ) );
+
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( $post_id, post_exists( $title, null, null, null, $post_status ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
@@ -1253,16 +1319,6 @@ public function test_post_exists_should_support_post_status() {
* @ticket 34012
*/
public function test_post_exists_should_support_post_type_status_combined() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'post';
$post_status = 'publish';
@@ -1273,7 +1329,39 @@ public function test_post_exists_should_support_post_type_status_combined() {
'post_status' => $post_status,
)
);
- $this->assertSame( $post_id, post_exists( $title, null, null, $post_type, $post_status ) );
+
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( $post_id, post_exists( $title, null, null, $post_type, $post_status ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
@@ -1282,16 +1370,6 @@ public function test_post_exists_should_support_post_type_status_combined() {
* @ticket 34012
*/
public function test_post_exists_should_only_match_correct_post_status() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'post';
$post_status = 'draft';
@@ -1302,7 +1380,39 @@ public function test_post_exists_should_only_match_correct_post_status() {
'post_status' => $post_status,
)
);
- $this->assertSame( 0, post_exists( $title, null, null, null, 'publish' ) );
+
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( 0, post_exists( $title, null, null, null, 'publish' ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
@@ -1311,16 +1421,6 @@ public function test_post_exists_should_only_match_correct_post_status() {
* @ticket 34012
*/
public function test_post_exists_should_not_match_invalid_post_type_and_status_combined() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$title = 'Foo Bar';
$post_type = 'post';
$post_status = 'publish';
@@ -1332,8 +1432,39 @@ public function test_post_exists_should_not_match_invalid_post_type_and_status_c
)
);
- $this->assertSame( 0, post_exists( $title, null, null, $post_type, 'draft' ) );
- $this->assertSame( 0, post_exists( $title, null, null, 'wp_tests', $post_status ) );
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( 0, post_exists( $title, null, null, $post_type, 'draft' ) );
+ $this->assertSame( 0, post_exists( $title, null, null, 'wp_tests', $post_status ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
diff --git a/tests/phpunit/tests/admin/wpListTable.php b/tests/phpunit/tests/admin/wpListTable.php
index b3de66659b308..9a61503c85b2e 100644
--- a/tests/phpunit/tests/admin/wpListTable.php
+++ b/tests/phpunit/tests/admin/wpListTable.php
@@ -395,13 +395,29 @@ public function test_should_get_compat_fields( $property_name, $expected ) {
* @covers WP_List_Table::__get()
*/
public function test_should_throw_deprecation_when_getting_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertNull( $this->list_table->undeclared_property, 'Getting a dynamic property should return null from WP_List_Table::__get()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_List_Table::__get(): ' .
'The property `undeclared_property` is not declared. Getting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertNull( $this->list_table->undeclared_property, 'Getting a dynamic property should return null from WP_List_Table::__get()' );
}
/**
@@ -425,13 +441,29 @@ public function test_should_set_compat_fields_defined_property( $property_name )
* @covers WP_List_Table::__set()
*/
public function test_should_throw_deprecation_when_setting_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->list_table->undeclared_property = 'some value';
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_List_Table::__set(): ' .
'The property `undeclared_property` is not declared. Setting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->list_table->undeclared_property = 'some value';
}
/**
@@ -458,13 +490,29 @@ public function test_should_isset_compat_fields( $property_name, $expected ) {
* @covers WP_List_Table::__isset()
*/
public function test_should_throw_deprecation_when_isset_of_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertFalse( isset( $this->list_table->undeclared_property ), 'Checking a dynamic property should return false from WP_List_Table::__isset()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_List_Table::__isset(): ' .
'The property `undeclared_property` is not declared. Checking `isset()` on a dynamic property ' .
- 'is deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'is deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertFalse( isset( $this->list_table->undeclared_property ), 'Checking a dynamic property should return false from WP_List_Table::__isset()' );
}
/**
@@ -486,13 +534,29 @@ public function test_should_unset_compat_fields_defined_property( $property_name
* @covers WP_List_Table::__unset()
*/
public function test_should_throw_deprecation_when_unset_of_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ unset( $this->list_table->undeclared_property );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_List_Table::__unset(): ' .
'A property `undeclared_property` is not declared. Unsetting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- unset( $this->list_table->undeclared_property );
}
/**
diff --git a/tests/phpunit/tests/diff/wpTextDiffRendererTable.php b/tests/phpunit/tests/diff/wpTextDiffRendererTable.php
index 7b85a78582859..b70779ca88a16 100644
--- a/tests/phpunit/tests/diff/wpTextDiffRendererTable.php
+++ b/tests/phpunit/tests/diff/wpTextDiffRendererTable.php
@@ -41,13 +41,29 @@ public function test_should_get_compat_fields( $property_name, $expected ) {
* @covers WP_Text_Diff_Renderer_Table::__get()
*/
public function test_should_throw_deprecation_when_getting_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertNull( $this->diff_renderer_table->undeclared_property, 'Getting a dynamic property should return null from WP_Text_Diff_Renderer_Table::__get()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_Text_Diff_Renderer_Table::__get(): ' .
'The property `undeclared_property` is not declared. Getting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertNull( $this->diff_renderer_table->undeclared_property, 'Getting a dynamic property should return null from WP_Text_Diff_Renderer_Table::__get()' );
}
/**
@@ -71,13 +87,29 @@ public function test_should_set_compat_fields( $property_name ) {
* @covers WP_Text_Diff_Renderer_Table::__set()
*/
public function test_should_throw_deprecation_when_setting_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->diff_renderer_table->undeclared_property = 'some value';
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_Text_Diff_Renderer_Table::__set(): ' .
'The property `undeclared_property` is not declared. Setting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->diff_renderer_table->undeclared_property = 'some value';
}
/**
@@ -104,13 +136,29 @@ public function test_should_isset_compat_fields( $property_name, $expected ) {
* @covers WP_Text_Diff_Renderer_Table::__isset()
*/
public function test_should_throw_deprecation_when_isset_of_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertFalse( isset( $this->diff_renderer_table->undeclared_property ), 'Checking a dynamic property should return false from WP_Text_Diff_Renderer_Table::__isset()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_Text_Diff_Renderer_Table::__isset(): ' .
'The property `undeclared_property` is not declared. Checking `isset()` on a dynamic property ' .
- 'is deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'is deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertFalse( isset( $this->diff_renderer_table->undeclared_property ), 'Checking a dynamic property should return false from WP_Text_Diff_Renderer_Table::__isset()' );
}
/**
@@ -132,13 +180,29 @@ public function test_should_unset_compat_fields( $property_name ) {
* @covers WP_Text_Diff_Renderer_Table::__unset()
*/
public function test_should_throw_deprecation_when_unset_of_dynamic_property() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ unset( $this->diff_renderer_table->undeclared_property );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_Text_Diff_Renderer_Table::__unset(): ' .
'A property `undeclared_property` is not declared. Unsetting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- unset( $this->diff_renderer_table->undeclared_property );
}
/**
diff --git a/tests/phpunit/tests/filesystem/wpOpcacheInvalidateDirectory.php b/tests/phpunit/tests/filesystem/wpOpcacheInvalidateDirectory.php
index a297c15e99940..e2f9aef13942b 100644
--- a/tests/phpunit/tests/filesystem/wpOpcacheInvalidateDirectory.php
+++ b/tests/phpunit/tests/filesystem/wpOpcacheInvalidateDirectory.php
@@ -35,13 +35,25 @@ public static function set_up_before_class() {
* @param mixed $dir An invalid directory path.
*/
public function test_should_trigger_error_with_invalid_dir( $dir ) {
- $this->expectError();
- $this->expectErrorMessage(
- 'wp_opcache_invalidate_directory() expects a non-empty string.',
- 'The expected error was not triggered.'
+ // Note: $this->expectError() is deprecated and will be removed in PHPUnit 10.
+ $errors = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$errors ) {
+ $errors[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_NOTICE
);
wp_opcache_invalidate_directory( $dir );
+
+ restore_error_handler();
+
+ $this->assertCount( 1, $errors, 'The expected error was not triggered.' );
+ $this->assertSame(
+ 'wp_opcache_invalidate_directory() expects a non-empty string.',
+ $errors[0]['errstr']
+ );
}
/**
diff --git a/tests/phpunit/tests/filters.php b/tests/phpunit/tests/filters.php
index 8ce599c388f29..3e40e374afe87 100644
--- a/tests/phpunit/tests/filters.php
+++ b/tests/phpunit/tests/filters.php
@@ -161,14 +161,36 @@ public function test_priority_callback_order( $priorities, $expected_call_order,
$mock = new MockAction();
$hook_name = __FUNCTION__;
- if ( $expected_deprecation && PHP_VERSION_ID >= 80100 ) {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( $expected_deprecation );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ $expect_warning = $expected_deprecation && PHP_VERSION_ID >= 80100;
+ if ( $expect_warning ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
}
- add_filter( $hook_name, array( $mock, 'filter' ), $priorities[0] );
- add_filter( $hook_name, array( $mock, 'filter2' ), $priorities[1] );
- apply_filters( $hook_name, __FUNCTION__ . '_val' );
+ try {
+ // The deprecation notice can be triggered by add_filter() coercing a non-integer priority.
+ add_filter( $hook_name, array( $mock, 'filter' ), $priorities[0] );
+ add_filter( $hook_name, array( $mock, 'filter2' ), $priorities[1] );
+ apply_filters( $hook_name, __FUNCTION__ . '_val' );
+ } finally {
+ if ( $expect_warning ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( $expect_warning ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertStringContainsString( $expected_deprecation, $deprecation['errstr'] );
+ }
+ }
$this->assertSame( 2, $mock->get_call_count(), 'The number of call counts does not match' );
diff --git a/tests/phpunit/tests/formatting/wpStripAllTags.php b/tests/phpunit/tests/formatting/wpStripAllTags.php
index 6afcbed79fc48..740602e1fdf96 100644
--- a/tests/phpunit/tests/formatting/wpStripAllTags.php
+++ b/tests/phpunit/tests/formatting/wpStripAllTags.php
@@ -53,9 +53,26 @@ public function test_wp_strip_all_tags_should_return_empty_string_for_a_null_arg
*/
public function test_wp_strip_all_tags_should_return_empty_string_and_trigger_an_error_for_non_string_arg( $non_string ) {
$type = gettype( $non_string );
- $this->expectError();
- $this->expectErrorMessage( "Warning: wp_strip_all_tags expects parameter #1 (\$text) to be a string, $type given." );
+
+ // Note: $this->expectError() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_WARNING
+ );
+
$this->assertSame( '', wp_strip_all_tags( $non_string ) );
+
+ restore_error_handler();
+
+ $this->assertCount( 1, $warnings, 'Expected one warning.' );
+ $this->assertSame(
+ "Warning: wp_strip_all_tags expects parameter #1 (\$text) to be a string, $type given.",
+ $warnings[0]['errstr']
+ );
}
/**
diff --git a/tests/phpunit/tests/formatting/wpTrimExcerpt.php b/tests/phpunit/tests/formatting/wpTrimExcerpt.php
index 688ba7731f1ce..be42ad474ea1a 100644
--- a/tests/phpunit/tests/formatting/wpTrimExcerpt.php
+++ b/tests/phpunit/tests/formatting/wpTrimExcerpt.php
@@ -74,25 +74,46 @@ public function test_secondary_loop_respect_nextpage() {
* @ticket 51042
*/
public function test_should_generate_excerpt_for_empty_values() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
$post = self::factory()->post->create(
array(
'post_content' => 'Post content',
)
);
- $this->assertSame( 'Post content', wp_trim_excerpt( '', $post ) );
- $this->assertSame( 'Post content', wp_trim_excerpt( null, $post ) );
- $this->assertSame( 'Post content', wp_trim_excerpt( false, $post ) );
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $this->assertSame( 'Post content', wp_trim_excerpt( '', $post ) );
+ $this->assertSame( 'Post content', wp_trim_excerpt( null, $post ) );
+ $this->assertSame( 'Post content', wp_trim_excerpt( false, $post ) );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
}
/**
diff --git a/tests/phpunit/tests/functions/cleanDirsizeCache.php b/tests/phpunit/tests/functions/cleanDirsizeCache.php
index c30517c115277..ada542e752bd7 100644
--- a/tests/phpunit/tests/functions/cleanDirsizeCache.php
+++ b/tests/phpunit/tests/functions/cleanDirsizeCache.php
@@ -20,10 +20,22 @@ class Tests_Functions_CleanDirsizeCache extends WP_UnitTestCase {
* @param string $expected_message Expected notice message.
*/
public function test_clean_dirsize_cache_with_invalid_inputs( $path, $expected_message ) {
- $this->expectNotice();
- $this->expectNoticeMessage( $expected_message );
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ $notices = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_NOTICE
+ );
clean_dirsize_cache( $path );
+
+ restore_error_handler();
+
+ $this->assertCount( 1, $notices, 'Expected one notice.' );
+ $this->assertSame( $expected_message, $notices[0]['errstr'] );
}
/**
diff --git a/tests/phpunit/tests/functions/wpTriggerError.php b/tests/phpunit/tests/functions/wpTriggerError.php
index b642b7b08f6ae..a642565c2527f 100644
--- a/tests/phpunit/tests/functions/wpTriggerError.php
+++ b/tests/phpunit/tests/functions/wpTriggerError.php
@@ -37,10 +37,24 @@ public function test_should_throw_exception( $function_name, $message, $expected
* @param string $expected_message The expected error message.
*/
public function test_should_trigger_warning( $function_name, $message, $expected_message ) {
- $this->expectWarning();
- $this->expectWarningMessage( $expected_message );
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_WARNING
+ );
+
+ try {
+ wp_trigger_error( $function_name, $message, E_USER_WARNING );
+ } finally {
+ restore_error_handler();
+ }
- wp_trigger_error( $function_name, $message, E_USER_WARNING );
+ $this->assertCount( 1, $warnings, 'Expected one warning.' );
+ $this->assertSame( $expected_message, $warnings[0]['errstr'] );
}
/**
@@ -53,10 +67,22 @@ public function test_should_trigger_warning( $function_name, $message, $expected
* @param string $expected_message The expected error message.
*/
public function test_should_trigger_notice( $function_name, $message, $expected_message ) {
- $this->expectNotice();
- $this->expectNoticeMessage( $expected_message );
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ $notices = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_NOTICE
+ );
wp_trigger_error( $function_name, $message );
+
+ restore_error_handler();
+
+ $this->assertCount( 1, $notices, 'Expected one notice.' );
+ $this->assertSame( $expected_message, $notices[0]['errstr'] );
}
/**
@@ -69,10 +95,24 @@ public function test_should_trigger_notice( $function_name, $message, $expected_
* @param string $expected_message The expected error message.
*/
public function test_should_trigger_deprecation( $function_name, $message, $expected_message ) {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( $expected_message );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ wp_trigger_error( $function_name, $message, E_USER_DEPRECATED );
+ } finally {
+ restore_error_handler();
+ }
- wp_trigger_error( $function_name, $message, E_USER_DEPRECATED );
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString( $expected_message, $deprecations[0]['errstr'] );
}
/**
diff --git a/tests/phpunit/tests/functions/wpUniquePrefixedId.php b/tests/phpunit/tests/functions/wpUniquePrefixedId.php
index ed6e489a3e259..c60d2861e0e67 100644
--- a/tests/phpunit/tests/functions/wpUniquePrefixedId.php
+++ b/tests/phpunit/tests/functions/wpUniquePrefixedId.php
@@ -86,14 +86,28 @@ public function data_should_create_unique_prefixed_ids() {
* @param array $expected_ids Expected unique IDs.
*/
public function test_should_raise_notice_and_use_empty_string_prefix_when_nonstring_given( $non_string_prefix, $number_of_ids_to_generate, $expected_message, $expected_ids ) {
- $this->expectNotice();
- $this->expectNoticeMessage( $expected_message );
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ $notices = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_NOTICE
+ );
$ids = array();
for ( $i = 0; $i < $number_of_ids_to_generate; $i++ ) {
$ids[] = wp_unique_prefixed_id( $non_string_prefix );
}
+ restore_error_handler();
+
+ $this->assertCount( $number_of_ids_to_generate, $notices, 'Expected one notice per generated ID.' );
+ foreach ( $notices as $notice ) {
+ $this->assertSame( $expected_message, $notice['errstr'] );
+ }
+
$this->assertSameSets( $ids, array_unique( $ids ), 'IDs are not unique.' );
$this->assertSameSets( $expected_ids, $ids, 'The IDs did not match the expected values.' );
}
diff --git a/tests/phpunit/tests/hooks/applyFilters.php b/tests/phpunit/tests/hooks/applyFilters.php
index 50c35e3498a96..b40547f826f25 100644
--- a/tests/phpunit/tests/hooks/applyFilters.php
+++ b/tests/phpunit/tests/hooks/applyFilters.php
@@ -63,14 +63,36 @@ public function test_priority_callback_order( $priorities, $expected_call_order,
$hook = new WP_Hook();
$hook_name = __FUNCTION__;
- if ( $expected_deprecation && PHP_VERSION_ID >= 80100 ) {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( $expected_deprecation );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ $expect_warning = $expected_deprecation && PHP_VERSION_ID >= 80100;
+ if ( $expect_warning ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
}
- $hook->add_filter( $hook_name, array( $mock, 'filter' ), $priorities[0], 1 );
- $hook->add_filter( $hook_name, array( $mock, 'filter2' ), $priorities[1], 1 );
- $hook->apply_filters( __FUNCTION__ . '_val', array( '' ) );
+ try {
+ // The deprecation notice can be triggered by add_filter() coercing a non-integer priority.
+ $hook->add_filter( $hook_name, array( $mock, 'filter' ), $priorities[0], 1 );
+ $hook->add_filter( $hook_name, array( $mock, 'filter2' ), $priorities[1], 1 );
+ $hook->apply_filters( __FUNCTION__ . '_val', array( '' ) );
+ } finally {
+ if ( $expect_warning ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( $expect_warning ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertStringContainsString( $expected_deprecation, $deprecation['errstr'] );
+ }
+ }
$this->assertSame( 2, $mock->get_call_count(), 'The number of call counts does not match' );
diff --git a/tests/phpunit/tests/hooks/doAction.php b/tests/phpunit/tests/hooks/doAction.php
index c9767f865df26..57346478af803 100644
--- a/tests/phpunit/tests/hooks/doAction.php
+++ b/tests/phpunit/tests/hooks/doAction.php
@@ -105,14 +105,36 @@ public function test_priority_callback_order( $priorities, $expected_call_order,
$hook = new WP_Hook();
$hook_name = __FUNCTION__;
- if ( $expected_deprecation && PHP_VERSION_ID >= 80100 ) {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( $expected_deprecation );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ $expect_warning = $expected_deprecation && PHP_VERSION_ID >= 80100;
+ if ( $expect_warning ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
}
- $hook->add_filter( $hook_name, array( $mock, 'action' ), $priorities[0], 1 );
- $hook->add_filter( $hook_name, array( $mock, 'action2' ), $priorities[1], 1 );
- $hook->do_action( array( '' ) );
+ try {
+ // The deprecation notice can be triggered by add_filter() coercing a non-integer priority.
+ $hook->add_filter( $hook_name, array( $mock, 'action' ), $priorities[0], 1 );
+ $hook->add_filter( $hook_name, array( $mock, 'action2' ), $priorities[1], 1 );
+ $hook->do_action( array( '' ) );
+ } finally {
+ if ( $expect_warning ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( $expect_warning ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertStringContainsString( $expected_deprecation, $deprecation['errstr'] );
+ }
+ }
$this->assertSame( 2, $mock->get_call_count(), 'The number of call counts does not match' );
diff --git a/tests/phpunit/tests/http/includeOldRequestsClass.php b/tests/phpunit/tests/http/includeOldRequestsClass.php
index 3be2173b6c566..57171a173e837 100644
--- a/tests/phpunit/tests/http/includeOldRequestsClass.php
+++ b/tests/phpunit/tests/http/includeOldRequestsClass.php
@@ -14,9 +14,24 @@ class Tests_HTTP_IncludeOldRequestsClass extends WP_UnitTestCase {
* @coversNothing
*/
public function test_should_include_old_requests_class() {
- $this->expectDeprecation();
- $this->expectDeprecationMessage( 'The PSR-0 `Requests_...` class names in the Requests library are deprecated.' );
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
- new Requests();
+ try {
+ // Referencing the class name triggers the autoloader, which is what raises the deprecation notice.
+ class_exists( 'Requests' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString( 'The PSR-0 `Requests_...` class names in the Requests library are deprecated.', $deprecations[0]['errstr'] );
}
}
diff --git a/tests/phpunit/tests/locale.php b/tests/phpunit/tests/locale.php
index b672d08121fac..35ce914fc46a2 100644
--- a/tests/phpunit/tests/locale.php
+++ b/tests/phpunit/tests/locale.php
@@ -65,13 +65,40 @@ public function test_get_weekday() {
* @covers WP_Locale::get_weekday
*/
public function test_get_weekday_undefined_index() {
+ $warnings = array();
+ $notices = array();
+
if ( PHP_VERSION_ID >= 80000 ) {
- $this->expectWarning();
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_WARNING
+ );
} else {
- $this->expectNotice();
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_NOTICE
+ );
+ }
+
+ try {
+ $this->locale->get_weekday( 7 );
+ } finally {
+ restore_error_handler();
}
- $this->locale->get_weekday( 7 );
+ if ( PHP_VERSION_ID >= 80000 ) {
+ $this->assertCount( 1, $warnings, 'Expected one warning.' );
+ } else {
+ $this->assertCount( 1, $notices, 'Expected one notice.' );
+ }
}
/**
diff --git a/tests/phpunit/tests/option/wpUserSettings.php b/tests/phpunit/tests/option/wpUserSettings.php
index 44cffc730ec49..ffb438b38099d 100644
--- a/tests/phpunit/tests/option/wpUserSettings.php
+++ b/tests/phpunit/tests/option/wpUserSettings.php
@@ -29,8 +29,23 @@ public function test_wp_user_settings_does_not_throw_deprecation_notice_for_setc
$this->assertGreaterThan( 0, get_current_user_id() );
// `Cannot modify header information - headers already sent by...` from setcookie().
- $this->expectWarning();
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_WARNING
+ );
- wp_user_settings();
+ try {
+ wp_user_settings();
+ } finally {
+ restore_error_handler();
+ }
+
+ // wp_user_settings() calls setcookie() twice.
+ $this->assertCount( 2, $warnings, 'Expected two warnings.' );
}
}
diff --git a/tests/phpunit/tests/rest-api/rest-block-directory-controller.php b/tests/phpunit/tests/rest-api/rest-block-directory-controller.php
index 8dbe36309fa7a..cf44daea37054 100644
--- a/tests/phpunit/tests/rest-api/rest-block-directory-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-block-directory-controller.php
@@ -92,8 +92,23 @@ public function test_get_items_wdotorg_unavailable() {
$this->prevent_requests_to_host( 'api.wordpress.org' );
- $this->expectWarning();
- $response = rest_do_request( $request );
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_WARNING
+ );
+
+ try {
+ $response = rest_do_request( $request );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $warnings, 'Expected one warning.' );
$this->assertErrorResponse( 'plugins_api_failed', $response, 500 );
}
diff --git a/tests/phpunit/tests/rest-api/rest-plugins-controller.php b/tests/phpunit/tests/rest-api/rest-plugins-controller.php
index 1d4311a21f251..23073b7ad6902 100644
--- a/tests/phpunit/tests/rest-api/rest-plugins-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-plugins-controller.php
@@ -534,8 +534,23 @@ public function test_create_item_wdotorg_unreachable() {
$this->prevent_requests_to_host( 'api.wordpress.org' );
- $this->expectWarning();
- $response = rest_do_request( $request );
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_WARNING
+ );
+
+ try {
+ $response = rest_do_request( $request );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $warnings, 'Expected one warning.' );
$this->assertErrorResponse( 'plugins_api_failed', $response, 500 );
}
diff --git a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php
index 2cea494c064ea..3883ef02e0eba 100644
--- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php
+++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php
@@ -454,17 +454,52 @@ public function test_format_validation_is_skipped_if_non_string_type() {
* @ticket 50189
*/
public function test_format_validation_is_applied_if_missing_type() {
- if ( PHP_VERSION_ID >= 80000 ) {
- $this->expectWarning(); // For the undefined index.
- } else {
- $this->expectNotice(); // For the undefined index.
- }
-
$this->setExpectedIncorrectUsage( 'rest_sanitize_value_from_schema' );
$schema = array( 'format' => 'hex-color' );
- $this->assertSame( '#abc', rest_sanitize_value_from_schema( '#abc', $schema ) );
- $this->assertSame( '', rest_sanitize_value_from_schema( '#jkl', $schema ) );
+
+ if ( PHP_VERSION_ID >= 80000 ) {
+ // For the undefined index.
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_WARNING
+ );
+
+ try {
+ $this->assertSame( '#abc', rest_sanitize_value_from_schema( '#abc', $schema ) );
+ $this->assertSame( '', rest_sanitize_value_from_schema( '#jkl', $schema ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ // The undefined index is accessed multiple times per call to rest_sanitize_value_from_schema().
+ $this->assertCount( 16, $warnings, 'Expected sixteen warnings.' );
+ } else {
+ // For the undefined index.
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ $notices = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_NOTICE
+ );
+
+ try {
+ $this->assertSame( '#abc', rest_sanitize_value_from_schema( '#abc', $schema ) );
+ $this->assertSame( '', rest_sanitize_value_from_schema( '#jkl', $schema ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertNotEmpty( $notices, 'Expected at least one notice for the undefined index.' );
+ }
}
/**
diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php
index f83c4817718c4..e9b3de9ba07bf 100644
--- a/tests/phpunit/tests/rest-api/rest-schema-validation.php
+++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php
@@ -154,17 +154,52 @@ public function test_format_validation_is_skipped_if_non_string_type() {
* @ticket 50189
*/
public function test_format_validation_is_applied_if_missing_type() {
- if ( PHP_VERSION_ID >= 80000 ) {
- $this->expectWarning(); // For the undefined index.
- } else {
- $this->expectNotice(); // For the undefined index.
- }
-
$this->setExpectedIncorrectUsage( 'rest_validate_value_from_schema' );
$schema = array( 'format' => 'email' );
- $this->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) );
- $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) );
+
+ if ( PHP_VERSION_ID >= 80000 ) {
+ // For the undefined index.
+ // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10.
+ $warnings = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ) {
+ $warnings[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_WARNING
+ );
+
+ try {
+ $this->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) );
+ $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ // The undefined index is accessed multiple times per call to rest_validate_value_from_schema().
+ $this->assertCount( 6, $warnings, 'Expected six warnings.' );
+ } else {
+ // For the undefined index.
+ // Note: $this->expectNotice() is deprecated and will be removed in PHPUnit 10.
+ $notices = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$notices ) {
+ $notices[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_NOTICE
+ );
+
+ try {
+ $this->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) );
+ $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertNotEmpty( $notices, 'Expected at least one notice for the undefined index.' );
+ }
}
/**
diff --git a/tests/phpunit/tests/term/wpGenerateTagCloud.php b/tests/phpunit/tests/term/wpGenerateTagCloud.php
index 4b7a27fb475e9..06f4426de8003 100644
--- a/tests/phpunit/tests/term/wpGenerateTagCloud.php
+++ b/tests/phpunit/tests/term/wpGenerateTagCloud.php
@@ -267,16 +267,6 @@ public function test_topic_count_text_callback() {
* @ticket 5172
*/
public function test_should_include_tag_link_position_class() {
- if ( PHP_VERSION_ID >= 80100 ) {
- /*
- * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
- * via hooked in filter functions until a more structural solution to the
- * "missing input validation" conundrum has been architected and implemented.
- */
- $this->expectDeprecation();
- $this->expectDeprecationMessageMatches( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`' );
- }
-
register_taxonomy( 'wptests_tax', 'post' );
$term_ids = self::factory()->term->create_many( 3, array( 'taxonomy' => 'wptests_tax' ) );
@@ -290,7 +280,39 @@ public function test_should_include_tag_link_position_class() {
)
);
- $cloud = wp_generate_tag_cloud( $term_objects );
+ /*
+ * For the time being, ignoring PHP 8.1 "null to non-nullable" deprecations coming in
+ * via hooked in filter functions until a more structural solution to the
+ * "missing input validation" conundrum has been architected and implemented.
+ *
+ * Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ */
+ $deprecations = array();
+ if ( PHP_VERSION_ID >= 80100 ) {
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_DEPRECATED
+ );
+ }
+
+ try {
+ $cloud = wp_generate_tag_cloud( $term_objects );
+ } finally {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ restore_error_handler();
+ }
+ }
+
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->assertNotEmpty( $deprecations, 'Expected at least one deprecation notice.' );
+ foreach ( $deprecations as $deprecation ) {
+ $this->assertMatchesRegularExpression( '`Passing null to parameter \#[0-9]+ \(\$[^\)]+\) of type [^ ]+ is deprecated`', $deprecation['errstr'] );
+ }
+ }
+
preg_match_all( '|tag\-link\-position-([0-9]+)|', $cloud, $matches );
$this->assertSame( array( 1, 2, 3 ), array_map( 'intval', $matches[1] ) );
diff --git a/tests/phpunit/tests/user/query.php b/tests/phpunit/tests/user/query.php
index 5978f4bf55e58..e661b13e3107f 100644
--- a/tests/phpunit/tests/user/query.php
+++ b/tests/phpunit/tests/user/query.php
@@ -2256,13 +2256,29 @@ public function test_should_get_compat_fields( $property_name, $expected ) {
public function test_should_throw_deprecation_when_getting_dynamic_property() {
$user_query = new WP_User_Query();
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertNull( $user_query->undefined_property, 'Getting a dynamic property should return null from WP_User_Query::__get()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_User_Query::__get(): ' .
'The property `undefined_property` is not declared. Getting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertNull( $user_query->undefined_property, 'Getting a dynamic property should return null from WP_User_Query::__get()' );
}
/**
@@ -2289,13 +2305,29 @@ public function test_should_set_compat_fields( $property_name ) {
public function test_should_throw_deprecation_when_setting_dynamic_property() {
$user_query = new WP_User_Query();
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $user_query->undefined_property = 'some value';
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_User_Query::__set(): ' .
'The property `undefined_property` is not declared. Setting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $user_query->undefined_property = 'some value';
}
/**
@@ -2326,13 +2358,29 @@ public function test_should_isset_compat_fields( $property_name, $expected ) {
public function test_should_throw_deprecation_when_isset_of_dynamic_property() {
$user_query = new WP_User_Query();
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ $this->assertFalse( isset( $user_query->undefined_property ), 'Checking a dynamic property should return false from WP_User_Query::__isset()' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_User_Query::__isset(): ' .
'The property `undefined_property` is not declared. Checking `isset()` on a dynamic property ' .
- 'is deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'is deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- $this->assertFalse( isset( $user_query->undefined_property ), 'Checking a dynamic property should return false from WP_User_Query::__isset()' );
}
/**
@@ -2358,13 +2406,29 @@ public function test_should_unset_compat_fields( $property_name ) {
public function test_should_throw_deprecation_when_unset_of_dynamic_property() {
$user_query = new WP_User_Query();
- $this->expectDeprecation();
- $this->expectDeprecationMessage(
+ // Note: $this->expectDeprecation() is deprecated and will be removed in PHPUnit 10.
+ $deprecations = array();
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$deprecations ) {
+ $deprecations[] = compact( 'errno', 'errstr' );
+ return true;
+ },
+ E_USER_DEPRECATED
+ );
+
+ try {
+ unset( $user_query->undefined_property );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertCount( 1, $deprecations, 'Expected one deprecation notice.' );
+ $this->assertStringContainsString(
'WP_User_Query::__unset(): ' .
'A property `undefined_property` is not declared. Unsetting a dynamic property is ' .
- 'deprecated since version 6.4.0! Instead, declare the property on the class.'
+ 'deprecated since version 6.4.0! Instead, declare the property on the class.',
+ $deprecations[0]['errstr']
);
- unset( $user_query->undefined_property );
}
/**