From 3e83f34483ee267dac4a37842ef8621b9303ef13 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:35:58 -0400 Subject: [PATCH 01/38] Reintroduce a Dependabot configuration file. --- .github/dependabot.yml | 213 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..24e2573546f53 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,213 @@ +# Configure Dependabot scanning. +version: 2 + +updates: + # Check for updates to GitHub Actions. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" + + # Check for updates to Composer packages. + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + ignore: + # These dependencies do not currently need to be managed with Dependabot. + - dependency-name: "squizlabs/php_codesniffer" + - dependency-name: "wp-coding-standards/wpcs" + - dependency-name: "phpcompatibility/php-compatibility" + - dependency-name: "yoast/phpunit-polyfills" + groups: + composer-packages: + patterns: + - "composer/ca-bundle" + + # Monitor some npm dependencies for updates in groups. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 20 + ignore: + - dependency-name: "@wordpress/*" + groups: + ## + # Groups for updating devDependencies. + ## + + # Dependencies related to Playwright testing (E2E, performance). + tests-playwright: + patterns: + - "*playwright*" + # Dependencies related to JavaScript testing with QUnit. + tests-qunit: + patterns: + - "*qunit*" + - "sinon*" + # Dependencies related to CSS and SASS building and manilupating. + dev-css-sass: + patterns: + - "autoprefixer" + # postcss and css related dependencies. + - "*css*" + - "*sass" + # Dependencies related to the Webpack build process. + dev-webpack: + patterns: + - "*webpack*" + - "react-refresh" + - "source-map-loader" + # Dependencies related to the local Docker development environment. + dev-docker: + patterns: + - "dotenv*" + - "wait-on" + # Dependencies that do not fall into a specific grouping. + dev-miscellaneous: + patterns: + - "chalk" + - "check-node-version" + - "ink-docstrap" + - "install-changed" + - "matchdep" + - "uuid" + # Dependencies related to JavaScript minification. + dev-uglify: + patterns: + - "*uglify*" + # All GruntJS related dependencies that do not relate to another group. + dev-grunt: + patterns: + - "*grunt*" + + ## + # Groups for updating production dependencies. + ## + + # Dependencies related to jQuery and its ecosystem. + external-jquery: + patterns: + - "jquery*" + # Dependencies related to React and its ecosystem. + external-react: + patterns: + - "react*" + - "!react-refresh" + # Dependencies used for bundling polyfill libraries into WordPress. + external-polyfills: + patterns: + - "core-js-url-browser" + - "element-closest" + - "formdata-polyfill" + - "imagesloaded" + - "objectFitPolyfill" + - "polyfill-library" + - "regenerator-runtime" + - "whatwg-fetch" + - "wicg-inert" + # Dependencies related to the Masonry library. + external-masonry: + patterns: + - "masonry-layout" + # Dependencies that do not fall into a specific grouping. + external-miscellaneous: + patterns: + - "backbone" + - "clipboard" + - "hoverintent" + - "json2php" + - "lodash" + - "moment" + - "underscore" + + # Monitor npm dependencies within default themes. + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentyfive" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentyfive-css: + patterns: + - "**browserslist*" + - "*css*" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentytwo" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentytwo-css: + patterns: + - "**browserslist*" + - "*css*" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwentyone" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwentyone-sass-css: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + - "*sass*" + - "!*stylelint*" + twentytwentyone-eslint: + patterns: + - "**eslint*" + twentytwentyone-stylelint: + patterns: + - "**stylelint*" + twentytwentyone-miscellaneous: + patterns: + - "chokidar-cli" + - "minimist" + - "npm-run-all" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentytwenty" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentytwenty-css: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + twentytwenty-stylelint: + patterns: + - "*stylelint*" + twentytwenty-miscellaneous: + patterns: + - "concurrently" + - "@wordpress/scripts" + + - package-ecosystem: "npm" + directory: "/src/wp-content/themes/twentynineteen" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + groups: + twentynineteen-css-sass: + patterns: + - "**browserslist*" + - "autoprefixer" + - "*css*" + - "*sass*" + twentynineteen-miscellaneous: + patterns: + - "chokidar-cli" + - "npm-run-all" From 9d31fde3a507de01e91a867bbe5d879b2cd479ac Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:41:49 -0400 Subject: [PATCH 02/38] Add a workflow to create backport PRs. --- .github/workflows/backport.yml | 369 +++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 .github/workflows/backport.yml 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 From 529b7765a4f337201e170f358a3b15c67c6da73d Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:32:23 -0400 Subject: [PATCH 03/38] Add workflow for testing backport PRs. --- .github/workflows/create-backport-prs.yml | 532 ++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 .github/workflows/create-backport-prs.yml 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 From 22fb5c605367530aeea952fdf7376c864434b08e Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 18 May 2026 08:53:45 -0400 Subject: [PATCH 04/38] Exclude major versions from Dependabot PRs. --- .github/dependabot.yml | 119 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 24e2573546f53..9d7df639e6421 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,18 +7,20 @@ updates: directory: "/" schedule: interval: "daily" - open-pull-requests-limit: 10 + open-pull-requests-limit: 50 groups: github-actions: - patterns: - - "*" + 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: 10 + open-pull-requests-limit: 50 ignore: # These dependencies do not currently need to be managed with Dependabot. - dependency-name: "squizlabs/php_codesniffer" @@ -35,7 +37,7 @@ updates: directory: "/" schedule: interval: "daily" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 ignore: - dependency-name: "@wordpress/*" groups: @@ -45,15 +47,27 @@ updates: # 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. @@ -61,17 +75,29 @@ updates: - "*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" @@ -81,10 +107,18 @@ updates: - "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*" @@ -94,15 +128,27 @@ updates: # 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" @@ -115,10 +161,18 @@ updates: - "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" @@ -133,9 +187,13 @@ updates: directory: "/src/wp-content/themes/twentytwentyfive" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 groups: twentytwentyfive-css: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "**browserslist*" - "*css*" @@ -144,9 +202,13 @@ updates: directory: "/src/wp-content/themes/twentytwentytwo" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 groups: twentytwentytwo-css: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "**browserslist*" - "*css*" @@ -155,9 +217,13 @@ updates: directory: "/src/wp-content/themes/twentytwentyone" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 groups: twentytwentyone-sass-css: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "**browserslist*" - "autoprefixer" @@ -165,12 +231,24 @@ updates: - "*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" @@ -180,17 +258,30 @@ updates: directory: "/src/wp-content/themes/twentytwenty" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 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 + applies-to: version-updates + update-types: + - minor + - patch patterns: - "concurrently" - "@wordpress/scripts" @@ -199,15 +290,23 @@ updates: directory: "/src/wp-content/themes/twentynineteen" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 50 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" From 3ab146f1fd1783710ddee3f491bca58e39746bc0 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Wed, 20 May 2026 23:56:59 -0400 Subject: [PATCH 05/38] Configure cooldown period in Dependabot. Update dependabot.yml file. --- .github/dependabot.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d7df639e6421..8cdbc7440b705 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,8 @@ updates: schedule: interval: "daily" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: github-actions: applies-to: version-updates @@ -21,6 +23,8 @@ updates: 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" @@ -29,6 +33,10 @@ updates: - dependency-name: "yoast/phpunit-polyfills" groups: composer-packages: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "composer/ca-bundle" @@ -38,6 +46,8 @@ updates: schedule: interval: "daily" open-pull-requests-limit: 50 + cooldown: + default-days: 7 ignore: - dependency-name: "@wordpress/*" groups: @@ -188,6 +198,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: twentytwentyfive-css: applies-to: version-updates @@ -203,6 +215,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: twentytwentytwo-css: applies-to: version-updates @@ -218,6 +232,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: twentytwentyone-sass-css: applies-to: version-updates @@ -259,6 +275,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: twentytwenty-css: applies-to: version-updates @@ -277,7 +295,6 @@ updates: patterns: - "*stylelint*" twentytwenty-miscellaneous: - applies-to: version-updates applies-to: version-updates update-types: - minor @@ -291,6 +308,8 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 50 + cooldown: + default-days: 7 groups: twentynineteen-css-sass: applies-to: version-updates From 5fc9e38b41050bdab72a5734dd1bc0d138557385 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:39:20 -0400 Subject: [PATCH 06/38] Docs: Add design spec for Gutenberg SHA sync workflow. Captures the brainstormed design for a GitHub Actions workflow that opens draft PRs to update package.json gutenberg.sha for trunk (latest public release) and the most recent version branch (daily, from wp/X.Y head). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-26-gutenberg-sha-sync-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-26-gutenberg-sha-sync-design.md 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). From 577ba7bacb71736ec01286675066c2eaec35a0dc Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:00:56 -0400 Subject: [PATCH 07/38] Build/Test Tools: Add Gutenberg Sync workflow skeleton and setup job. --- .github/workflows/gutenberg-sync.yml | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/gutenberg-sync.yml diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml new file mode 100644 index 0000000000000..3241e804dc7ab --- /dev/null +++ b/.github/workflows/gutenberg-sync.yml @@ -0,0 +1,64 @@ +# 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 From f62ea50c71e6edf8d5abfc2f6324ec8b0b0b9b23 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:49:49 -0400 Subject: [PATCH 08/38] Build/Test Tools: Harden Gutenberg Sync setup job branch detection. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/gutenberg-sync.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 3241e804dc7ab..cb3c54bd14aae 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -54,10 +54,13 @@ jobs: run: | latest=$(gh api --paginate "repos/${REPO}/branches" --jq '.[].name' \ | grep -E '^[0-9]+\.[0-9]+$' | sort -V | tail -1) + if [ -z "${latest}" ]; then + echo "::error::Could not detect latest version branch." >&2 + exit 1 + fi 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 + if gh api "repos/WordPress/gutenberg/git/refs/heads/wp/${latest}" >/dev/null 2>&1; then echo "wp_branch_exists=true" >> "$GITHUB_OUTPUT" else echo "wp_branch_exists=false" >> "$GITHUB_OUTPUT" From 9c993060142ebac041d2fea6e20169d496a113e5 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:10:41 -0400 Subject: [PATCH 09/38] Build/Test Tools: Add trunk update job to Gutenberg Sync workflow. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/gutenberg-sync.yml | 154 +++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index cb3c54bd14aae..0bfc096891591 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -65,3 +65,157 @@ jobs: else echo "wp_branch_exists=false" >> "$GITHUB_OUTPUT" fi + + # 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 + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + { + 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." + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + 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 From 27dc01c678e05daae382217fe144dd6954953ef2 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:15:30 -0400 Subject: [PATCH 10/38] Build/Test Tools: Add strict mode to trunk SHA resolution step. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/gutenberg-sync.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 0bfc096891591..bcfef197cb398 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -124,6 +124,8 @@ jobs: env: GH_TOKEN: ${{ steps.generate_token.outputs.access-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") From d83a2a884abd868864a3bb4fb95eb0f4b9a42e02 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:35:41 -0400 Subject: [PATCH 11/38] Build/Test Tools: Add version branch update job to Gutenberg Sync workflow. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/gutenberg-sync.yml | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index bcfef197cb398..31bd14ba1dc08 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -221,3 +221,154 @@ jobs: gh pr create --repo "$REPO" --draft --base "$BASE" --head "$HEAD_BRANCH" \ --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync" fi + + # 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 + + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + { + 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." + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + 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 From 5362b314c0829604c51031165915cf27fddc35f2 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:44:33 -0400 Subject: [PATCH 12/38] Docs: Add implementation plan for Gutenberg SHA sync workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-26-gutenberg-sha-sync.md | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-gutenberg-sha-sync.md 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. From 54f19247fce50172883dccd38eb0c0ce7cea0604 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:45:22 -0400 Subject: [PATCH 13/38] Build/Test Tools: Harden Gutenberg Sync version branch changelog and decouple trunk job. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/gutenberg-sync.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 31bd14ba1dc08..87575a72e2255 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -70,7 +70,6 @@ jobs: 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' || @@ -289,9 +288,25 @@ jobs: 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 + + 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; changelog 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 changelog 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 + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> commit_list.md + else + cp commit_list_full.md commit_list.md + fi + fi # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. { From ed8124f064525b68361e76458796e8d28455a5d1 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:58:07 -0400 Subject: [PATCH 14/38] Build/Test Tools: Use a supported-branch env var and single-branch dispatch for Gutenberg Sync. - Remove the dry_run input and all DRY_RUN handling. - Replace the auto-detecting setup job with a workflow-level CURRENTLY_SUPPORTED_BRANCH env var (mirrors test-old-branches.yml). - Replace the target choice input with a free-form branch input accepting "trunk" or an X.Y branch name; scheduled runs update trunk plus the currently supported branch, while a manual run targets one branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 126 ++++++++++----------------- 1 file changed, 47 insertions(+), 79 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 87575a72e2255..eb89ab2caa2b3 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -1,79 +1,48 @@ # 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. +# - the currently supported X.Y branch: the head of Gutenberg's wp/X.Y branch. +# +# Scheduled runs update both trunk and the currently supported branch +# (CURRENTLY_SUPPORTED_BRANCH). A manual run targets a single branch: either +# "trunk" or a specific X.Y branch name. name: Gutenberg Sync on: schedule: - # Daily. Drives the version-branch update and the trunk release poll. + # Daily. Drives the supported-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 + branch: + description: 'Create/update a PR for a single branch: "trunk" or an X.Y branch name (e.g. "6.8"). Leave blank to target the currently supported branch.' + required: false + type: string + default: '' # Disable permissions for all available scopes by default. # Any needed permissions are configured at the job level. permissions: {} +# The most recent stable release branch that should track Gutenberg's wp/X.Y. +# Bump this once per release cycle (mirrors test-old-branches.yml). +env: + CURRENTLY_SUPPORTED_BRANCH: '7.0' + # 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) - if [ -z "${latest}" ]; then - echo "::error::Could not detect latest version branch." >&2 - exit 1 - fi - echo "latest_version_branch=${latest}" >> "$GITHUB_OUTPUT" - - if gh api "repos/WordPress/gutenberg/git/refs/heads/wp/${latest}" >/dev/null 2>&1; then - echo "wp_branch_exists=true" >> "$GITHUB_OUTPUT" - else - echo "wp_branch_exists=false" >> "$GITHUB_OUTPUT" - fi - # Updates trunk to the commit referenced by Gutenberg's latest public release. + # Runs automatically on a schedule, or manually when dispatched with branch "trunk". update-trunk: name: Update trunk to latest release runs-on: ubuntu-24.04 if: >- ${{ github.repository == 'WordPress/wordpress-develop' && - ( github.event_name != 'workflow_dispatch' || - inputs.target == 'both' || inputs.target == 'trunk' ) }} + ( github.event_name != 'workflow_dispatch' || inputs.branch == 'trunk' ) }} timeout-minutes: 10 permissions: contents: read @@ -163,7 +132,6 @@ jobs: 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 @@ -172,16 +140,6 @@ jobs: --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") @@ -221,22 +179,35 @@ jobs: --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync" fi - # Updates the most recent X.Y version branch to the head of Gutenberg's wp/X.Y. + # Updates a version branch to the head of Gutenberg's matching wp/X.Y branch. + # Runs automatically on a schedule for CURRENTLY_SUPPORTED_BRANCH, or manually + # when dispatched with a specific X.Y branch name (or blank for the supported branch). 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' ) }} + ( github.event_name != 'workflow_dispatch' || inputs.branch != 'trunk' ) }} timeout-minutes: 10 permissions: contents: read - env: - BASE_BRANCH: ${{ needs.setup.outputs.latest_version_branch }} steps: + - name: Determine target branch + env: + INPUT_BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${INPUT_BRANCH}" ]; then + if ! printf '%s' "${INPUT_BRANCH}" | grep -Eq '^[0-9]+\.[0-9]+$'; then + echo "::error::Invalid branch input '${INPUT_BRANCH}'. Use 'trunk' or an X.Y branch name (e.g. 6.8)." >&2 + exit 1 + fi + base="${INPUT_BRANCH}" + else + base="${CURRENTLY_SUPPORTED_BRANCH}" + fi + echo "BASE_BRANCH=${base}" >> "$GITHUB_ENV" + - name: Generate installation token id: generate_token env: @@ -265,7 +236,7 @@ jobs: - name: Checkout version branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ needs.setup.outputs.latest_version_branch }} + ref: ${{ env.BASE_BRANCH }} fetch-depth: 0 token: ${{ steps.generate_token.outputs.access-token }} persist-credentials: true @@ -283,6 +254,14 @@ jobs: GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} run: | set -euo pipefail + + # Skip gracefully if Gutenberg has not cut wp/ yet (e.g. early in a cycle). + if ! gh api "repos/WordPress/gutenberg/git/refs/heads/wp/${BASE_BRANCH}" >/dev/null 2>&1; then + echo "::notice::Gutenberg branch wp/${BASE_BRANCH} does not exist; nothing to sync." + echo "PROCEED=false" >> "$GITHUB_ENV" + exit 0 + fi + NEW_SHA=$(gh api "repos/WordPress/gutenberg/commits/wp/${BASE_BRANCH}" --jq '.sha') OLD_SHA=$(jq -r '.gutenberg.sha' package.json) @@ -330,7 +309,6 @@ jobs: 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 @@ -339,16 +317,6 @@ jobs: --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") From e2e129f91179c67066a08c20bd18c5515fe2c370 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:39:30 -0400 Subject: [PATCH 15/38] Build/Test Tools: Collapse Gutenberg Sync into a single manual-dispatch job. - Remove the schedule trigger; the workflow now runs only on workflow_dispatch. - Make the branch input required with a default of "trunk". - Merge the trunk and version-branch jobs into one "sync" job; trunk- and version-specific work is gated on inputs.branch, and the token, checkout, body-building, PR lookup, and PR create/update steps are shared. - Split steps to be atomic: resolve SHA, build PR body, look up the existing PR, and create/update the PR are now separate steps. - Drop the now-unused CURRENTLY_SUPPORTED_BRANCH env var. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 267 +++++++++------------------ 1 file changed, 83 insertions(+), 184 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index eb89ab2caa2b3..63584bbb7d55a 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -1,52 +1,59 @@ -# 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 currently supported X.Y branch: the head of Gutenberg's wp/X.Y branch. +# Opens (or updates) a single draft pull request 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. # -# Scheduled runs update both trunk and the currently supported branch -# (CURRENTLY_SUPPORTED_BRANCH). A manual run targets a single branch: either -# "trunk" or a specific X.Y branch name. +# Runs only on manual dispatch; each run targets exactly one branch. name: Gutenberg Sync on: - schedule: - # Daily. Drives the supported-branch update and the trunk release poll. - - cron: '0 6 * * *' workflow_dispatch: inputs: branch: - description: 'Create/update a PR for a single branch: "trunk" or an X.Y branch name (e.g. "6.8"). Leave blank to target the currently supported branch.' - required: false + description: 'Branch to update: "trunk" or an X.Y branch name (e.g. "6.8").' + required: true type: string - default: '' + default: trunk # Disable permissions for all available scopes by default. # Any needed permissions are configured at the job level. permissions: {} -# The most recent stable release branch that should track Gutenberg's wp/X.Y. -# Bump this once per release cycle (mirrors test-old-branches.yml). -env: - CURRENTLY_SUPPORTED_BRANCH: '7.0' - -# Prevent overlapping daily/manual runs from racing on the same PR branches. +# Serialize runs for the same target branch; allow different branches in parallel. concurrency: - group: ${{ github.workflow }} + group: ${{ github.workflow }}-${{ inputs.branch }} cancel-in-progress: false jobs: - # Updates trunk to the commit referenced by Gutenberg's latest public release. - # Runs automatically on a schedule, or manually when dispatched with branch "trunk". - update-trunk: - name: Update trunk to latest release + # Creates or updates the draft "Gutenberg Sync" PR for the requested branch. + # Trunk-specific and version-specific work is gated on inputs.branch; every + # other step is shared. + sync: + name: Sync ${{ inputs.branch }} runs-on: ubuntu-24.04 - if: >- - ${{ github.repository == 'WordPress/wordpress-develop' && - ( github.event_name != 'workflow_dispatch' || inputs.branch == 'trunk' ) }} + if: ${{ github.repository == 'WordPress/wordpress-develop' }} timeout-minutes: 10 permissions: contents: read steps: + - name: Determine target branch + env: + INPUT_BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + if [ "${INPUT_BRANCH}" = "trunk" ]; then + base="trunk" + elif printf '%s' "${INPUT_BRANCH}" | grep -Eq '^[0-9]+\.[0-9]+$'; then + base="${INPUT_BRANCH}" + else + echo "::error::Invalid branch '${INPUT_BRANCH}'. Use 'trunk' or an X.Y branch name (e.g. 6.8)." >&2 + exit 1 + fi + { + echo "BASE_BRANCH=${base}" + echo "HEAD_BRANCH=gutenberg-sync/${base}" + } >> "$GITHUB_ENV" + - name: Generate installation token id: generate_token env: @@ -72,10 +79,10 @@ jobs: echo "::add-mask::$ACCESS_TOKEN" echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT" - - name: Checkout trunk + - name: Checkout target branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: trunk + ref: ${{ env.BASE_BRANCH }} fetch-depth: 0 token: ${{ steps.generate_token.outputs.access-token }} persist-credentials: true @@ -88,7 +95,8 @@ jobs: 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 + - name: Resolve release SHA + if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} run: | @@ -111,145 +119,20 @@ jobs: # Write the (untrusted) release notes to a file rather than env. jq -r '.body // ""' <<<"$release" > release_notes.md - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. { - 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 + } > changelog.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 }} - 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 [ -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." - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. - 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 - - # Updates a version branch to the head of Gutenberg's matching wp/X.Y branch. - # Runs automatically on a schedule for CURRENTLY_SUPPORTED_BRANCH, or manually - # when dispatched with a specific X.Y branch name (or blank for the supported branch). - update-version-branch: - name: Update version branch to wp/X.Y head - runs-on: ubuntu-24.04 - if: >- - ${{ github.repository == 'WordPress/wordpress-develop' && - ( github.event_name != 'workflow_dispatch' || inputs.branch != 'trunk' ) }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Determine target branch - env: - INPUT_BRANCH: ${{ inputs.branch }} - run: | - set -euo pipefail - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${INPUT_BRANCH}" ]; then - if ! printf '%s' "${INPUT_BRANCH}" | grep -Eq '^[0-9]+\.[0-9]+$'; then - echo "::error::Invalid branch input '${INPUT_BRANCH}'. Use 'trunk' or an X.Y branch name (e.g. 6.8)." >&2 - exit 1 - fi - base="${INPUT_BRANCH}" - else - base="${CURRENTLY_SUPPORTED_BRANCH}" - fi - echo "BASE_BRANCH=${base}" >> "$GITHUB_ENV" - - - 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: ${{ env.BASE_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 + - name: Resolve wp/X.Y head + if: ${{ inputs.branch != 'trunk' }} env: GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} run: | @@ -280,61 +163,77 @@ jobs: total=$(wc -l < commit_list_full.md) if [ "${total}" -gt 300 ]; then head -n 300 commit_list_full.md > commit_list.md - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> commit_list.md else cp commit_list_full.md commit_list.md fi fi - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. { - 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 + } > changelog.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 + - name: Build pull request body + if: ${{ env.PROCEED == 'true' }} + run: | + set -euo pipefail + # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. + { + printf 'Updates the bundled Gutenberg commit reference for `%s` to `%s`.\n\n' "$BASE_BRANCH" "$NEW_SHA" + cat changelog.md + } > pr_body.md + + - name: Look up existing pull request if: ${{ env.PROCEED == 'true' }} env: GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} REPO: ${{ github.repository }} - 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" \ + pr_json=$(gh pr list --repo "$REPO" --state open --base "$BASE_BRANCH" \ --label "Gutenberg Sync" --json number,headRefName,isCrossRepository \ --jq '.[0] // empty') if [ -n "$pr_json" ]; then - number=$(jq -r '.number' <<<"$pr_json") - head=$(jq -r '.headRefName' <<<"$pr_json") - cross=$(jq -r '.isCrossRepository' <<<"$pr_json") + { + echo "PR_NUMBER=$(jq -r '.number' <<<"$pr_json")" + echo "PR_HEAD=$(jq -r '.headRefName' <<<"$pr_json")" + echo "PR_CROSS=$(jq -r '.isCrossRepository' <<<"$pr_json")" + } >> "$GITHUB_ENV" + else + echo "PR_NUMBER=" >> "$GITHUB_ENV" + fi + + - name: Create or update the draft pull request + if: ${{ env.PROCEED == 'true' }} + env: + GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} + REPO: ${{ github.repository }} + COMMIT_MSG: 'Build/Test Tools: Update the bundled Gutenberg commit reference.' + run: | + set -euo pipefail - if [ "$cross" = "true" ]; then - echo "::warning::PR #$number is from a fork; updating metadata only." + if [ -n "${PR_NUMBER}" ]; then + if [ "${PR_CROSS}" = "true" ]; then + echo "::warning::PR #${PR_NUMBER} is from a fork; updating metadata only." # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. 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 + gh pr edit "${PR_NUMBER}" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md exit 0 fi - git fetch origin "$head" - git checkout -B "$head" "origin/$head" + git fetch origin "${PR_HEAD}" + git checkout -B "${PR_HEAD}" "origin/${PR_HEAD}" else - number="" - git checkout -B "$HEAD_BRANCH" "origin/$BASE" + git checkout -B "${HEAD_BRANCH}" "origin/${BASE_BRANCH}" fi jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp @@ -347,11 +246,11 @@ jobs: 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 + if [ -n "${PR_NUMBER}" ]; then + git push origin "HEAD:${PR_HEAD}" + gh pr edit "${PR_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" \ + git push --force origin "HEAD:${HEAD_BRANCH}" + gh pr create --repo "$REPO" --draft --base "${BASE_BRANCH}" --head "${HEAD_BRANCH}" \ --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync" fi From 417905a7f57b2906191ec0c1e8d34fd8339bbd67 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:34:18 -0400 Subject: [PATCH 16/38] Build/Test Tools: Drop the determine-branch step in favor of workflow env vars. Define BASE_BRANCH and HEAD_BRANCH as workflow-level environment variables derived from the branch input, and remove the explicit determine/validate step. An invalid branch now fails at the checkout step, which is sufficient. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 63584bbb7d55a..3cbde3e82b1be 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -13,12 +13,17 @@ on: description: 'Branch to update: "trunk" or an X.Y branch name (e.g. "6.8").' required: true type: string - default: trunk + default: 'trunk' # Disable permissions for all available scopes by default. # Any needed permissions are configured at the job level. permissions: {} +# The target branch and the bot-managed PR head branch, derived from the input. +env: + 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 }} @@ -36,24 +41,6 @@ jobs: permissions: contents: read steps: - - name: Determine target branch - env: - INPUT_BRANCH: ${{ inputs.branch }} - run: | - set -euo pipefail - if [ "${INPUT_BRANCH}" = "trunk" ]; then - base="trunk" - elif printf '%s' "${INPUT_BRANCH}" | grep -Eq '^[0-9]+\.[0-9]+$'; then - base="${INPUT_BRANCH}" - else - echo "::error::Invalid branch '${INPUT_BRANCH}'. Use 'trunk' or an X.Y branch name (e.g. 6.8)." >&2 - exit 1 - fi - { - echo "BASE_BRANCH=${base}" - echo "HEAD_BRANCH=gutenberg-sync/${base}" - } >> "$GITHUB_ENV" - - name: Generate installation token id: generate_token env: From 203fbe08dc0dafa0edf5d9c2fb39c8235e8a3d87 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:34:29 -0400 Subject: [PATCH 17/38] Build/Test Tools: Make Gutenberg Sync run from a fork and PR back to upstream. WordPress/wordpress-develop does not accept pushed branches, so the workflow now runs from a fork: - Guard inverted to run only on forks (github.repository != upstream). - Check out the target branch from upstream so the PR is built on current content, push the gutenberg-sync/ branch to the fork, and open the pull request from the fork branch back to upstream. - Drop the canonical-only GitHub App token (its private key cannot live on a fork). Push with the built-in GITHUB_TOKEN; open/label the upstream PR with an optional GUTENBERG_SYNC_TOKEN PAT. Without the PAT, the branch is still pushed and the run prints a link to open the PR manually. - Skip creating a duplicate when an open Gutenberg Sync PR already exists for the target branch from another fork. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 153 ++++++++++++--------------- 1 file changed, 70 insertions(+), 83 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 3cbde3e82b1be..dd8312d8f31f8 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -3,7 +3,19 @@ # - "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. # -# Runs only on manual dispatch; each run targets exactly one branch. +# The WordPress/wordpress-develop repository does not accept pushed branches, so +# this workflow is designed to run from a FORK. It checks out the target branch +# from upstream (WordPress/wordpress-develop), pushes a gutenberg-sync/ +# branch to the fork it runs in, and opens a pull request from that fork branch +# back to upstream. Each run targets exactly one branch. +# +# Authentication: +# - GITHUB_TOKEN (built in) pushes the branch to the fork. +# - GUTENBERG_SYNC_TOKEN (optional secret) is a personal access token used to +# open/update the pull request on upstream and apply the "Gutenberg Sync" +# label. It needs permission to create PRs and label them on upstream (i.e. +# a committer's token). Without it, the branch is still pushed and the run +# prints a link to open the pull request manually. name: Gutenberg Sync on: @@ -19,8 +31,10 @@ on: # Any needed permissions are configured at the job level. permissions: {} -# The target branch and the bot-managed PR head branch, derived from the input. 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 }} @@ -36,56 +50,32 @@ jobs: sync: name: Sync ${{ inputs.branch }} runs-on: ubuntu-24.04 - if: ${{ github.repository == 'WordPress/wordpress-develop' }} + # Runs from a fork; the canonical repository does not accept pushed branches. + if: ${{ github.repository != 'WordPress/wordpress-develop' }} timeout-minutes: 10 permissions: - contents: read + # Push the gutenberg-sync/ branch to the fork this workflow runs in. + contents: write 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 target branch + - 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: ${{ steps.generate_token.outputs.access-token }} + token: ${{ github.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" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Resolve release SHA if: ${{ inputs.branch == 'trunk' }} env: - GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -121,7 +111,7 @@ jobs: - name: Resolve wp/X.Y head if: ${{ inputs.branch != 'trunk' }} env: - GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -178,66 +168,63 @@ jobs: cat changelog.md } > pr_body.md - - name: Look up existing pull request + - name: Push the sync branch to the fork if: ${{ env.PROCEED == 'true' }} env: - GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} - REPO: ${{ github.repository }} + FORK_REPO: ${{ github.repository }} + 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_BRANCH" \ - --label "Gutenberg Sync" --json number,headRefName,isCrossRepository \ - --jq '.[0] // empty') - if [ -n "$pr_json" ]; then - { - echo "PR_NUMBER=$(jq -r '.number' <<<"$pr_json")" - echo "PR_HEAD=$(jq -r '.headRefName' <<<"$pr_json")" - echo "PR_CROSS=$(jq -r '.isCrossRepository' <<<"$pr_json")" - } >> "$GITHUB_ENV" - else - echo "PR_NUMBER=" >> "$GITHUB_ENV" - fi + # Build the head branch from the upstream target branch plus the bump. + git checkout -B "${HEAD_BRANCH}" + jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp + mv package.json.tmp package.json + git add package.json + git commit -m "$COMMIT_MSG" + + # Push to the fork this workflow runs in (origin points at upstream). + git remote add fork "https://github.com/${FORK_REPO}.git" + git push --force fork "HEAD:${HEAD_BRANCH}" - - name: Create or update the draft pull request + - name: Open or update the upstream pull request if: ${{ env.PROCEED == 'true' }} env: - GH_TOKEN: ${{ steps.generate_token.outputs.access-token }} - REPO: ${{ github.repository }} - COMMIT_MSG: 'Build/Test Tools: Update the bundled Gutenberg commit reference.' + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} + FORK_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail - if [ -n "${PR_NUMBER}" ]; then - if [ "${PR_CROSS}" = "true" ]; then - echo "::warning::PR #${PR_NUMBER} is from a fork; updating metadata only." - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. - 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 "${PR_NUMBER}" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md - exit 0 - fi - - git fetch origin "${PR_HEAD}" - git checkout -B "${PR_HEAD}" "origin/${PR_HEAD}" - else - git checkout -B "${HEAD_BRANCH}" "origin/${BASE_BRANCH}" - fi + compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - 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" + # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch + # is already pushed, so surface a link to open the PR manually. + if [ "${HAS_PAT}" != "true" ]; then + echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" + { + echo "### Gutenberg Sync" + echo "" + echo "Pushed \`${HEAD_BRANCH}\` to your fork." + echo "" + echo "[Open the pull request](${compare_url})" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 fi - if [ -n "${PR_NUMBER}" ]; then - git push origin "HEAD:${PR_HEAD}" - gh pr edit "${PR_NUMBER}" --repo "$REPO" --title "$PR_TITLE" --body-file pr_body.md + prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open --base "$BASE_BRANCH" \ + --label "Gutenberg Sync" --json number,headRefName,headRepositoryOwner) + ours=$(jq -r --arg o "$FORK_OWNER" --arg h "$HEAD_BRANCH" \ + '[.[] | select(.headRepositoryOwner.login == $o and .headRefName == $h)][0].number // empty' <<<"$prs") + anyone=$(jq -r '.[0].number // empty' <<<"$prs") + + if [ -n "$ours" ]; then + # The force-push above already refreshed the branch; update title/body. + gh pr edit "$ours" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + elif [ -n "$anyone" ]; then + echo "::notice::An open 'Gutenberg Sync' PR for ${BASE_BRANCH} already exists (#${anyone}); leaving it as-is." else - git push --force origin "HEAD:${HEAD_BRANCH}" - gh pr create --repo "$REPO" --draft --base "${BASE_BRANCH}" --head "${HEAD_BRANCH}" \ + gh pr create --repo "$UPSTREAM_REPO" --draft \ + --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync" fi From 5bfeef36aff40e26bfd7229ca5179e60163c2e9c Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:10:39 -0400 Subject: [PATCH 18/38] Build/Test Tools: Check out Gutenberg via actions/checkout and drop the wp/X.Y existence check. - Replace the manual git clone of WordPress/gutenberg with an actions/checkout step (fetch-depth: 0 so the changelog's git log range has history), and read the new SHA from the checked-out head. - Remove the custom "wp/ does not exist" guard; if the branch is missing, the Gutenberg checkout fails on its own, which is sufficient. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index dd8312d8f31f8..d0a4e5b896cad 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -108,26 +108,26 @@ jobs: if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi } >> "$GITHUB_ENV" + - name: Checkout the Gutenberg branch + if: ${{ inputs.branch != 'trunk' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: WordPress/gutenberg + ref: wp/${{ env.BASE_BRANCH }} + path: gutenberg-src + fetch-depth: 0 + token: ${{ github.token }} + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + - name: Resolve wp/X.Y head if: ${{ inputs.branch != 'trunk' }} - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Skip gracefully if Gutenberg has not cut wp/ yet (e.g. early in a cycle). - if ! gh api "repos/WordPress/gutenberg/git/refs/heads/wp/${BASE_BRANCH}" >/dev/null 2>&1; then - echo "::notice::Gutenberg branch wp/${BASE_BRANCH} does not exist; nothing to sync." - echo "PROCEED=false" >> "$GITHUB_ENV" - exit 0 - fi - - NEW_SHA=$(gh api "repos/WordPress/gutenberg/commits/wp/${BASE_BRANCH}" --jq '.sha') + NEW_SHA=$(git -C gutenberg-src rev-parse HEAD) OLD_SHA=$(jq -r '.gutenberg.sha' package.json) - git clone --filter=tree:0 --no-checkout \ - https://github.com/WordPress/gutenberg.git gutenberg-src - 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; changelog omitted._\n' "${OLD_SHA}" > commit_list.md From 82e5eb885f006570b244c6b125a9f833171d626d Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:34:30 -0400 Subject: [PATCH 19/38] Build/Test Tools: Build the Gutenberg Sync PR body from a markdown template. Move the PR body structure into .github/workflows/gutenberg-sync-pr-body.md with __BRANCH__, __NEW_SHA__, __SOURCE_LINE__, and __CHANGELOG__ placeholders. The resolve steps now only emit the scalar values and write the changelog content to a file; a single awk step renders the template (inserting the changelog file verbatim so untrusted release notes are never re-substituted). The template is sparse-checked-out from the workflow's own ref so it is always available and version-matched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync-pr-body.md | 7 +++ .github/workflows/gutenberg-sync.yml | 53 +++++++++++++-------- 2 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/gutenberg-sync-pr-body.md diff --git a/.github/workflows/gutenberg-sync-pr-body.md b/.github/workflows/gutenberg-sync-pr-body.md new file mode 100644 index 0000000000000..97f83ceb4ff50 --- /dev/null +++ b/.github/workflows/gutenberg-sync-pr-body.md @@ -0,0 +1,7 @@ +Updates the bundled Gutenberg commit reference for `__BRANCH__` to `__NEW_SHA__`. + +__SOURCE_LINE__ + +## Changes + +__CHANGELOG__ diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index d0a4e5b896cad..a7ab892bf27f4 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -95,15 +95,11 @@ jobs: 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 'Release: [%s](%s)\n\n' "$TAG" "$RELEASE_URL" - printf '## Changelog\n\n' - cat release_notes.md - } > changelog.md + jq -r '.body // ""' <<<"$release" > changelog_content.md { echo "NEW_SHA=${NEW_SHA}" + echo "SOURCE_LINE=Release: [${TAG}](${RELEASE_URL})" 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" @@ -130,7 +126,7 @@ jobs: 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; changelog omitted._\n' "${OLD_SHA}" > commit_list.md + printf -- '_Previous reference `%s` was not found in Gutenberg history; changelog omitted._\n' "${OLD_SHA}" > changelog_content.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' \ @@ -139,34 +135,49 @@ jobs: # Cap the changelog 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; see the compare link above._\n' "$(( total - 300 ))" >> commit_list.md + head -n 300 commit_list_full.md > changelog_content.md + printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md else - cp commit_list_full.md commit_list.md + cp commit_list_full.md changelog_content.md fi fi - { - printf 'Compare: https://github.com/WordPress/gutenberg/compare/%s...%s\n\n' "$OLD_SHA" "$NEW_SHA" - printf '## Changes\n\n' - cat commit_list.md - } > changelog.md - { echo "NEW_SHA=${NEW_SHA}" + echo "SOURCE_LINE=Compare: https://github.com/WordPress/gutenberg/compare/${OLD_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: Checkout the pull request body template + if: ${{ env.PROCEED == 'true' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/workflows/gutenberg-sync-pr-body.md + sparse-checkout-cone-mode: false + path: sync-workflow + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + - name: Build pull request body if: ${{ env.PROCEED == 'true' }} run: | set -euo pipefail - # shellcheck disable=SC2016 # Backticks are markdown literals, not shell expansions. - { - printf 'Updates the bundled Gutenberg commit reference for `%s` to `%s`.\n\n' "$BASE_BRANCH" "$NEW_SHA" - cat changelog.md - } > pr_body.md + # Substitute the scalar placeholders; the changelog file is inserted + # verbatim so its (untrusted) contents are never re-substituted. + awk -v branch="$BASE_BRANCH" -v sha="$NEW_SHA" -v src="$SOURCE_LINE" ' + /__CHANGELOG__/ { + while ( ( getline line < "changelog_content.md" ) > 0 ) print line + close( "changelog_content.md" ) + next + } + { + gsub( /__BRANCH__/, branch ) + gsub( /__NEW_SHA__/, sha ) + gsub( /__SOURCE_LINE__/, src ) + print + } + ' sync-workflow/.github/workflows/gutenberg-sync-pr-body.md > pr_body.md - name: Push the sync branch to the fork if: ${{ env.PROCEED == 'true' }} From aed40cf62ac319d36935cff3bb28fafc79bbe40c Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:44:00 -0400 Subject: [PATCH 20/38] Build/Test Tools: Fill the PR body template in place and split out labeling. - Render the PR body by writing each placeholder into a working copy of the template as its value is resolved (sed for scalars, awk to insert the changelog verbatim). This removes the SOURCE_LINE environment variable and the separate body-building step. - Stop applying the label in the sync workflow and identify an existing PR by the gutenberg-sync/ head branch name instead of the label. - Add gutenberg-sync-label.yml: on pull_request_target (opened/reopened) it labels any gutenberg-sync/* PR using upstream's token, which (unlike the fork's token) can label PRs on upstream. It never checks out PR code, so the pull_request_target trigger is safe (zizmor ignore documented inline). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync-label.yml | 38 ++++++++ .github/workflows/gutenberg-sync.yml | 102 ++++++++++----------- 2 files changed, 87 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/gutenberg-sync-label.yml 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 index a7ab892bf27f4..bc39e83970f60 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -3,19 +3,19 @@ # - "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. # -# The WordPress/wordpress-develop repository does not accept pushed branches, so -# this workflow is designed to run from a FORK. It checks out the target branch -# from upstream (WordPress/wordpress-develop), pushes a gutenberg-sync/ -# branch to the fork it runs in, and opens a pull request from that fork branch -# back to upstream. Each run targets exactly one 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 opens a pull +# request from that fork branch back to upstream. Each run targets one branch. +# +# The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml +# when a gutenberg-sync/* pull request is opened. # # Authentication: # - GITHUB_TOKEN (built in) pushes the branch to the fork. # - GUTENBERG_SYNC_TOKEN (optional secret) is a personal access token used to -# open/update the pull request on upstream and apply the "Gutenberg Sync" -# label. It needs permission to create PRs and label them on upstream (i.e. -# a committer's token). Without it, the branch is still pushed and the run -# prints a link to open the pull request manually. +# open/update the pull request on upstream. Without it, the branch is still +# pushed and the run prints a link to open the pull request manually. name: Gutenberg Sync on: @@ -44,7 +44,7 @@ concurrency: cancel-in-progress: false jobs: - # Creates or updates the draft "Gutenberg Sync" PR for the requested branch. + # Creates or updates the draft pull request for the requested branch. # Trunk-specific and version-specific work is gated on inputs.branch; every # other step is shared. sync: @@ -67,6 +67,15 @@ jobs: persist-credentials: true show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + - name: Checkout the pull request body template + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/workflows/gutenberg-sync-pr-body.md + sparse-checkout-cone-mode: false + path: sync-workflow + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + - name: Configure git author run: | git config user.name "github-actions[bot]" @@ -79,9 +88,15 @@ jobs: run: | set -euo pipefail + # Build the PR body from the template, filling each placeholder as its + # value is resolved. The branch name is known from the input. + cp sync-workflow/.github/workflows/gutenberg-sync-pr-body.md pr_body.md + sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md + release=$(gh api repos/WordPress/gutenberg/releases/latest) TAG=$(jq -r '.tag_name' <<<"$release") RELEASE_URL=$(jq -r '.html_url' <<<"$release") + sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md ref=$(gh api "repos/WordPress/gutenberg/git/refs/tags/${TAG}") obj_type=$(jq -r '.object.type' <<<"$ref") @@ -91,15 +106,16 @@ jobs: else NEW_SHA="$obj_sha" fi + sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - OLD_SHA=$(jq -r '.gutenberg.sha' package.json) - - # Write the (untrusted) release notes to a file rather than env. + # Insert the (untrusted) release notes verbatim at the changelog placeholder. jq -r '.body // ""' <<<"$release" > changelog_content.md + awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp + mv pr_body.tmp pr_body.md + OLD_SHA=$(jq -r '.gutenberg.sha' package.json) { echo "NEW_SHA=${NEW_SHA}" - echo "SOURCE_LINE=Release: [${TAG}](${RELEASE_URL})" 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" @@ -121,8 +137,14 @@ jobs: run: | set -euo pipefail + cp sync-workflow/.github/workflows/gutenberg-sync-pr-body.md pr_body.md + sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md + NEW_SHA=$(git -C gutenberg-src rev-parse HEAD) + sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md + OLD_SHA=$(jq -r '.gutenberg.sha' package.json) + sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md 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. @@ -141,44 +163,15 @@ jobs: cp commit_list_full.md changelog_content.md fi fi + awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp + mv pr_body.tmp pr_body.md { echo "NEW_SHA=${NEW_SHA}" - echo "SOURCE_LINE=Compare: https://github.com/WordPress/gutenberg/compare/${OLD_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: Checkout the pull request body template - if: ${{ env.PROCEED == 'true' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .github/workflows/gutenberg-sync-pr-body.md - sparse-checkout-cone-mode: false - path: sync-workflow - persist-credentials: false - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - - name: Build pull request body - if: ${{ env.PROCEED == 'true' }} - run: | - set -euo pipefail - # Substitute the scalar placeholders; the changelog file is inserted - # verbatim so its (untrusted) contents are never re-substituted. - awk -v branch="$BASE_BRANCH" -v sha="$NEW_SHA" -v src="$SOURCE_LINE" ' - /__CHANGELOG__/ { - while ( ( getline line < "changelog_content.md" ) > 0 ) print line - close( "changelog_content.md" ) - next - } - { - gsub( /__BRANCH__/, branch ) - gsub( /__NEW_SHA__/, sha ) - gsub( /__SOURCE_LINE__/, src ) - print - } - ' sync-workflow/.github/workflows/gutenberg-sync-pr-body.md > pr_body.md - - name: Push the sync branch to the fork if: ${{ env.PROCEED == 'true' }} env: @@ -223,19 +216,22 @@ jobs: exit 0 fi - prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open --base "$BASE_BRANCH" \ - --label "Gutenberg Sync" --json number,headRefName,headRepositoryOwner) - ours=$(jq -r --arg o "$FORK_OWNER" --arg h "$HEAD_BRANCH" \ - '[.[] | select(.headRepositoryOwner.login == $o and .headRefName == $h)][0].number // empty' <<<"$prs") - anyone=$(jq -r '.[0].number // empty' <<<"$prs") + # Identify an existing open PR for this base by the sync head branch name. + prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ + --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ + --json number,headRepositoryOwner) + ours=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") + others=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") if [ -n "$ours" ]; then # The force-push above already refreshed the branch; update title/body. gh pr edit "$ours" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md - elif [ -n "$anyone" ]; then - echo "::notice::An open 'Gutenberg Sync' PR for ${BASE_BRANCH} already exists (#${anyone}); leaving it as-is." + elif [ -n "$others" ]; then + echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." else gh pr create --repo "$UPSTREAM_REPO" --draft \ --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md --label "Gutenberg Sync" + --title "$PR_TITLE" --body-file pr_body.md fi From beac8226943491e52e2e672d8922fba186b6214f Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:02:36 -0400 Subject: [PATCH 21/38] Build/Test Tools: Split Gutenberg Sync into atomic steps and relocate the template. - Move the body template to .github/template-gutenberg-sync-pr-body.md. - Break each resolve path into one action per step (copy template, fill each placeholder, retrieve release details / determine SHAs, build changelog). - Break the push into create-branch, update-file, stage, commit, add-remote, and push steps. - Split the pull request step into lookup, update-existing, and open-new. Co-Authored-By: Claude Opus 4.8 (1M context) --- ....md => template-gutenberg-sync-pr-body.md} | 0 .github/workflows/gutenberg-sync.yml | 171 +++++++++++++----- 2 files changed, 130 insertions(+), 41 deletions(-) rename .github/{workflows/gutenberg-sync-pr-body.md => template-gutenberg-sync-pr-body.md} (100%) diff --git a/.github/workflows/gutenberg-sync-pr-body.md b/.github/template-gutenberg-sync-pr-body.md similarity index 100% rename from .github/workflows/gutenberg-sync-pr-body.md rename to .github/template-gutenberg-sync-pr-body.md diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index bc39e83970f60..febb2f6465ff9 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -70,7 +70,7 @@ jobs: - name: Checkout the pull request body template uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .github/workflows/gutenberg-sync-pr-body.md + sparse-checkout: .github/template-gutenberg-sync-pr-body.md sparse-checkout-cone-mode: false path: sync-workflow persist-credentials: false @@ -81,23 +81,46 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Resolve release SHA + # ----- Resolve the trunk release (inputs.branch == 'trunk') ----- + + - name: Copy the pull request body template if: ${{ inputs.branch == 'trunk' }} - env: - GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail + cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - # Build the PR body from the template, filling each placeholder as its - # value is resolved. The branch name is known from the input. - cp sync-workflow/.github/workflows/gutenberg-sync-pr-body.md pr_body.md + - name: Add the branch name to the body + if: ${{ inputs.branch == 'trunk' }} + run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md + - name: Retrieve the latest release details + 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") + # The (untrusted) release notes are written to a file for later insertion. + jq -r '.body // ""' <<<"$release" > changelog_content.md + { + echo "TAG=${TAG}" + echo "RELEASE_URL=${RELEASE_URL}" + echo "PR_TITLE=Gutenberg Sync: Update trunk to ${TAG}" + } >> "$GITHUB_ENV" + + - name: Add the release link to the body + if: ${{ inputs.branch == 'trunk' }} + run: | sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md + - name: Determine the new commit SHA + if: ${{ inputs.branch == 'trunk' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail ref=$(gh api "repos/WordPress/gutenberg/git/refs/tags/${TAG}") obj_type=$(jq -r '.object.type' <<<"$ref") obj_sha=$(jq -r '.object.sha' <<<"$ref") @@ -106,19 +129,27 @@ jobs: else NEW_SHA="$obj_sha" fi + echo "NEW_SHA=${NEW_SHA}" >> "$GITHUB_ENV" + + - name: Add the new SHA to the body + if: ${{ inputs.branch == 'trunk' }} + run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - # Insert the (untrusted) release notes verbatim at the changelog placeholder. - jq -r '.body // ""' <<<"$release" > changelog_content.md + - name: Add the release notes to the body + if: ${{ inputs.branch == 'trunk' }} + run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md + - name: Determine the current SHA + if: ${{ inputs.branch == 'trunk' }} + run: | + set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) - { - 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" + if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" + + # ----- Resolve the wp/X.Y head (inputs.branch != 'trunk') ----- - name: Checkout the Gutenberg branch if: ${{ inputs.branch != 'trunk' }} @@ -132,20 +163,46 @@ jobs: persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Resolve wp/X.Y head + - name: Copy the pull request body template if: ${{ inputs.branch != 'trunk' }} run: | - set -euo pipefail + cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - cp sync-workflow/.github/workflows/gutenberg-sync-pr-body.md pr_body.md + - name: Add the branch name to the body + if: ${{ inputs.branch != 'trunk' }} + run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - NEW_SHA=$(git -C gutenberg-src rev-parse HEAD) + - name: Determine the new commit SHA + if: ${{ inputs.branch != 'trunk' }} + run: | + echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Add the new SHA to the body + if: ${{ inputs.branch != 'trunk' }} + run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md + - name: Determine the current SHA + if: ${{ inputs.branch != 'trunk' }} + run: | + set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) + { + echo "OLD_SHA=${OLD_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: Add the compare link to the body + if: ${{ inputs.branch != 'trunk' }} + run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md + - name: Build the changelog + if: ${{ inputs.branch != 'trunk' }} + 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.md @@ -163,35 +220,52 @@ jobs: cp commit_list_full.md changelog_content.md fi fi + + - name: Add the changelog to the body + if: ${{ inputs.branch != 'trunk' }} + run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - { - 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" + # ----- Push the sync branch to the fork ----- - - name: Push the sync branch to the fork + - name: Create the sync branch if: ${{ env.PROCEED == 'true' }} - env: - FORK_REPO: ${{ github.repository }} - COMMIT_MSG: 'Build/Test Tools: Update the bundled Gutenberg commit reference.' run: | - set -euo pipefail - - # Build the head branch from the upstream target branch plus the bump. git checkout -B "${HEAD_BRANCH}" + + - name: Update the bundled Gutenberg SHA + if: ${{ env.PROCEED == 'true' }} + run: | + set -euo pipefail jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp mv package.json.tmp package.json + + - name: Stage the change + if: ${{ env.PROCEED == 'true' }} + run: | git add package.json - git commit -m "$COMMIT_MSG" - # Push to the fork this workflow runs in (origin points at upstream). + - name: Commit the change + if: ${{ env.PROCEED == 'true' }} + run: | + git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." + + - name: Add the fork remote + if: ${{ env.PROCEED == 'true' }} + env: + FORK_REPO: ${{ github.repository }} + run: | git remote add fork "https://github.com/${FORK_REPO}.git" + + - name: Push to the fork + if: ${{ env.PROCEED == 'true' }} + run: | git push --force fork "HEAD:${HEAD_BRANCH}" - - name: Open or update the upstream pull request + # ----- Open or update the upstream pull request ----- + + - name: Look up the existing pull request if: ${{ env.PROCEED == 'true' }} env: GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} @@ -200,11 +274,10 @@ jobs: run: | set -euo pipefail - compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch # is already pushed, so surface a link to open the PR manually. if [ "${HAS_PAT}" != "true" ]; then + compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" { echo "### Gutenberg Sync" @@ -213,6 +286,7 @@ jobs: echo "" echo "[Open the pull request](${compare_url})" } >> "$GITHUB_STEP_SUMMARY" + echo "PR_ACTION=manual" >> "$GITHUB_ENV" exit 0 fi @@ -226,12 +300,27 @@ jobs: '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") if [ -n "$ours" ]; then - # The force-push above already refreshed the branch; update title/body. - gh pr edit "$ours" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" elif [ -n "$others" ]; then echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." + echo "PR_ACTION=skip" >> "$GITHUB_ENV" else - gh pr create --repo "$UPSTREAM_REPO" --draft \ - --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md + echo "PR_ACTION=create" >> "$GITHUB_ENV" fi + + - name: Update the existing pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + run: | + gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + + - name: Open a new pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + FORK_OWNER: ${{ github.repository_owner }} + run: | + gh pr create --repo "$UPSTREAM_REPO" --draft \ + --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ + --title "$PR_TITLE" --body-file pr_body.md From 56022772964b9e4f11dcd75a44daa4dc190f6ee8 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:08:00 -0400 Subject: [PATCH 22/38] Build/Test Tools: Split Gutenberg Sync into separate trunk and version-branch jobs. Gate the branch selection at the job level (inputs.branch == 'trunk' vs != 'trunk') so the per-step branch conditionals are no longer needed. Each job runs end to end: checkout, resolve, push to the fork, and open/update the PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 165 ++++++++++++++++++++++----- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index febb2f6465ff9..7fb19e3203233 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -6,7 +6,8 @@ # 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 opens a pull -# request from that fork branch back to upstream. Each run targets one branch. +# request from that fork branch back to upstream. Each run targets one branch: +# the "trunk" job or the "version-branch" job runs depending on the input. # # The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml # when a gutenberg-sync/* pull request is opened. @@ -44,14 +45,12 @@ concurrency: cancel-in-progress: false jobs: - # Creates or updates the draft pull request for the requested branch. - # Trunk-specific and version-specific work is gated on inputs.branch; every - # other step is shared. - sync: - name: Sync ${{ inputs.branch }} + # Syncs trunk to the commit of Gutenberg's latest public (non-prerelease) release. + trunk: + name: Sync trunk runs-on: ubuntu-24.04 # Runs from a fork; the canonical repository does not accept pushed branches. - if: ${{ github.repository != 'WordPress/wordpress-develop' }} + if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch == 'trunk' }} timeout-minutes: 10 permissions: # Push the gutenberg-sync/ branch to the fork this workflow runs in. @@ -81,20 +80,15 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # ----- Resolve the trunk release (inputs.branch == 'trunk') ----- - - name: Copy the pull request body template - if: ${{ inputs.branch == 'trunk' }} run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - name: Add the branch name to the body - if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - name: Retrieve the latest release details - if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} run: | @@ -111,12 +105,10 @@ jobs: } >> "$GITHUB_ENV" - name: Add the release link to the body - if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md - name: Determine the new commit SHA - if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} run: | @@ -132,27 +124,145 @@ jobs: echo "NEW_SHA=${NEW_SHA}" >> "$GITHUB_ENV" - name: Add the new SHA to the body - if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Add the release notes to the body - if: ${{ inputs.branch == 'trunk' }} run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - name: Determine the current SHA - if: ${{ inputs.branch == 'trunk' }} run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" - # ----- Resolve the wp/X.Y head (inputs.branch != 'trunk') ----- + - name: Create the sync branch + if: ${{ env.PROCEED == 'true' }} + run: | + git checkout -B "${HEAD_BRANCH}" + + - name: Update the bundled Gutenberg SHA + if: ${{ env.PROCEED == 'true' }} + run: | + set -euo pipefail + jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Stage the change + if: ${{ env.PROCEED == 'true' }} + run: | + git add package.json + + - name: Commit the change + if: ${{ env.PROCEED == 'true' }} + run: | + git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." + + - name: Add the fork remote + if: ${{ env.PROCEED == 'true' }} + env: + FORK_REPO: ${{ github.repository }} + run: | + git remote add fork "https://github.com/${FORK_REPO}.git" + + - name: Push to the fork + if: ${{ env.PROCEED == 'true' }} + run: | + git push --force fork "HEAD:${HEAD_BRANCH}" + + - name: Look up the existing pull request + if: ${{ env.PROCEED == 'true' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} + FORK_OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + + # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch + # is already pushed, so surface a link to open the PR manually. + if [ "${HAS_PAT}" != "true" ]; then + compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" + echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" + { + echo "### Gutenberg Sync" + echo "" + echo "Pushed \`${HEAD_BRANCH}\` to your fork." + echo "" + echo "[Open the pull request](${compare_url})" + } >> "$GITHUB_STEP_SUMMARY" + echo "PR_ACTION=manual" >> "$GITHUB_ENV" + exit 0 + fi + + # Identify an existing open PR for this base by the sync head branch name. + prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ + --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ + --json number,headRepositoryOwner) + ours=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") + others=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") + + if [ -n "$ours" ]; then + { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" + elif [ -n "$others" ]; then + echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." + echo "PR_ACTION=skip" >> "$GITHUB_ENV" + else + echo "PR_ACTION=create" >> "$GITHUB_ENV" + fi + + - name: Update the existing pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + run: | + gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + + - name: Open a new pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + FORK_OWNER: ${{ github.repository_owner }} + run: | + gh pr create --repo "$UPSTREAM_REPO" --draft \ + --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ + --title "$PR_TITLE" --body-file pr_body.md + + # Syncs a version branch to the head of Gutenberg's matching wp/X.Y branch. + version-branch: + name: Sync ${{ inputs.branch }} + runs-on: ubuntu-24.04 + # Runs from a fork; the canonical repository does not accept pushed branches. + if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch != 'trunk' }} + timeout-minutes: 10 + permissions: + # Push the gutenberg-sync/ branch to the fork this workflow runs in. + 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: 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 + path: sync-workflow + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout the Gutenberg branch - if: ${{ inputs.branch != 'trunk' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: WordPress/gutenberg @@ -163,28 +273,28 @@ jobs: persist-credentials: false 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: Copy the pull request body template - if: ${{ inputs.branch != 'trunk' }} run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - name: Add the branch name to the body - if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - name: Determine the new commit SHA - if: ${{ inputs.branch != 'trunk' }} run: | echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV" - name: Add the new SHA to the body - if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Determine the current SHA - if: ${{ inputs.branch != 'trunk' }} run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) @@ -195,12 +305,10 @@ jobs: } >> "$GITHUB_ENV" - name: Add the compare link to the body - if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md - name: Build the changelog - if: ${{ inputs.branch != 'trunk' }} run: | set -euo pipefail if ! git -C gutenberg-src cat-file -e "${OLD_SHA}^{commit}" 2>/dev/null; then @@ -222,13 +330,10 @@ jobs: fi - name: Add the changelog to the body - if: ${{ inputs.branch != 'trunk' }} run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - # ----- Push the sync branch to the fork ----- - - name: Create the sync branch if: ${{ env.PROCEED == 'true' }} run: | @@ -263,8 +368,6 @@ jobs: run: | git push --force fork "HEAD:${HEAD_BRANCH}" - # ----- Open or update the upstream pull request ----- - - name: Look up the existing pull request if: ${{ env.PROCEED == 'true' }} env: From 969f6cb8345187ff28a938b5015b5ec3938bc4ce Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:29:42 -0400 Subject: [PATCH 23/38] Build/Test Tools: Extract the Gutenberg Sync publish logic into a reusable workflow. Add reusable-gutenberg-sync-pr.yml (workflow_call) that pushes the gutenberg-sync/ branch to the fork and opens or updates the upstream pull request. The trunk and version-branch jobs now only resolve the new SHA, title, and body (uploaded as an artifact) and expose them as outputs; the open-pull-request job calls the reusable workflow with those values. This removes the push/PR steps that were duplicated across the two jobs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 293 +++++------------- .../workflows/reusable-gutenberg-sync-pr.yml | 171 ++++++++++ 2 files changed, 243 insertions(+), 221 deletions(-) create mode 100644 .github/workflows/reusable-gutenberg-sync-pr.yml diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 7fb19e3203233..2756af3e6087b 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -1,22 +1,16 @@ -# Opens (or updates) a single draft pull request that updates the bundled -# Gutenberg commit reference (package.json -> gutenberg.sha) for one branch: +# Resolves the bundled Gutenberg commit reference (package.json -> gutenberg.sha) +# for one branch, then opens (or updates) a draft pull request via the reusable +# reusable-gutenberg-sync-pr.yml workflow: # - "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 opens a pull -# request from that fork branch back to upstream. Each run targets one branch: -# the "trunk" job or the "version-branch" job runs depending on the input. +# runs from a FORK. The resolve job builds the pull request body and uploads it +# as an artifact; the reusable workflow pushes the gutenberg-sync/ branch +# to the fork and opens the pull request back to upstream. # # The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml # when a gutenberg-sync/* pull request is opened. -# -# Authentication: -# - GITHUB_TOKEN (built in) pushes the branch to the fork. -# - GUTENBERG_SYNC_TOKEN (optional secret) is a personal access token used to -# open/update the pull request on upstream. Without it, the branch is still -# pushed and the run prints a link to open the pull request manually. name: Gutenberg Sync on: @@ -33,11 +27,8 @@ on: 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. + # The target 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: @@ -45,25 +36,27 @@ concurrency: cancel-in-progress: false jobs: - # Syncs trunk to the commit of Gutenberg's latest public (non-prerelease) release. + # Resolves trunk against Gutenberg's latest public (non-prerelease) release. trunk: - name: Sync trunk + name: Resolve trunk runs-on: ubuntu-24.04 # Runs from a fork; the canonical repository does not accept pushed branches. if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch == 'trunk' }} timeout-minutes: 10 permissions: - # Push the gutenberg-sync/ branch to the fork this workflow runs in. - contents: write + contents: read + outputs: + new-sha: ${{ steps.resolved.outputs.new-sha }} + pr-title: ${{ steps.resolved.outputs.pr-title }} + proceed: ${{ steps.resolved.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: 0 - token: ${{ github.token }} - persist-credentials: true + fetch-depth: 1 + persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout the pull request body template @@ -75,11 +68,6 @@ jobs: persist-credentials: false 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: Copy the pull request body template run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md @@ -138,119 +126,45 @@ jobs: OLD_SHA=$(jq -r '.gutenberg.sha' package.json) if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" - - name: Create the sync branch - if: ${{ env.PROCEED == 'true' }} - run: | - git checkout -B "${HEAD_BRANCH}" - - - name: Update the bundled Gutenberg SHA - if: ${{ env.PROCEED == 'true' }} - run: | - set -euo pipefail - jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp - mv package.json.tmp package.json - - - name: Stage the change - if: ${{ env.PROCEED == 'true' }} - run: | - git add package.json - - - name: Commit the change - if: ${{ env.PROCEED == 'true' }} - run: | - git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." - - - name: Add the fork remote - if: ${{ env.PROCEED == 'true' }} - env: - FORK_REPO: ${{ github.repository }} - run: | - git remote add fork "https://github.com/${FORK_REPO}.git" - - - name: Push to the fork - if: ${{ env.PROCEED == 'true' }} + - name: Export the resolved values + id: resolved run: | - git push --force fork "HEAD:${HEAD_BRANCH}" + { + echo "new-sha=${NEW_SHA}" + echo "pr-title=${PR_TITLE}" + echo "proceed=${PROCEED}" + } >> "$GITHUB_OUTPUT" - - name: Look up the existing pull request + - name: Upload the pull request body if: ${{ env.PROCEED == 'true' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} - FORK_OWNER: ${{ github.repository_owner }} - run: | - set -euo pipefail - - # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch - # is already pushed, so surface a link to open the PR manually. - if [ "${HAS_PAT}" != "true" ]; then - compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" - { - echo "### Gutenberg Sync" - echo "" - echo "Pushed \`${HEAD_BRANCH}\` to your fork." - echo "" - echo "[Open the pull request](${compare_url})" - } >> "$GITHUB_STEP_SUMMARY" - echo "PR_ACTION=manual" >> "$GITHUB_ENV" - exit 0 - fi - - # Identify an existing open PR for this base by the sync head branch name. - prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ - --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ - --json number,headRepositoryOwner) - ours=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") - others=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") - - if [ -n "$ours" ]; then - { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" - elif [ -n "$others" ]; then - echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." - echo "PR_ACTION=skip" >> "$GITHUB_ENV" - else - echo "PR_ACTION=create" >> "$GITHUB_ENV" - fi - - - name: Update the existing pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - run: | - gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md - - - name: Open a new pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - FORK_OWNER: ${{ github.repository_owner }} - run: | - gh pr create --repo "$UPSTREAM_REPO" --draft \ - --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gutenberg-sync-pr-body + path: pr_body.md + if-no-files-found: error + retention-days: 1 - # Syncs a version branch to the head of Gutenberg's matching wp/X.Y branch. + # Resolves a version branch against the head of Gutenberg's matching wp/X.Y branch. version-branch: - name: Sync ${{ inputs.branch }} + name: Resolve ${{ inputs.branch }} runs-on: ubuntu-24.04 # Runs from a fork; the canonical repository does not accept pushed branches. if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch != 'trunk' }} timeout-minutes: 10 permissions: - # Push the gutenberg-sync/ branch to the fork this workflow runs in. - contents: write + contents: read + outputs: + new-sha: ${{ steps.resolved.outputs.new-sha }} + pr-title: ${{ steps.resolved.outputs.pr-title }} + proceed: ${{ steps.resolved.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: 0 - token: ${{ github.token }} - persist-credentials: true + fetch-depth: 1 + persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout the pull request body template @@ -269,15 +183,9 @@ jobs: ref: wp/${{ env.BASE_BRANCH }} path: gutenberg-src fetch-depth: 0 - token: ${{ github.token }} persist-credentials: false 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: Copy the pull request body template run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md @@ -334,96 +242,39 @@ jobs: awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - - name: Create the sync branch - if: ${{ env.PROCEED == 'true' }} + - name: Export the resolved values + id: resolved run: | - git checkout -B "${HEAD_BRANCH}" - - - name: Update the bundled Gutenberg SHA - if: ${{ env.PROCEED == 'true' }} - run: | - set -euo pipefail - jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp - mv package.json.tmp package.json - - - name: Stage the change - if: ${{ env.PROCEED == 'true' }} - run: | - git add package.json - - - name: Commit the change - if: ${{ env.PROCEED == 'true' }} - run: | - git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." - - - name: Add the fork remote - if: ${{ env.PROCEED == 'true' }} - env: - FORK_REPO: ${{ github.repository }} - run: | - git remote add fork "https://github.com/${FORK_REPO}.git" - - - name: Push to the fork - if: ${{ env.PROCEED == 'true' }} - run: | - git push --force fork "HEAD:${HEAD_BRANCH}" + { + echo "new-sha=${NEW_SHA}" + echo "pr-title=${PR_TITLE}" + echo "proceed=${PROCEED}" + } >> "$GITHUB_OUTPUT" - - name: Look up the existing pull request + - name: Upload the pull request body if: ${{ env.PROCEED == 'true' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} - FORK_OWNER: ${{ github.repository_owner }} - run: | - set -euo pipefail - - # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch - # is already pushed, so surface a link to open the PR manually. - if [ "${HAS_PAT}" != "true" ]; then - compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" - { - echo "### Gutenberg Sync" - echo "" - echo "Pushed \`${HEAD_BRANCH}\` to your fork." - echo "" - echo "[Open the pull request](${compare_url})" - } >> "$GITHUB_STEP_SUMMARY" - echo "PR_ACTION=manual" >> "$GITHUB_ENV" - exit 0 - fi - - # Identify an existing open PR for this base by the sync head branch name. - prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ - --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ - --json number,headRepositoryOwner) - ours=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") - others=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") - - if [ -n "$ours" ]; then - { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" - elif [ -n "$others" ]; then - echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." - echo "PR_ACTION=skip" >> "$GITHUB_ENV" - else - echo "PR_ACTION=create" >> "$GITHUB_ENV" - fi - - - name: Update the existing pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - run: | - gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md - - - name: Open a new pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - FORK_OWNER: ${{ github.repository_owner }} - run: | - gh pr create --repo "$UPSTREAM_REPO" --draft \ - --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gutenberg-sync-pr-body + path: pr_body.md + if-no-files-found: error + retention-days: 1 + + # Pushes the branch to the fork and opens (or updates) the pull request. + open-pull-request: + name: Open pull request + needs: [ trunk, version-branch ] + if: >- + ${{ !cancelled() && + ( ( needs.trunk.result == 'success' && needs.trunk.outputs.proceed == 'true' ) || + ( needs.version-branch.result == 'success' && needs.version-branch.outputs.proceed == 'true' ) ) }} + permissions: + contents: write + uses: ./.github/workflows/reusable-gutenberg-sync-pr.yml + with: + base-branch: ${{ inputs.branch }} + new-sha: ${{ needs.trunk.outputs.new-sha || needs.version-branch.outputs.new-sha }} + pr-title: ${{ needs.trunk.outputs.pr-title || needs.version-branch.outputs.pr-title }} + body-artifact: gutenberg-sync-pr-body + secrets: + GUTENBERG_SYNC_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN }} diff --git a/.github/workflows/reusable-gutenberg-sync-pr.yml b/.github/workflows/reusable-gutenberg-sync-pr.yml new file mode 100644 index 0000000000000..af7c2136489d6 --- /dev/null +++ b/.github/workflows/reusable-gutenberg-sync-pr.yml @@ -0,0 +1,171 @@ +## +# A reusable workflow that publishes a Gutenberg Sync pull request. +# +# The caller resolves the new commit SHA, the pull request title, and the body +# (uploaded as an artifact). This workflow then pushes a gutenberg-sync/ +# branch to the fork it runs in and opens (or updates) a draft pull request +# against upstream (WordPress/wordpress-develop). +# +# Authentication: +# - GITHUB_TOKEN (built in) pushes the branch to the fork. +# - GUTENBERG_SYNC_TOKEN (optional) is a personal access token used to open or +# update the pull request on upstream. Without it, the branch is still +# pushed and the run prints a link to open the pull request manually. +## +name: Open a Gutenberg Sync pull request + +on: + workflow_call: + inputs: + base-branch: + description: 'The upstream branch to target (e.g. "trunk" or "6.8").' + required: true + type: string + new-sha: + description: 'The Gutenberg commit SHA to record in package.json.' + required: true + type: string + pr-title: + description: 'The pull request title.' + required: true + type: string + body-artifact: + description: 'Name of the artifact containing the pull request body (pr_body.md).' + required: true + type: string + secrets: + GUTENBERG_SYNC_TOKEN: + description: 'Personal access token used to open/update the pull request on upstream. Optional.' + required: false + +# Disable permissions for all available scopes by default. +# Any needed permissions are configured at the job level. +permissions: {} + +env: + UPSTREAM_REPO: WordPress/wordpress-develop + HEAD_BRANCH: gutenberg-sync/${{ inputs.base-branch }} + +jobs: + open-pull-request: + name: Open or update the pull request + 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: + # Push the gutenberg-sync/ branch to the fork this workflow runs in. + contents: write + steps: + - name: Checkout the upstream target branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: WordPress/wordpress-develop + ref: ${{ inputs.base-branch }} + fetch-depth: 0 + token: ${{ github.token }} + persist-credentials: true + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + + - name: Download the pull request body + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.body-artifact }} + + - 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 the sync branch + run: | + git checkout -B "${HEAD_BRANCH}" + + - name: Update the bundled Gutenberg SHA + env: + NEW_SHA: ${{ inputs.new-sha }} + run: | + set -euo pipefail + jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Stage the change + run: | + git add package.json + + - name: Commit the change + run: | + git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." + + - name: Add the fork remote + env: + FORK_REPO: ${{ github.repository }} + run: | + git remote add fork "https://github.com/${FORK_REPO}.git" + + - name: Push to the fork + run: | + git push --force fork "HEAD:${HEAD_BRANCH}" + + - name: Look up the existing pull request + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} + FORK_OWNER: ${{ github.repository_owner }} + BASE_BRANCH: ${{ inputs.base-branch }} + run: | + set -euo pipefail + + # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch + # is already pushed, so surface a link to open the PR manually. + if [ "${HAS_PAT}" != "true" ]; then + compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" + echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" + { + echo "### Gutenberg Sync" + echo "" + echo "Pushed \`${HEAD_BRANCH}\` to your fork." + echo "" + echo "[Open the pull request](${compare_url})" + } >> "$GITHUB_STEP_SUMMARY" + echo "PR_ACTION=manual" >> "$GITHUB_ENV" + exit 0 + fi + + # Identify an existing open PR for this base by the sync head branch name. + prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ + --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ + --json number,headRepositoryOwner) + ours=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") + others=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") + + if [ -n "$ours" ]; then + { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" + elif [ -n "$others" ]; then + echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." + echo "PR_ACTION=skip" >> "$GITHUB_ENV" + else + echo "PR_ACTION=create" >> "$GITHUB_ENV" + fi + + - name: Update the existing pull request + if: ${{ env.PR_ACTION == 'update' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + PR_TITLE: ${{ inputs.pr-title }} + run: | + gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + + - name: Open a new pull request + if: ${{ env.PR_ACTION == 'create' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + FORK_OWNER: ${{ github.repository_owner }} + BASE_BRANCH: ${{ inputs.base-branch }} + PR_TITLE: ${{ inputs.pr-title }} + run: | + gh pr create --repo "$UPSTREAM_REPO" --draft \ + --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ + --title "$PR_TITLE" --body-file pr_body.md From 39ac88e921737442c3723d8ecbdb2eb4137a5b3d Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:33:57 -0400 Subject: [PATCH 24/38] Build/Test Tools: Revert Gutenberg Sync to a single job with inline branch conditions. Remove the reusable-gutenberg-sync-pr.yml workflow and the resolve/publish job split, returning to one sync job whose trunk- and version-specific steps are gated with inputs.branch conditions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 246 +++++++++++------- .../workflows/reusable-gutenberg-sync-pr.yml | 171 ------------ 2 files changed, 146 insertions(+), 271 deletions(-) delete mode 100644 .github/workflows/reusable-gutenberg-sync-pr.yml diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 2756af3e6087b..febb2f6465ff9 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -1,16 +1,21 @@ -# Resolves the bundled Gutenberg commit reference (package.json -> gutenberg.sha) -# for one branch, then opens (or updates) a draft pull request via the reusable -# reusable-gutenberg-sync-pr.yml workflow: +# Opens (or updates) a single draft pull request 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. The resolve job builds the pull request body and uploads it -# as an artifact; the reusable workflow pushes the gutenberg-sync/ branch -# to the fork and opens the pull request back to upstream. +# runs from a FORK: it checks out the target branch from upstream, pushes a +# gutenberg-sync/ branch to the fork it runs in, and opens a pull +# request from that fork branch back to upstream. Each run targets one branch. # # The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml # when a gutenberg-sync/* pull request is opened. +# +# Authentication: +# - GITHUB_TOKEN (built in) pushes the branch to the fork. +# - GUTENBERG_SYNC_TOKEN (optional secret) is a personal access token used to +# open/update the pull request on upstream. Without it, the branch is still +# pushed and the run prints a link to open the pull request manually. name: Gutenberg Sync on: @@ -27,8 +32,11 @@ on: permissions: {} env: - # The target branch, derived from the input. + # 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: @@ -36,27 +44,27 @@ concurrency: cancel-in-progress: false jobs: - # Resolves trunk against Gutenberg's latest public (non-prerelease) release. - trunk: - name: Resolve trunk + # Creates or updates the draft pull request for the requested branch. + # Trunk-specific and version-specific work is gated on inputs.branch; every + # other step is shared. + sync: + name: Sync ${{ inputs.branch }} runs-on: ubuntu-24.04 # Runs from a fork; the canonical repository does not accept pushed branches. - if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch == 'trunk' }} + if: ${{ github.repository != 'WordPress/wordpress-develop' }} timeout-minutes: 10 permissions: - contents: read - outputs: - new-sha: ${{ steps.resolved.outputs.new-sha }} - pr-title: ${{ steps.resolved.outputs.pr-title }} - proceed: ${{ steps.resolved.outputs.proceed }} + # Push the gutenberg-sync/ branch to the fork this workflow runs in. + 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: 1 - persist-credentials: false + fetch-depth: 0 + token: ${{ github.token }} + persist-credentials: true show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout the pull request body template @@ -68,15 +76,25 @@ jobs: persist-credentials: false 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" + + # ----- Resolve the trunk release (inputs.branch == 'trunk') ----- + - name: Copy the pull request body template + if: ${{ inputs.branch == 'trunk' }} run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - name: Add the branch name to the body + if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - name: Retrieve the latest release details + if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} run: | @@ -93,10 +111,12 @@ jobs: } >> "$GITHUB_ENV" - name: Add the release link to the body + if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md - name: Determine the new commit SHA + if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} run: | @@ -112,97 +132,59 @@ jobs: echo "NEW_SHA=${NEW_SHA}" >> "$GITHUB_ENV" - name: Add the new SHA to the body + if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Add the release notes to the body + if: ${{ inputs.branch == 'trunk' }} run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - name: Determine the current SHA + if: ${{ inputs.branch == 'trunk' }} run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" - - name: Export the resolved values - id: resolved - run: | - { - echo "new-sha=${NEW_SHA}" - echo "pr-title=${PR_TITLE}" - echo "proceed=${PROCEED}" - } >> "$GITHUB_OUTPUT" - - - name: Upload the pull request body - if: ${{ env.PROCEED == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: gutenberg-sync-pr-body - path: pr_body.md - if-no-files-found: error - retention-days: 1 - - # Resolves a version branch against the head of Gutenberg's matching wp/X.Y branch. - version-branch: - name: Resolve ${{ inputs.branch }} - runs-on: ubuntu-24.04 - # Runs from a fork; the canonical repository does not accept pushed branches. - if: ${{ github.repository != 'WordPress/wordpress-develop' && inputs.branch != 'trunk' }} - timeout-minutes: 10 - permissions: - contents: read - outputs: - new-sha: ${{ steps.resolved.outputs.new-sha }} - pr-title: ${{ steps.resolved.outputs.pr-title }} - proceed: ${{ steps.resolved.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: 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 - path: sync-workflow - persist-credentials: false - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + # ----- Resolve the wp/X.Y head (inputs.branch != 'trunk') ----- - name: Checkout the Gutenberg branch + if: ${{ inputs.branch != 'trunk' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: WordPress/gutenberg ref: wp/${{ env.BASE_BRANCH }} path: gutenberg-src fetch-depth: 0 + token: ${{ github.token }} persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Copy the pull request body template + if: ${{ inputs.branch != 'trunk' }} run: | cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - name: Add the branch name to the body + if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - name: Determine the new commit SHA + if: ${{ inputs.branch != 'trunk' }} run: | echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV" - name: Add the new SHA to the body + if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Determine the current SHA + if: ${{ inputs.branch != 'trunk' }} run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) @@ -213,10 +195,12 @@ jobs: } >> "$GITHUB_ENV" - name: Add the compare link to the body + if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md - name: Build the changelog + if: ${{ inputs.branch != 'trunk' }} run: | set -euo pipefail if ! git -C gutenberg-src cat-file -e "${OLD_SHA}^{commit}" 2>/dev/null; then @@ -238,43 +222,105 @@ jobs: fi - name: Add the changelog to the body + if: ${{ inputs.branch != 'trunk' }} run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md - - name: Export the resolved values - id: resolved + # ----- Push the sync branch to the fork ----- + + - name: Create the sync branch + if: ${{ env.PROCEED == 'true' }} run: | - { - echo "new-sha=${NEW_SHA}" - echo "pr-title=${PR_TITLE}" - echo "proceed=${PROCEED}" - } >> "$GITHUB_OUTPUT" + git checkout -B "${HEAD_BRANCH}" - - name: Upload the pull request body + - name: Update the bundled Gutenberg SHA if: ${{ env.PROCEED == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: gutenberg-sync-pr-body - path: pr_body.md - if-no-files-found: error - retention-days: 1 - - # Pushes the branch to the fork and opens (or updates) the pull request. - open-pull-request: - name: Open pull request - needs: [ trunk, version-branch ] - if: >- - ${{ !cancelled() && - ( ( needs.trunk.result == 'success' && needs.trunk.outputs.proceed == 'true' ) || - ( needs.version-branch.result == 'success' && needs.version-branch.outputs.proceed == 'true' ) ) }} - permissions: - contents: write - uses: ./.github/workflows/reusable-gutenberg-sync-pr.yml - with: - base-branch: ${{ inputs.branch }} - new-sha: ${{ needs.trunk.outputs.new-sha || needs.version-branch.outputs.new-sha }} - pr-title: ${{ needs.trunk.outputs.pr-title || needs.version-branch.outputs.pr-title }} - body-artifact: gutenberg-sync-pr-body - secrets: - GUTENBERG_SYNC_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN }} + run: | + set -euo pipefail + jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Stage the change + if: ${{ env.PROCEED == 'true' }} + run: | + git add package.json + + - name: Commit the change + if: ${{ env.PROCEED == 'true' }} + run: | + git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." + + - name: Add the fork remote + if: ${{ env.PROCEED == 'true' }} + env: + FORK_REPO: ${{ github.repository }} + run: | + git remote add fork "https://github.com/${FORK_REPO}.git" + + - name: Push to the fork + if: ${{ env.PROCEED == 'true' }} + run: | + git push --force fork "HEAD:${HEAD_BRANCH}" + + # ----- Open or update the upstream pull request ----- + + - name: Look up the existing pull request + if: ${{ env.PROCEED == 'true' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} + FORK_OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + + # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch + # is already pushed, so surface a link to open the PR manually. + if [ "${HAS_PAT}" != "true" ]; then + compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" + echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" + { + echo "### Gutenberg Sync" + echo "" + echo "Pushed \`${HEAD_BRANCH}\` to your fork." + echo "" + echo "[Open the pull request](${compare_url})" + } >> "$GITHUB_STEP_SUMMARY" + echo "PR_ACTION=manual" >> "$GITHUB_ENV" + exit 0 + fi + + # Identify an existing open PR for this base by the sync head branch name. + prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ + --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ + --json number,headRepositoryOwner) + ours=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") + others=$(jq -r --arg o "$FORK_OWNER" \ + '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") + + if [ -n "$ours" ]; then + { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" + elif [ -n "$others" ]; then + echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." + echo "PR_ACTION=skip" >> "$GITHUB_ENV" + else + echo "PR_ACTION=create" >> "$GITHUB_ENV" + fi + + - name: Update the existing pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + run: | + gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md + + - name: Open a new pull request + if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} + env: + GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} + FORK_OWNER: ${{ github.repository_owner }} + run: | + gh pr create --repo "$UPSTREAM_REPO" --draft \ + --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ + --title "$PR_TITLE" --body-file pr_body.md diff --git a/.github/workflows/reusable-gutenberg-sync-pr.yml b/.github/workflows/reusable-gutenberg-sync-pr.yml deleted file mode 100644 index af7c2136489d6..0000000000000 --- a/.github/workflows/reusable-gutenberg-sync-pr.yml +++ /dev/null @@ -1,171 +0,0 @@ -## -# A reusable workflow that publishes a Gutenberg Sync pull request. -# -# The caller resolves the new commit SHA, the pull request title, and the body -# (uploaded as an artifact). This workflow then pushes a gutenberg-sync/ -# branch to the fork it runs in and opens (or updates) a draft pull request -# against upstream (WordPress/wordpress-develop). -# -# Authentication: -# - GITHUB_TOKEN (built in) pushes the branch to the fork. -# - GUTENBERG_SYNC_TOKEN (optional) is a personal access token used to open or -# update the pull request on upstream. Without it, the branch is still -# pushed and the run prints a link to open the pull request manually. -## -name: Open a Gutenberg Sync pull request - -on: - workflow_call: - inputs: - base-branch: - description: 'The upstream branch to target (e.g. "trunk" or "6.8").' - required: true - type: string - new-sha: - description: 'The Gutenberg commit SHA to record in package.json.' - required: true - type: string - pr-title: - description: 'The pull request title.' - required: true - type: string - body-artifact: - description: 'Name of the artifact containing the pull request body (pr_body.md).' - required: true - type: string - secrets: - GUTENBERG_SYNC_TOKEN: - description: 'Personal access token used to open/update the pull request on upstream. Optional.' - required: false - -# Disable permissions for all available scopes by default. -# Any needed permissions are configured at the job level. -permissions: {} - -env: - UPSTREAM_REPO: WordPress/wordpress-develop - HEAD_BRANCH: gutenberg-sync/${{ inputs.base-branch }} - -jobs: - open-pull-request: - name: Open or update the pull request - 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: - # Push the gutenberg-sync/ branch to the fork this workflow runs in. - contents: write - steps: - - name: Checkout the upstream target branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: WordPress/wordpress-develop - ref: ${{ inputs.base-branch }} - fetch-depth: 0 - token: ${{ github.token }} - persist-credentials: true - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - - name: Download the pull request body - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.body-artifact }} - - - 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 the sync branch - run: | - git checkout -B "${HEAD_BRANCH}" - - - name: Update the bundled Gutenberg SHA - env: - NEW_SHA: ${{ inputs.new-sha }} - run: | - set -euo pipefail - jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp - mv package.json.tmp package.json - - - name: Stage the change - run: | - git add package.json - - - name: Commit the change - run: | - git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." - - - name: Add the fork remote - env: - FORK_REPO: ${{ github.repository }} - run: | - git remote add fork "https://github.com/${FORK_REPO}.git" - - - name: Push to the fork - run: | - git push --force fork "HEAD:${HEAD_BRANCH}" - - - name: Look up the existing pull request - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} - FORK_OWNER: ${{ github.repository_owner }} - BASE_BRANCH: ${{ inputs.base-branch }} - run: | - set -euo pipefail - - # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch - # is already pushed, so surface a link to open the PR manually. - if [ "${HAS_PAT}" != "true" ]; then - compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" - { - echo "### Gutenberg Sync" - echo "" - echo "Pushed \`${HEAD_BRANCH}\` to your fork." - echo "" - echo "[Open the pull request](${compare_url})" - } >> "$GITHUB_STEP_SUMMARY" - echo "PR_ACTION=manual" >> "$GITHUB_ENV" - exit 0 - fi - - # Identify an existing open PR for this base by the sync head branch name. - prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ - --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ - --json number,headRepositoryOwner) - ours=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") - others=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") - - if [ -n "$ours" ]; then - { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" - elif [ -n "$others" ]; then - echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." - echo "PR_ACTION=skip" >> "$GITHUB_ENV" - else - echo "PR_ACTION=create" >> "$GITHUB_ENV" - fi - - - name: Update the existing pull request - if: ${{ env.PR_ACTION == 'update' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - PR_TITLE: ${{ inputs.pr-title }} - run: | - gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md - - - name: Open a new pull request - if: ${{ env.PR_ACTION == 'create' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - FORK_OWNER: ${{ github.repository_owner }} - BASE_BRANCH: ${{ inputs.base-branch }} - PR_TITLE: ${{ inputs.pr-title }} - run: | - gh pr create --repo "$UPSTREAM_REPO" --draft \ - --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md From affe1d9a98681a278dbd6451a195bdc87fffa737 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:39:12 -0400 Subject: [PATCH 25/38] Build/Test Tools: Output the Gutenberg Sync PR body to the job summary instead of opening a PR. The compare link cannot carry the generated body, so stop creating/updating the pull request (and drop the GUTENBERG_SYNC_TOKEN PAT). The workflow now pushes the branch to the fork and writes the PR title and body to the job summary, with the body in a fenced block for copy/paste into a manually opened pull request. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 92 ++++++++-------------------- 1 file changed, 27 insertions(+), 65 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index febb2f6465ff9..88d72ca906787 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -1,21 +1,17 @@ -# Opens (or updates) a single draft pull request that updates the bundled -# Gutenberg commit reference (package.json -> gutenberg.sha) for one branch: +# 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 opens a pull -# request from that fork branch back to upstream. Each run targets one branch. +# 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 "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml # when a gutenberg-sync/* pull request is opened. -# -# Authentication: -# - GITHUB_TOKEN (built in) pushes the branch to the fork. -# - GUTENBERG_SYNC_TOKEN (optional secret) is a personal access token used to -# open/update the pull request on upstream. Without it, the branch is still -# pushed and the run prints a link to open the pull request manually. name: Gutenberg Sync on: @@ -263,64 +259,30 @@ jobs: run: | git push --force fork "HEAD:${HEAD_BRANCH}" - # ----- Open or update the upstream pull request ----- + # ----- Output the pull request details for manual submission ----- - - name: Look up the existing pull request + - name: Output the pull request details if: ${{ env.PROCEED == 'true' }} env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - HAS_PAT: ${{ secrets.GUTENBERG_SYNC_TOKEN != '' }} FORK_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail - - # Without a PAT, the GITHUB_TOKEN cannot open a PR on upstream. The branch - # is already pushed, so surface a link to open the PR manually. - if [ "${HAS_PAT}" != "true" ]; then - compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" - echo "::notice::Branch '${HEAD_BRANCH}' pushed to ${FORK_OWNER}. Set the GUTENBERG_SYNC_TOKEN secret to open PRs automatically, or open it here: ${compare_url}" - { - echo "### Gutenberg Sync" - echo "" - echo "Pushed \`${HEAD_BRANCH}\` to your fork." - echo "" - echo "[Open the pull request](${compare_url})" - } >> "$GITHUB_STEP_SUMMARY" - echo "PR_ACTION=manual" >> "$GITHUB_ENV" - exit 0 - fi - - # Identify an existing open PR for this base by the sync head branch name. - prs=$(gh pr list --repo "$UPSTREAM_REPO" --state open \ - --base "$BASE_BRANCH" --head "$HEAD_BRANCH" \ - --json number,headRepositoryOwner) - ours=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login == $o)][0].number // empty' <<<"$prs") - others=$(jq -r --arg o "$FORK_OWNER" \ - '[.[] | select(.headRepositoryOwner.login != $o)][0].number // empty' <<<"$prs") - - if [ -n "$ours" ]; then - { echo "PR_NUMBER=${ours}"; echo "PR_ACTION=update"; } >> "$GITHUB_ENV" - elif [ -n "$others" ]; then - echo "::notice::An open ${HEAD_BRANCH} PR for ${BASE_BRANCH} already exists (#${others}); leaving it as-is." - echo "PR_ACTION=skip" >> "$GITHUB_ENV" - else - echo "PR_ACTION=create" >> "$GITHUB_ENV" - fi - - - name: Update the existing pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'update' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - run: | - gh pr edit "$PR_NUMBER" --repo "$UPSTREAM_REPO" --title "$PR_TITLE" --body-file pr_body.md - - - name: Open a new pull request - if: ${{ env.PROCEED == 'true' && env.PR_ACTION == 'create' }} - env: - GH_TOKEN: ${{ secrets.GUTENBERG_SYNC_TOKEN || github.token }} - FORK_OWNER: ${{ github.repository_owner }} - run: | - gh pr create --repo "$UPSTREAM_REPO" --draft \ - --base "$BASE_BRANCH" --head "${FORK_OWNER}:${HEAD_BRANCH}" \ - --title "$PR_TITLE" --body-file pr_body.md + 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}\`. [Open a pull request](${compare_url}) against \`${UPSTREAM_REPO}\`, then copy the title and body below into it." + echo "" + echo "### Title" + echo "" + echo '```' + echo "${PR_TITLE}" + echo '```' + echo "" + echo "### 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" From 91bfd1ec87bd06dc3e561a6ab0d8936600076e23 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:18:55 -0400 Subject: [PATCH 26/38] Reorganize logic into separate jobs. --- .github/workflows/gutenberg-sync.yml | 240 +++++++++++++-------------- 1 file changed, 114 insertions(+), 126 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 88d72ca906787..d5a80ed0e3f95 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -43,15 +43,12 @@ jobs: # Creates or updates the draft pull request for the requested branch. # Trunk-specific and version-specific work is gated on inputs.branch; every # other step is shared. - sync: + research: name: Sync ${{ inputs.branch }} 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: - # Push the gutenberg-sync/ branch to the fork this workflow runs in. - contents: write steps: - name: Checkout the upstream target branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -63,33 +60,19 @@ jobs: persist-credentials: true show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Checkout the pull request body template + - name: Checkout the target branch in Gutenberg + if: ${{ inputs.branch != 'trunk' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .github/template-gutenberg-sync-pr-body.md - sparse-checkout-cone-mode: false - path: sync-workflow + repository: WordPress/gutenberg + ref: wp/${{ env.BASE_BRANCH }} + path: gutenberg-src + fetch-depth: 0 + token: ${{ github.token }} persist-credentials: false 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" - - # ----- Resolve the trunk release (inputs.branch == 'trunk') ----- - - - name: Copy the pull request body template - if: ${{ inputs.branch == 'trunk' }} - run: | - cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md - - - name: Add the branch name to the body - if: ${{ inputs.branch == 'trunk' }} - run: | - sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - - - name: Retrieve the latest release details + - name: Retrieve the latest release details (trunk only) if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} @@ -106,12 +89,29 @@ jobs: echo "PR_TITLE=Gutenberg Sync: Update trunk to ${TAG}" } >> "$GITHUB_ENV" - - name: Add the release link to the body - if: ${{ inputs.branch == 'trunk' }} + - name: Build the changelog + if: ${{ inputs.branch != 'trunk' }} run: | - sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md + 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.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 - - name: Determine the new commit SHA + # Cap the changelog 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 > changelog_content.md + printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md + else + cp commit_list_full.md changelog_content.md + fi + fi + + - name: Determine the new commit SHA (trunk) if: ${{ inputs.branch == 'trunk' }} env: GH_TOKEN: ${{ github.token }} @@ -127,162 +127,150 @@ jobs: fi echo "NEW_SHA=${NEW_SHA}" >> "$GITHUB_ENV" - - name: Add the new SHA to the body - if: ${{ inputs.branch == 'trunk' }} - run: | - sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - - - name: Add the release notes to the body - if: ${{ inputs.branch == 'trunk' }} + - name: Determine the new commit SHA (version branches) + if: ${{ inputs.branch != 'trunk' }} run: | - awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp - mv pr_body.tmp pr_body.md + echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV" - - name: Determine the current SHA + - name: Determine the current SHA (trunk) if: ${{ inputs.branch == 'trunk' }} run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" - # ----- Resolve the wp/X.Y head (inputs.branch != 'trunk') ----- - - - name: Checkout the Gutenberg branch + - 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=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" + + build-pr-body: + name: Build pull request body + runs-on: 'ubuntu-24.04' + needs: [ 'research' ] + steps: + - name: Checkout the upstream target branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - repository: WordPress/gutenberg - ref: wp/${{ env.BASE_BRANCH }} - path: gutenberg-src + repository: WordPress/wordpress-develop + ref: ${{ env.BASE_BRANCH }} fetch-depth: 0 token: ${{ github.token }} - persist-credentials: false + persist-credentials: true show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Copy the pull request body template - if: ${{ inputs.branch != 'trunk' }} - run: | - cp sync-workflow/.github/template-gutenberg-sync-pr-body.md pr_body.md + run: cp .github/template-gutenberg-sync-pr-body.md sync_pr_body.md - name: Add the branch name to the body - if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - - name: Determine the new commit SHA - if: ${{ inputs.branch != 'trunk' }} - run: | - echo "NEW_SHA=$(git -C gutenberg-src rev-parse HEAD)" >> "$GITHUB_ENV" - - - name: Add the new SHA to the body - if: ${{ inputs.branch != 'trunk' }} + - name: Add release notes to the body (trunk only) + if: ${{ inputs.branch == 'trunk' }} run: | - sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md + awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp + mv pr_body.tmp pr_body.md - - name: Determine the current SHA - if: ${{ inputs.branch != 'trunk' }} + - name: Add the release link to the body (trunk only) + if: ${{ inputs.branch == 'trunk' }} run: | - set -euo pipefail - OLD_SHA=$(jq -r '.gutenberg.sha' package.json) - { - echo "OLD_SHA=${OLD_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" + sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md - name: Add the compare link to the body if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md - - name: Build the changelog - if: ${{ inputs.branch != 'trunk' }} + - name: Add the new SHA to the body + run: | + sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md + + - name: Output pull request details + if: ${{ env.PROCEED == 'true' }} + env: + FORK_OWNER: ${{ github.repository_owner }} 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.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 + 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}\`. [Open a pull request](${compare_url}) against \`${UPSTREAM_REPO}\`, then copy the title and body below into it." + echo "" + echo "### Title" + echo "" + echo '```' + echo "${PR_TITLE}" + echo '```' + echo "" + echo "### 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" - # Cap the changelog 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 > changelog_content.md - printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md - else - cp commit_list_full.md changelog_content.md - fi - fi + create-update-branch: + name: Update pinned hash value and push + runs-on: 'ubuntu-24.04' + needs: [ 'research' ] + permissions: + content: write - - name: Add the changelog to the body - if: ${{ inputs.branch != 'trunk' }} - run: | - awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp - mv pr_body.tmp pr_body.md + 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' }} - # ----- Push the sync branch to the fork ----- + - 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 the sync branch + - name: Create a sync branch if: ${{ env.PROCEED == 'true' }} run: | git checkout -B "${HEAD_BRANCH}" - - name: Update the bundled Gutenberg SHA + - name: Update the pinned hash value if: ${{ env.PROCEED == 'true' }} run: | set -euo pipefail jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp mv package.json.tmp package.json - - name: Stage the change + - name: Stage changes if: ${{ env.PROCEED == 'true' }} run: | git add package.json - - name: Commit the change + - name: Commit changes if: ${{ env.PROCEED == 'true' }} run: | git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." - - name: Add the fork remote + - name: Configure fork as a remote if: ${{ env.PROCEED == 'true' }} env: FORK_REPO: ${{ github.repository }} run: | git remote add fork "https://github.com/${FORK_REPO}.git" - - name: Push to the fork + - name: Push changes to fork if: ${{ env.PROCEED == 'true' }} run: | git push --force fork "HEAD:${HEAD_BRANCH}" - - # ----- Output the pull request details for manual submission ----- - - - name: Output the pull request details - if: ${{ env.PROCEED == 'true' }} - env: - FORK_OWNER: ${{ github.repository_owner }} - 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}\`. [Open a pull request](${compare_url}) against \`${UPSTREAM_REPO}\`, then copy the title and body below into it." - echo "" - echo "### Title" - echo "" - echo '```' - echo "${PR_TITLE}" - echo '```' - echo "" - echo "### 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" From e331e2da5912b94e6c73d979750980cf01e210b7 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:22:05 -0400 Subject: [PATCH 27/38] Build/Test Tools: Wire Gutenberg Sync data flow across the split jobs. The reorganized workflow split resolve, body-building, and push into separate jobs, but the data did not cross the job boundaries. Fix that: - Expose research's resolved values (new/old SHA, title, tag, release URL, proceed) as job outputs and consume them downstream via needs.research.outputs. - Gate build-pr-body and create-update-branch at the job level on the proceed output instead of the per-step env.PROCEED checks, which were empty in those jobs. - Pass the changelog between jobs as an artifact. - Reorder research so the changelog is built after the SHAs are determined. - Build the PR body template from the fork (sparse checkout) and fix the pr_body.md filename and the missing changelog insertion for version branches. - Correct the create-update-branch permission (contents: write) and scope the read-only jobs to contents: read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 156 +++++++++++++++++---------- 1 file changed, 102 insertions(+), 54 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index d5a80ed0e3f95..4e3bb5429abac 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -10,6 +10,12 @@ # 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 changelog +# (read-only), "build-pr-body" assembles the pull request body, and +# "create-update-branch" pushes the branch to the fork (the only job that needs +# write access). Resolved values flow downstream through job outputs; the +# changelog is passed as an artifact. +# # The "Gutenberg Sync" label is applied separately by gutenberg-sync-label.yml # when a gutenberg-sync/* pull request is opened. name: Gutenberg Sync @@ -40,24 +46,31 @@ concurrency: cancel-in-progress: false jobs: - # Creates or updates the draft pull request for the requested branch. - # Trunk-specific and version-specific work is gated on inputs.branch; every - # other step is shared. + # Resolves the new and current SHAs and builds the changelog. Read-only. + # Trunk-specific and version-specific work is gated on inputs.branch. research: - name: Sync ${{ inputs.branch }} + name: Resolve ${{ inputs.branch }} 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: 0 - token: ${{ github.token }} - persist-credentials: true + fetch-depth: 1 + persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout the target branch in Gutenberg @@ -68,7 +81,6 @@ jobs: ref: wp/${{ env.BASE_BRANCH }} path: gutenberg-src fetch-depth: 0 - token: ${{ github.token }} persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -89,28 +101,6 @@ jobs: echo "PR_TITLE=Gutenberg Sync: Update trunk to ${TAG}" } >> "$GITHUB_ENV" - - name: Build the changelog - if: ${{ inputs.branch != 'trunk' }} - 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.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 changelog 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 > changelog_content.md - printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md - else - cp commit_list_full.md changelog_content.md - fi - fi - - name: Determine the new commit SHA (trunk) if: ${{ inputs.branch == 'trunk' }} env: @@ -137,7 +127,10 @@ jobs: run: | set -euo pipefail OLD_SHA=$(jq -r '.gutenberg.sha' package.json) - if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi >> "$GITHUB_ENV" + { + 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' }} @@ -150,30 +143,88 @@ jobs: if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi } >> "$GITHUB_ENV" + - name: Build the changelog (version branches) + if: ${{ inputs.branch != 'trunk' }} + 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.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 changelog 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 > changelog_content.md + printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md + else + cp commit_list_full.md changelog_content.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 changelog + if: ${{ env.PROCEED == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gutenberg-sync-changelog + path: changelog_content.md + if-no-files-found: error + retention-days: 1 + + # Assembles the pull request body and writes it to the job summary. Read-only. build-pr-body: name: Build pull request body - runs-on: 'ubuntu-24.04' - needs: [ 'research' ] + runs-on: ubuntu-24.04 + needs: [ research ] + if: ${{ needs.research.outputs.proceed == 'true' }} + timeout-minutes: 10 + permissions: + contents: read + env: + NEW_SHA: ${{ needs.research.outputs.new-sha }} + OLD_SHA: ${{ needs.research.outputs.old-sha }} + TAG: ${{ needs.research.outputs.tag }} + RELEASE_URL: ${{ needs.research.outputs.release-url }} + PR_TITLE: ${{ needs.research.outputs.pr-title }} steps: - - name: Checkout the upstream target branch + - name: Checkout the pull request body template 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 + 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 changelog + 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 sync_pr_body.md + 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 release notes to the body (trunk only) - if: ${{ inputs.branch == 'trunk' }} + - name: Add the changelog to the body run: | awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp mv pr_body.tmp pr_body.md @@ -183,7 +234,7 @@ jobs: run: | sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md - - name: Add the compare link to the body + - name: Add the compare link to the body (version branches) if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md @@ -193,7 +244,6 @@ jobs: sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Output pull request details - if: ${{ env.PROCEED == 'true' }} env: FORK_OWNER: ${{ github.repository_owner }} run: | @@ -218,13 +268,17 @@ jobs: echo '````' } >> "$GITHUB_STEP_SUMMARY" + # 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' ] + runs-on: ubuntu-24.04 + needs: [ research ] + if: ${{ needs.research.outputs.proceed == 'true' }} + timeout-minutes: 10 permissions: - content: write - + contents: write + env: + NEW_SHA: ${{ needs.research.outputs.new-sha }} steps: - name: Checkout the upstream target branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -242,35 +296,29 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Create a sync branch - if: ${{ env.PROCEED == 'true' }} run: | git checkout -B "${HEAD_BRANCH}" - name: Update the pinned hash value - if: ${{ env.PROCEED == 'true' }} run: | set -euo pipefail jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp mv package.json.tmp package.json - name: Stage changes - if: ${{ env.PROCEED == 'true' }} run: | git add package.json - name: Commit changes - if: ${{ env.PROCEED == 'true' }} run: | git commit -m "Build/Test Tools: Update the bundled Gutenberg commit reference." - name: Configure fork as a remote - if: ${{ env.PROCEED == 'true' }} env: FORK_REPO: ${{ github.repository }} run: | git remote add fork "https://github.com/${FORK_REPO}.git" - name: Push changes to fork - if: ${{ env.PROCEED == 'true' }} run: | git push --force fork "HEAD:${HEAD_BRANCH}" From 9b89a5994134bf82f04bee2a5cd97c382f96a802 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:32:28 -0400 Subject: [PATCH 28/38] Reorganize further. --- .github/workflows/gutenberg-sync.yml | 136 ++++++++++++++------------- 1 file changed, 69 insertions(+), 67 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 4e3bb5429abac..5300206df90c2 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -46,10 +46,9 @@ concurrency: cancel-in-progress: false jobs: - # Resolves the new and current SHAs and builds the changelog. Read-only. - # Trunk-specific and version-specific work is gated on inputs.branch. + # Resolves the new and current SHAs and builds the changelog. research: - name: Resolve ${{ inputs.branch }} + 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' }} @@ -63,6 +62,7 @@ jobs: 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 @@ -187,21 +187,70 @@ jobs: if-no-files-found: error retention-days: 1 - # Assembles the pull request body and writes it to the job summary. Read-only. + # 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 ] + needs: [ 'research', 'create-update-branch' ] if: ${{ needs.research.outputs.proceed == 'true' }} timeout-minutes: 10 permissions: contents: read - env: - NEW_SHA: ${{ needs.research.outputs.new-sha }} - OLD_SHA: ${{ needs.research.outputs.old-sha }} - TAG: ${{ needs.research.outputs.tag }} - RELEASE_URL: ${{ needs.research.outputs.release-url }} - PR_TITLE: ${{ needs.research.outputs.pr-title }} steps: - name: Checkout the pull request body template uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -233,19 +282,24 @@ jobs: if: ${{ inputs.branch == 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md + env: + TAG: ${{ needs.research.outputs.tag }} + RELEASE_URL: ${{ needs.research.outputs.release-url }} + - name: Add the compare link to the body (version branches) if: ${{ inputs.branch != 'trunk' }} run: | sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md + env: + NEW_SHA: ${{ needs.research.outputs.new-sha }} + OLD_SHA: ${{ needs.research.outputs.old-sha }} - name: Add the new SHA to the body run: | sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md - name: Output pull request details - env: - FORK_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail compare_url="https://github.com/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${FORK_OWNER}:${HEAD_BRANCH}?expand=1" @@ -267,58 +321,6 @@ jobs: cat pr_body.md echo '````' } >> "$GITHUB_STEP_SUMMARY" - - # 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 - env: - NEW_SHA: ${{ needs.research.outputs.new-sha }} - 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 - run: | - set -euo pipefail - jq --tab --arg sha "$NEW_SHA" '.gutenberg.sha = $sha' package.json > package.json.tmp - 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}" + FORK_OWNER: ${{ github.repository_owner }} + PR_TITLE: ${{ needs.research.outputs.pr-title }} From b0fd7d97879b1af34ab691556b7cfd7aa8528cb6 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:51:21 -0400 Subject: [PATCH 29/38] Build/Test Tools: Add a commit list, fold the release changelog into a details block, and fix the SHA placeholder. - Add a list of changes (git log --reverse OLD..NEW, linkified) to every PR body, right after the intro line. Trunk now also checks out Gutenberg (at the release tag) so the list can be built, and the new SHA is read uniformly via git rev-parse. - For trunk, extract only the release's "## Changelog" section (which drops the heading and the First-time contributors/Contributors sections) and place it in a collapsed
labeled "Changelog"; remove the "## Changes" heading. - Pass the commit list and release changelog between jobs as artifacts. - Fix the __NEW_SHA__ replacement by giving the step its NEW_SHA env value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/template-gutenberg-sync-pr-body.md | 4 +- .github/workflows/gutenberg-sync.yml | 152 +++++++++++++-------- 2 files changed, 97 insertions(+), 59 deletions(-) diff --git a/.github/template-gutenberg-sync-pr-body.md b/.github/template-gutenberg-sync-pr-body.md index 97f83ceb4ff50..9d6acce262959 100644 --- a/.github/template-gutenberg-sync-pr-body.md +++ b/.github/template-gutenberg-sync-pr-body.md @@ -1,7 +1,7 @@ Updates the bundled Gutenberg commit reference for `__BRANCH__` to `__NEW_SHA__`. -__SOURCE_LINE__ +__COMMIT_LIST__ -## Changes +__SOURCE_LINE__ __CHANGELOG__ diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 5300206df90c2..57d016a8ede18 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -10,11 +10,11 @@ # 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 changelog -# (read-only), "build-pr-body" assembles the pull request body, and -# "create-update-branch" pushes the branch to the fork (the only job that needs -# write access). Resolved values flow downstream through job outputs; the -# changelog is passed as an artifact. +# 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. @@ -46,7 +46,8 @@ concurrency: cancel-in-progress: false jobs: - # Resolves the new and current SHAs and builds the changelog. + # Resolves the new and current SHAs, the commit list, and (for trunk) the + # release changelog. research: name: Determine context runs-on: ubuntu-24.04 @@ -73,17 +74,6 @@ jobs: persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Checkout the target branch in Gutenberg - if: ${{ inputs.branch != 'trunk' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: WordPress/gutenberg - ref: wp/${{ env.BASE_BRANCH }} - path: gutenberg-src - fetch-depth: 0 - persist-credentials: false - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Retrieve the latest release details (trunk only) if: ${{ inputs.branch == 'trunk' }} env: @@ -93,32 +83,46 @@ jobs: release=$(gh api repos/WordPress/gutenberg/releases/latest) TAG=$(jq -r '.tag_name' <<<"$release") RELEASE_URL=$(jq -r '.html_url' <<<"$release") - # The (untrusted) release notes are written to a file for later insertion. - jq -r '.body // ""' <<<"$release" > changelog_content.md + 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=Gutenberg Sync: Update trunk to ${TAG}" } >> "$GITHUB_ENV" - - name: Determine the new commit SHA (trunk) + - name: Checkout Gutenberg at the release (trunk only) if: ${{ inputs.branch == 'trunk' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - 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 "NEW_SHA=${NEW_SHA}" >> "$GITHUB_ENV" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: WordPress/gutenberg + ref: ${{ env.TAG }} + path: gutenberg-src + fetch-depth: 0 + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Determine the new commit SHA (version branches) + - name: Checkout the target branch in Gutenberg (version branches) if: ${{ inputs.branch != 'trunk' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: WordPress/gutenberg + ref: wp/${{ 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" @@ -143,25 +147,24 @@ jobs: if [ "${NEW_SHA}" != "${OLD_SHA}" ]; then echo "PROCEED=true"; else echo "PROCEED=false"; fi } >> "$GITHUB_ENV" - - name: Build the changelog (version branches) - if: ${{ inputs.branch != 'trunk' }} + - 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; changelog omitted._\n' "${OLD_SHA}" > changelog_content.md + 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}" \ + 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 changelog so the PR body cannot exceed GitHub's body limit. + # 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 > changelog_content.md - printf -- '- _…and %s more commits; see the compare link above._\n' "$(( total - 300 ))" >> changelog_content.md + 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 changelog_content.md + cp commit_list_full.md commit_list.md fi fi @@ -178,12 +181,21 @@ jobs: echo "proceed=${PROCEED:-false}" } >> "$GITHUB_OUTPUT" - - name: Upload the changelog + - 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: changelog_content.md + path: release_changelog.md if-no-files-found: error retention-days: 1 @@ -260,7 +272,13 @@ jobs: persist-credentials: false show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - name: Download the changelog + - 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 @@ -273,33 +291,56 @@ jobs: run: | sed -i "s|__BRANCH__|${BASE_BRANCH}|g" pr_body.md - - name: Add the changelog to the body + - name: Add the new SHA to the body + env: + NEW_SHA: ${{ needs.research.outputs.new-sha }} run: | - awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_content.md" ) > 0 ) print line; close( "changelog_content.md" ); next } 1' pr_body.md > pr_body.tmp + sed -i "s|__NEW_SHA__|${NEW_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' }} - run: | - sed -i "s|__SOURCE_LINE__|Release: [${TAG}](${RELEASE_URL})|g" pr_body.md 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' }} - run: | - sed -i "s|__SOURCE_LINE__|Compare: https://github.com/WordPress/gutenberg/compare/${OLD_SHA}...${NEW_SHA}|g" pr_body.md 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 new SHA to the body + - name: Add the changelog to the body (trunk only) + if: ${{ inputs.branch == 'trunk' }} run: | - sed -i "s|__NEW_SHA__|${NEW_SHA}|g" pr_body.md + { + echo "
" + echo "Changelog" + echo "" + cat release_changelog.md + echo "" + echo "
" + } > changelog_block.md + awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_block.md" ) > 0 ) print line; close( "changelog_block.md" ); next } 1' pr_body.md > pr_body.tmp + mv pr_body.tmp pr_body.md + + - name: Remove the changelog placeholder (version branches) + if: ${{ inputs.branch != 'trunk' }} + run: | + sed -i '/__CHANGELOG__/d' 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" @@ -321,6 +362,3 @@ jobs: cat pr_body.md echo '````' } >> "$GITHUB_STEP_SUMMARY" - env: - FORK_OWNER: ${{ github.repository_owner }} - PR_TITLE: ${{ needs.research.outputs.pr-title }} From 319bdca7bc7dd0cc1c51337a68e25b57fce99f1b Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:24:17 -0400 Subject: [PATCH 30/38] Build/Test Tools: Replace __OLD_SHA__ and fix the templated changelog sections. - Add a step to substitute the new __OLD_SHA__ placeholder. - Rename the first details summary to "Changes" (it holds the commit list and source link); the second remains "Changelog" for the release notes. - For version branches, remove the entire changelog
block rather than just the placeholder line, so the body has no empty collapsible. - Insert the release changelog directly now that the template supplies the
wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/template-gutenberg-sync-pr-body.md | 12 +++++++- .github/workflows/gutenberg-sync.yml | 35 +++++++++++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/.github/template-gutenberg-sync-pr-body.md b/.github/template-gutenberg-sync-pr-body.md index 9d6acce262959..59d4ad609a195 100644 --- a/.github/template-gutenberg-sync-pr-body.md +++ b/.github/template-gutenberg-sync-pr-body.md @@ -1,7 +1,17 @@ -Updates the bundled Gutenberg commit reference for `__BRANCH__` to `__NEW_SHA__`. +This updates the pinned Gutenberg repository commit hash for `__BRANCH__` from `__OLD_SHA__` to `__NEW_SHA__`. + +
+Changes __COMMIT_LIST__ __SOURCE_LINE__ +
+ +
+Changelog + __CHANGELOG__ + +
diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 57d016a8ede18..6a7f2cb15b9c1 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -297,6 +297,12 @@ jobs: 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 @@ -321,21 +327,28 @@ jobs: - name: Add the changelog to the body (trunk only) if: ${{ inputs.branch == 'trunk' }} run: | - { - echo "
" - echo "Changelog" - echo "" - cat release_changelog.md - echo "" - echo "
" - } > changelog_block.md - awk '/__CHANGELOG__/ { while ( ( getline line < "changelog_block.md" ) > 0 ) print line; close( "changelog_block.md" ); next } 1' pr_body.md > pr_body.tmp + 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 placeholder (version branches) + - name: Remove the changelog section (version branches) if: ${{ inputs.branch != 'trunk' }} run: | - sed -i '/__CHANGELOG__/d' pr_body.md + # 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: From 24253648bc31d1a4ff445ee70227df5bde081d17 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:07:19 -0400 Subject: [PATCH 31/38] Build/Test Tools: Reword the sync PR title/summary and fix title escaping. - Update the pull request titles and job-summary headings. - Escape the backticks in the PR titles so the shell treats them as literal text instead of command substitution (which blanked the code references). - Restore the blank lines around __CHANGELOG__ in the template so the release changelog renders as Markdown inside the
block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 6a7f2cb15b9c1..d6eb9d6f8a1ea 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -97,7 +97,7 @@ jobs: { echo "TAG=${TAG}" echo "RELEASE_URL=${RELEASE_URL}" - echo "PR_TITLE=Gutenberg Sync: Update trunk to ${TAG}" + echo "PR_TITLE=Bump \`gutenberg.sha\` to version ${TAG}" } >> "$GITHUB_ENV" - name: Checkout Gutenberg at the release (trunk only) @@ -143,7 +143,7 @@ jobs: OLD_SHA=$(jq -r '.gutenberg.sha' package.json) { echo "OLD_SHA=${OLD_SHA}" - echo "PR_TITLE=Gutenberg Sync: Update ${BASE_BRANCH} to latest wp/${BASE_BRANCH}" + 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" @@ -360,15 +360,17 @@ jobs: { echo "## Gutenberg Sync" echo "" - echo "Pushed \`${HEAD_BRANCH}\` to \`${FORK_OWNER}\`. [Open a pull request](${compare_url}) against \`${UPSTREAM_REPO}\`, then copy the title and body below into it." + echo "Pushed \`${HEAD_BRANCH}\` to \`${FORK_OWNER}\`." echo "" - echo "### Title" + 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 "### Body" + echo "### PR Body" echo "" # A four-backtick fence so a body containing triple-backtick code blocks copies intact. echo '````markdown' From 39afd36acbc68aaf303f8f50cb09da1cc9d3ff71 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:56:45 -0400 Subject: [PATCH 32/38] Build/Test Tools: Fix the combined Gutenberg checkout and restrict the branch input. - Run the single Gutenberg checkout for both trunk and version branches, after the release details are retrieved (so the trunk ref resolves to the release tag), and target wp/X.Y for version branches. - Make the branch input a choice so only allowed values can be dispatched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index d6eb9d6f8a1ea..33cb071dde1f1 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -24,10 +24,14 @@ on: workflow_dispatch: inputs: branch: - description: 'Branch to update: "trunk" or an X.Y branch name (e.g. "6.8").' + description: 'Branch to update.' required: true - type: string - default: 'trunk' + 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. @@ -100,23 +104,12 @@ jobs: echo "PR_TITLE=Bump \`gutenberg.sha\` to version ${TAG}" } >> "$GITHUB_ENV" - - name: Checkout Gutenberg at the release (trunk only) - if: ${{ inputs.branch == 'trunk' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: WordPress/gutenberg - ref: ${{ env.TAG }} - path: gutenberg-src - fetch-depth: 0 - persist-credentials: false - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - - name: Checkout the target branch in Gutenberg (version branches) - if: ${{ inputs.branch != 'trunk' }} + - name: Checkout the Gutenberg repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: WordPress/gutenberg - ref: wp/${{ env.BASE_BRANCH }} + # 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 From b80de4985d2498cb9b376b6c13120409d7549d3b Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:58:54 -0400 Subject: [PATCH 33/38] Build/Test Tools: Quote the Gutenberg Sync branch input values. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gutenberg-sync.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gutenberg-sync.yml b/.github/workflows/gutenberg-sync.yml index 33cb071dde1f1..f4e35e3cbd31b 100644 --- a/.github/workflows/gutenberg-sync.yml +++ b/.github/workflows/gutenberg-sync.yml @@ -29,9 +29,9 @@ on: type: choice # Update this list as Gutenberg cuts new wp/X.Y branches. options: - - trunk + - 'trunk' - '7.0' - default: trunk + default: 'trunk' # Disable permissions for all available scopes by default. # Any needed permissions are configured at the job level. @@ -109,7 +109,7 @@ jobs: 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) }} + ref: ${{ inputs.branch == 'trunk' && env.TAG || format( 'wp/{0}', env.BASE_BRANCH ) }} path: gutenberg-src fetch-depth: 0 persist-credentials: false From 3f1e733fbd52e90c5082f8f5cf89ce188fe77712 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:59:22 -0400 Subject: [PATCH 34/38] Update PR template. --- .github/template-gutenberg-sync-pr-body.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/template-gutenberg-sync-pr-body.md b/.github/template-gutenberg-sync-pr-body.md index 59d4ad609a195..4c8dce3f9293f 100644 --- a/.github/template-gutenberg-sync-pr-body.md +++ b/.github/template-gutenberg-sync-pr-body.md @@ -15,3 +15,10 @@ __SOURCE_LINE__ __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.** From 822f12cd50adf26219484461cf492e4fc1a598d6 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:09:26 -0700 Subject: [PATCH 35/38] Tests: Stop using the deprecated expectError()/expectErrorMessage() PHPUnit methods. PHPUnit's TestCase::expectError()/expectErrorMessage() rely on PHPUnit converting PHP-native errors into catchable exceptions, a mechanism that has been soft-deprecated since PHPUnit 9.6 (triggering an internal "Expecting E_ERROR and E_USER_ERROR is deprecated" warning) and is no longer performed by default as of PHPUnit 10, where triggered errors are only reported, not thrown. On PHPUnit 10+ these two tests would silently stop verifying anything, since expectError() would never be satisfied. Both call sites are replaced with plain set_error_handler()/ restore_error_handler() blocks that capture the triggered error and assert against it directly, following the convention already established in Tests_User::test_wp_insert_user_with_null() (#61175). This works identically on PHPUnit 9, 10, 11, and 12. PHPUnit\Framework\Error\Error, the exception class expectError() checks for, is the base class of Warning, Notice, and Deprecated, so the previous expectError() calls loosely matched any of those levels rather than only E_ERROR/E_USER_ERROR. Neither of these two tests' functions under test actually triggers E_USER_ERROR: * wp_opcache_invalidate_directory() calls wp_trigger_error() without an explicit level, whose default is E_USER_NOTICE. * wp_strip_all_tags() explicitly calls wp_trigger_error() with E_USER_WARNING. The new set_error_handler() calls listen for these actual levels rather than E_USER_ERROR, so the migrated tests keep verifying the real behavior instead of merely happening to pass due to PHPUnit's loose class hierarchy. Props desrosj. Ticket #57375, #56434. Co-Authored-By: Claude Sonnet 5 --- .../wpOpcacheInvalidateDirectory.php | 20 ++++++++++++++---- .../tests/formatting/wpStripAllTags.php | 21 +++++++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) 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/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'] + ); } /** From d0898fe60e4d1792b7740d79b2989a22415194e0 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:14:25 -0700 Subject: [PATCH 36/38] Tests: Replace PHPUnit's `expectWarning()`/`expectWarningMessage()` with `set_error_handler()`. `TestCase::expectWarning()` and its `*Message()` companion rely on PHPUnit converting native PHP warnings into catchable exceptions, a mechanism that has been soft-deprecated since PHPUnit 9.6 and is no longer performed by default as of PHPUnit 10. Tests still using these methods would silently stop verifying anything on PHPUnit 10+, since no exception would ever arrive to satisfy the expectation. Replace each use with the version-agnostic pattern already established in `Tests_User::test_wp_insert_user_with_null()` (#61175): install a local `set_error_handler()` scoped to the relevant error level, capture the triggered errors, then assert against the captured array before restoring the previous handler. This works identically on PHPUnit 9, 10, 11, and 12. Some of the ported assertions needed a wider net than a single `expectWarning()` call implied: * `wp_user_settings()` triggers two "headers already sent" warnings, one per `setcookie()` call. * `rest_validate_value_from_schema()` and `rest_sanitize_value_from_schema()` read the same missing `'type'` schema array index repeatedly per call, and each test invokes the function twice, so those warnings now assert counts of 6 and 16 respectively rather than short-circuiting after the first one. In `Tests_Locale::test_get_weekday_undefined_index()`, `WP_Test_REST_Schema_Validation::test_format_validation_is_applied_if_missing_type()`, and `WP_Test_REST_Schema_Sanitization::test_format_validation_is_applied_if_missing_type()`, only the `PHP_VERSION_ID >= 80000` branch (native `E_WARNING`) was touched; the `expectNotice()` branch for PHP < 8 is left as-is. This is one part of preparing the test suite for compatibility with PHPUnit 10+, which no longer supports `expectWarning()`/`expectWarningMessage()`. Props desrosj. Co-Authored-By: Claude Sonnet 5 --- .../tests/functions/wpTriggerError.php | 20 +++++++++-- tests/phpunit/tests/locale.php | 23 +++++++++++-- tests/phpunit/tests/option/wpUserSettings.php | 19 +++++++++-- .../rest-block-directory-controller.php | 19 +++++++++-- .../rest-api/rest-plugins-controller.php | 19 +++++++++-- .../rest-api/rest-schema-sanitization.php | 34 +++++++++++++++---- .../tests/rest-api/rest-schema-validation.php | 34 +++++++++++++++---- 7 files changed, 143 insertions(+), 25 deletions(-) diff --git a/tests/phpunit/tests/functions/wpTriggerError.php b/tests/phpunit/tests/functions/wpTriggerError.php index b642b7b08f6ae..3396948a7a742 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'] ); } /** diff --git a/tests/phpunit/tests/locale.php b/tests/phpunit/tests/locale.php index b672d08121fac..0e4371f7512f3 100644 --- a/tests/phpunit/tests/locale.php +++ b/tests/phpunit/tests/locale.php @@ -65,13 +65,32 @@ public function test_get_weekday() { * @covers WP_Locale::get_weekday */ public function test_get_weekday_undefined_index() { + $warnings = 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(); } - $this->locale->get_weekday( 7 ); + try { + $this->locale->get_weekday( 7 ); + } finally { + if ( PHP_VERSION_ID >= 80000 ) { + restore_error_handler(); + } + } + + if ( PHP_VERSION_ID >= 80000 ) { + $this->assertCount( 1, $warnings, 'Expected one warning.' ); + } } /** 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..a04f56d7492c8 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php +++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php @@ -454,17 +454,37 @@ public function test_format_validation_is_skipped_if_non_string_type() { * @ticket 50189 */ public function test_format_validation_is_applied_if_missing_type() { + $this->setExpectedIncorrectUsage( 'rest_sanitize_value_from_schema' ); + + $schema = array( 'format' => 'hex-color' ); + if ( PHP_VERSION_ID >= 80000 ) { - $this->expectWarning(); // For the undefined index. + // 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 { $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 ) ); + $this->assertSame( '#abc', rest_sanitize_value_from_schema( '#abc', $schema ) ); + $this->assertSame( '', rest_sanitize_value_from_schema( '#jkl', $schema ) ); + } } /** diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php index f83c4817718c4..00a793d75f6ed 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -154,17 +154,37 @@ public function test_format_validation_is_skipped_if_non_string_type() { * @ticket 50189 */ public function test_format_validation_is_applied_if_missing_type() { + $this->setExpectedIncorrectUsage( 'rest_validate_value_from_schema' ); + + $schema = array( 'format' => 'email' ); + if ( PHP_VERSION_ID >= 80000 ) { - $this->expectWarning(); // For the undefined index. + // 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 { $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 ) ); + $this->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) ); + } } /** From 7ed064e2941fd592ec4fd2d5e70ddd6c871a44d4 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:19:07 -0700 Subject: [PATCH 37/38] Tests: Replace `expectNotice()`/`expectNoticeMessage()` with `set_error_handler()`. PHPUnit's `expectNotice()` and `expectNoticeMessage()` rely on PHPUnit converting native PHP notices into catchable exceptions, a mechanism that has been soft-deprecated since PHPUnit 9.6 and is no longer performed by default as of PHPUnit 10. Continuing to rely on these methods means the affected tests would silently stop verifying anything once the suite is run under PHPUnit 10+, since no exception would ever be thrown to satisfy the expectation. Replaces each usage with a local `set_error_handler()`/`restore_error_handler()` block that captures the triggered notice(s) directly, following the same convention already established for `expectWarning()` in `Tests_User::test_wp_insert_user_with_null()` (see #61175). This works identically regardless of PHPUnit version. For `clean_dirsize_cache()`, `wp_unique_prefixed_id()`, and `wp_trigger_error()`, the notices are raised via `wp_trigger_error()` without an explicit error level, which defaults to `E_USER_NOTICE`. For `WP_Locale::get_weekday()` and the PHP-version-gated "undefined index" branches in the REST schema validation/sanitization tests, the notice is a native PHP "undefined index" notice (`E_NOTICE`) raised only on PHP < 8.0; on PHP 8.0+ the same access raises an `E_WARNING` instead, which is handled separately by the existing `expectWarning()` branch. Since these two test files are run exclusively under PHP >= 8.0 in this environment, the `E_NOTICE` branches could not be exercised directly, but were verified by inspection and by confirming an equivalent `E_WARNING` handler observes the expected number of triggers. Props desrosj. Co-Authored-By: Claude Sonnet 5 --- .../tests/functions/cleanDirsizeCache.php | 16 ++++++++++++-- .../tests/functions/wpTriggerError.php | 16 ++++++++++++-- .../tests/functions/wpUniquePrefixedId.php | 18 ++++++++++++++-- tests/phpunit/tests/locale.php | 16 ++++++++++---- .../rest-api/rest-schema-sanitization.php | 21 ++++++++++++++++--- .../tests/rest-api/rest-schema-validation.php | 21 ++++++++++++++++--- 6 files changed, 92 insertions(+), 16 deletions(-) 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 3396948a7a742..2f7f740565429 100644 --- a/tests/phpunit/tests/functions/wpTriggerError.php +++ b/tests/phpunit/tests/functions/wpTriggerError.php @@ -67,10 +67,22 @@ static function ( int $errno, string $errstr ) use ( &$warnings ) { * @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'] ); } /** 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/locale.php b/tests/phpunit/tests/locale.php index 0e4371f7512f3..35ce914fc46a2 100644 --- a/tests/phpunit/tests/locale.php +++ b/tests/phpunit/tests/locale.php @@ -66,6 +66,7 @@ public function test_get_weekday() { */ public function test_get_weekday_undefined_index() { $warnings = array(); + $notices = array(); if ( PHP_VERSION_ID >= 80000 ) { // Note: $this->expectWarning() is deprecated and will be removed in PHPUnit 10. @@ -77,19 +78,26 @@ static function ( int $errno, string $errstr ) use ( &$warnings ) { 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 { - if ( PHP_VERSION_ID >= 80000 ) { - restore_error_handler(); - } + restore_error_handler(); } 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/rest-api/rest-schema-sanitization.php b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php index a04f56d7492c8..3883ef02e0eba 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php +++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php @@ -480,10 +480,25 @@ static function ( int $errno, string $errstr ) use ( &$warnings ) { // The undefined index is accessed multiple times per call to rest_sanitize_value_from_schema(). $this->assertCount( 16, $warnings, 'Expected sixteen warnings.' ); } else { - $this->expectNotice(); // For the undefined index. + // 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->assertSame( '#abc', rest_sanitize_value_from_schema( '#abc', $schema ) ); - $this->assertSame( '', rest_sanitize_value_from_schema( '#jkl', $schema ) ); + $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 00a793d75f6ed..e9b3de9ba07bf 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -180,10 +180,25 @@ static function ( int $errno, string $errstr ) use ( &$warnings ) { // The undefined index is accessed multiple times per call to rest_validate_value_from_schema(). $this->assertCount( 6, $warnings, 'Expected six warnings.' ); } else { - $this->expectNotice(); // For the undefined index. + // 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->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) ); - $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) ); + $this->assertNotEmpty( $notices, 'Expected at least one notice for the undefined index.' ); } } From 8e73fd665fe91b551a5fdb66ffb04d0673396eef Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:31:19 -0700 Subject: [PATCH 38/38] Tests: Replace `expectDeprecation()` with native PHP error handling. PHPUnit's `TestCase::expectDeprecation()` (and its `expectDeprecationMessage()`/ `expectDeprecationMessageMatches()` companions) rely on PHPUnit converting PHP-native deprecation notices into catchable exceptions, a mechanism that has been soft-deprecated since PHPUnit 9.6 and is no longer performed by default as of PHPUnit 10 (triggered deprecations are only reported, never thrown). Calling these methods also emits its own PHPUnit-generated warning: "Expecting E_DEPRECATED and E_USER_DEPRECATED is deprecated and will no longer be possible in PHPUnit 10." On PHPUnit 10+, tests still using these methods wouldn't just print noise, they would silently stop verifying anything, since no exception would ever arrive to satisfy the expectation. Replace all 26 remaining `expectDeprecation()`/`expectDeprecationMessage()`/ `expectDeprecationMessageMatches()` call sites with the established `set_error_handler()`/`restore_error_handler()` pattern already used elsewhere in the test suite (see `Tests_User::test_wp_insert_user_with_null()` for ticket #61175), capturing triggered errors into an array and asserting against them directly. This works identically on PHPUnit 9, 10, 11, and 12. Notable details: * Several call sites (`Tests_Actions`, `Tests_Filters`, `Tests_Hooks_DoAction`, `Tests_Hooks_ApplyFilters`) trigger their deprecation during `add_action()`/`add_filter()` (float-to-int priority coercion), not during the later `do_action()`/`apply_filters()` call, so the error handler now wraps the full add/do sequence rather than just the final call. * `Tests_Formatting_wpTrimExcerpt::test_should_generate_excerpt_for_empty_values()` and `Tests_Admin_IncludesPost::test_post_exists_should_not_match_invalid_post_type_and_status_combined()` previously relied on PHPUnit's exception-based short-circuit to skip code after the triggering call; that code now genuinely executes, so the assertions were adjusted to verify it rather than assume it's unreached. * `Tests_HTTP_IncludeOldRequestsClass::test_should_include_old_requests_class()` used `new Requests()` purely to trigger the class's autoloader; the `WpOrg\Requests\Requests` parent class has a private constructor, so actual instantiation was never reachable before (the exception-based short-circuit masked this). Switched to `class_exists( 'Requests' )` to trigger the autoloader without attempting to construct the class. Deliberately left untouched: `setExpectedDeprecated()`, `setExpectedIncorrectUsage()`, `deprecated_function_run()`, `doing_it_wrong_run()` (WordPress's own deprecation tracking), and all `expectWarning()`/`expectNotice()`/`expectError()` call sites, which are a separate, unrelated PHPUnit-native-API cleanup. Props desrosj. Co-Authored-By: Claude Sonnet 5 --- tests/phpunit/tests/actions.php | 34 ++- tests/phpunit/tests/admin/includesPost.php | 265 +++++++++++++----- tests/phpunit/tests/admin/wpListTable.php | 96 +++++-- .../tests/diff/wpTextDiffRendererTable.php | 96 +++++-- tests/phpunit/tests/filters.php | 34 ++- .../tests/formatting/wpTrimExcerpt.php | 47 +++- .../tests/functions/wpTriggerError.php | 20 +- tests/phpunit/tests/hooks/applyFilters.php | 34 ++- tests/phpunit/tests/hooks/doAction.php | 34 ++- .../tests/http/includeOldRequestsClass.php | 21 +- .../phpunit/tests/term/wpGenerateTagCloud.php | 44 ++- tests/phpunit/tests/user/query.php | 96 +++++-- 12 files changed, 652 insertions(+), 169 deletions(-) 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/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/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/wpTriggerError.php b/tests/phpunit/tests/functions/wpTriggerError.php index 2f7f740565429..a642565c2527f 100644 --- a/tests/phpunit/tests/functions/wpTriggerError.php +++ b/tests/phpunit/tests/functions/wpTriggerError.php @@ -95,10 +95,24 @@ static function ( int $errno, string $errstr ) use ( &$notices ) { * @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/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/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 ); } /**