From 10bf92f14e95ce38e0d887caf90391228c026aec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:26:30 +0000 Subject: [PATCH 1/6] Split the matrix computation into its own reusable workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype. A matrix becomes a collapsible group in the Actions run view only when it sits on a job declared in the workflow the run belongs to. In the current shape the matrix is one level down, inside reusable-testing.yml, so it is not surfaced and all fifty legs appear as one flat list under a single "test" header. That is why the earlier attempt to group them by moving the PHP version into the calling job's name failed: there was no group to move it to. Move the `prepare` job into reusable-prepare-matrix.yml and expose the two matrices as workflow_call outputs. A package can now call that directly and run the legs from its own top-level jobs, which puts the matrix where the run view will group on it. reusable-testing.yml keeps its four inputs and now delegates to the new workflow, so every existing caller is unaffected and adoption is opt-in per package. Verified that both shapes pass identical `with:` blocks to the called workflows, that the public inputs are unchanged, and that the extracted matrix logic produces byte-identical output — 41 functional and 9 unit legs on a pull request, 51 and 12 on the schedule. This repository's own testing.yml adopts the fanned-out shape as the reference implementation, referring to the workflows by local path so that a pull request here exercises its own changes rather than whatever is on main. The cost is documented in the README rather than hidden: the fan-out is about thirty lines in a file that is not synced, so future changes to it have to be repeated per package. Leg names stay self-describing in both shapes, which means the grouped view repeats the PHP version inside the group. Worth revisiting once the grouped run can actually be seen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-prepare-matrix.yml | 520 ++++++++++++++++++ .github/workflows/reusable-testing.yml | 480 +--------------- .github/workflows/testing.yml | 51 +- README.md | 30 + 4 files changed, 608 insertions(+), 473 deletions(-) create mode 100644 .github/workflows/reusable-prepare-matrix.yml diff --git a/.github/workflows/reusable-prepare-matrix.yml b/.github/workflows/reusable-prepare-matrix.yml new file mode 100644 index 0000000..9753814 --- /dev/null +++ b/.github/workflows/reusable-prepare-matrix.yml @@ -0,0 +1,520 @@ +## +# Computes the unit and functional test matrices for a package. +# +# Split out of reusable-testing.yml so a caller can own the fan-out itself. A +# matrix only becomes a collapsible group in the Actions run view when it sits on +# a job declared in the workflow the run belongs to; a matrix one level down is +# not surfaced. Calling this workflow directly and fanning out from the caller is +# therefore the only way to group the legs by PHP version. +# +# reusable-testing.yml still wraps this for callers that do not need that. +## +name: Prepare test matrices + +on: + workflow_call: + inputs: + minimum-php: + description: 'Minimum PHP version to test against.' + type: string + required: false + default: '7.2' + minimum-wp: + description: 'Minimum WP version to test against.' + type: string + required: false + default: '4.9' + with-coverage: + description: 'Include coverages tests.' + type: boolean + required: false + default: true + matrix: + description: 'Additional matrix entries to include or exclude.' + type: string + required: false + default: '{ "include": [], "exclude": [] }' + outputs: + unit: + description: 'Matrix for the unit test legs as a JSON string, or empty when the package has no PHPUnit setup or the change is documentation only.' + value: ${{ jobs.prepare.outputs.unit }} + functional: + description: 'Matrix for the Behat legs as a JSON string, or empty when the package has no Behat setup or the change is documentation only.' + value: ${{ jobs.prepare.outputs.functional }} + +permissions: + contents: read + +jobs: + prepare: + name: Prepare test matrices + runs-on: ubuntu-22.04 + timeout-minutes: 10 + outputs: + unit: ${{ steps.unit.outputs.matrix }} + functional: ${{ steps.functional.outputs.matrix }} + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + # Enough history to diff the change against its base. Other events do + # not inspect a diff and do not need it. The operands are quoted because + # an unquoted 0 is falsy, which would make `&& 0 || 1` always yield 1. + fetch-depth: ${{ ( github.event_name == 'pull_request' || github.event_name == 'push' ) && '0' || '1' }} + + # A change that only touches documentation cannot affect the test result, so + # there is no reason to spend 50 jobs on it. This is deliberately a deny list + # rather than an allow list: the reusable workflow cannot know how any given + # package lays out its source, so anything not provably irrelevant still runs + # the full suite. Every failure path below also falls back to testing. + - name: Determine whether the change is documentation only + id: docs-only + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + # Scheduled and manually dispatched runs always test everything. + if [ "$EVENT_NAME" != 'pull_request' ] && [ "$EVENT_NAME" != 'push' ]; then + echo 'Not a pull request or push; testing everything.' + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A newly created branch reports an all-zero base. + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = '0000000000000000000000000000000000000000' ]; then + echo 'No usable base commit; testing everything.' + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! CHANGED=$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" 2>/dev/null); then + echo "Could not diff ${BASE_SHA}...${HEAD_SHA}; testing everything." + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo 'Changed files:' + printf '%s\n' "$CHANGED" + + RELEVANT=$(printf '%s\n' "$CHANGED" | grep -Ev \ + -e '\.md$' \ + -e '^\.github/ISSUE_TEMPLATE/' \ + -e '^\.github/(CODEOWNERS|FUNDING\.yml)$' \ + -e '^(LICENSE|\.editorconfig|\.gitattributes|\.gitignore)$' \ + || true) + + if [ -z "$RELEVANT" ]; then + echo 'Only documentation and repository metadata changed; skipping the test matrix.' + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo 'Test-relevant files changed:' + printf '%s\n' "$RELEVANT" + echo "value=false" >> "$GITHUB_OUTPUT" + fi + + # Entries flagged with "nightly": true are the ones the test jobs run with + # `continue-on-error`. They can never gate a merge, so running them on every + # pull request spends runner time without producing a signal. They run on the + # nightly schedule and on manual dispatch instead. + - name: Build the base matrix + id: base + env: + ADDITIONAL_MATRIX: ${{ inputs.matrix }} + INCLUDE_NIGHTLY: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + run: | + MATRIX=$(cat << 'EOF' + { + "include": [ + { + "php": "7.2", + "wp": "4.9", + "mysql": "mysql-5.6" + }, + { + "php": "7.2", + "wp": "6.9", + "mysql": "mysql-8.0" + }, + { + "php": "7.2", + "wp": "6.9", + "dbtype": "sqlite" + }, + { + "php": "7.3", + "wp": "6.9", + "mysql": "mysql-8.0" + }, + { + "php": "7.3", + "wp": "6.9", + "dbtype": "sqlite" + }, + { + "php": "7.4", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "7.4", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.0", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "8.0", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.1", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "8.1", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.2", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "8.2", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.3", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "8.3", + "wp": "latest", + "mysql": "mysql-8.4" + }, + { + "php": "8.3", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.3", + "wp": "latest", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "8.4", + "wp": "latest", + "mysql": "mysql-8.0" + }, + { + "php": "8.4", + "wp": "latest", + "mysql": "mysql-8.4" + }, + { + "php": "8.4", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.4", + "wp": "latest", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "8.5", + "wp": "latest", + "mysql": "mysql-8.0", + "coverage": true + }, + { + "php": "8.5", + "wp": "latest", + "mysql": "mysql-8.4" + }, + { + "php": "8.5", + "wp": "latest", + "dbtype": "sqlite" + }, + { + "php": "8.5", + "wp": "latest", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "7.4", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "7.4", + "wp": "trunk", + "mysql": "mysql-5.7" + }, + { + "php": "7.4", + "wp": "trunk", + "mysql": "mysql-5.6" + }, + { + "php": "8.0", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.0", + "wp": "trunk", + "mysql": "mysql-5.7" + }, + { + "php": "8.0", + "wp": "trunk", + "mysql": "mysql-5.6" + }, + { + "php": "8.1", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.2", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.3", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.3", + "wp": "trunk", + "mysql": "mysql-8.4" + }, + { + "php": "8.3", + "wp": "trunk", + "dbtype": "sqlite" + }, + { + "php": "8.3", + "wp": "trunk", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "8.4", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.4", + "wp": "trunk", + "mysql": "mysql-8.4" + }, + { + "php": "8.4", + "wp": "trunk", + "dbtype": "sqlite" + }, + { + "php": "8.4", + "wp": "trunk", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "8.5", + "wp": "trunk", + "mysql": "mysql-8.0" + }, + { + "php": "8.5", + "wp": "trunk", + "mysql": "mysql-8.4" + }, + { + "php": "8.5", + "wp": "trunk", + "dbtype": "sqlite" + }, + { + "php": "8.5", + "wp": "trunk", + "mysql": "mariadb-11.4", + "dbtype": "mariadb", + "nightly": true + }, + { + "php": "nightly", + "wp": "trunk", + "mysql": "mysql-8.4", + "nightly": true + }, + { + "php": "nightly", + "wp": "trunk", + "dbtype": "sqlite", + "nightly": true + }, + { + "php": "8.5", + "wp": "latest", + "dbtype": "sqlite", + "object_cache": "sqlite" + }, + { + "php": "8.5", + "wp": "trunk", + "dbtype": "sqlite", + "object_cache": "sqlite" + }, + { + "php": "8.5", + "wp": "trunk", + "dbtype": "sqlite", + "os": "macos-latest", + "nightly": true + }, + { + "php": "8.5", + "wp": "trunk", + "dbtype": "sqlite", + "os": "windows-2022", + "nightly": true + } + ] + } + EOF + ) + MERGED_MATRIX=$(printf '%s\n%s\n' "$MATRIX" "$ADDITIONAL_MATRIX" | jq -sc \ + --argjson include_nightly "$INCLUDE_NIGHTLY" ' + . as $root | + (($root[0].exclude // []) + ($root[1].exclude // [])) as $excludes | + { + include: ( + (.[0].include + .[1].include | map(if .os == null then .os = "" else . end)) | + map(. as $item | select($excludes | any(. as $rule | all($rule|keys[]; $item[.] == $rule[.])) | not)) | + unique | + + # Drop the soft-failing entries unless this is a nightly or manual run. + # Entries supplied through the `matrix` input have no `nightly` key and + # are therefore always kept. + map(select($include_nightly or (.nightly // false) == false)) + ) + } + ') + echo "matrix=${MERGED_MATRIX}" >> "$GITHUB_OUTPUT" + + - name: Check existence of composer.json & phpunit.xml.dist files + id: check_unit_files + run: echo "files_exists=$([ -f composer.json ] && [ -f phpunit.xml.dist ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Check existence of composer.json & behat.yml files + id: check_functional_files + run: echo "files_exists=$([ -f composer.json ] && [ -f behat.yml ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Set unit test matrix + id: unit + env: + BASE_MATRIX: ${{ steps.base.outputs.matrix }} + FILE_EXISTS: ${{ steps.check_unit_files.outputs.files_exists }} + DOCS_ONLY: ${{ steps.docs-only.outputs.value }} + INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} + INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} + WITH_COVERAGE: ${{ inputs.with-coverage }} + run: | + if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then + echo "matrix=$(jq -c \ + --argjson with_coverage_flag "${WITH_COVERAGE}" \ + --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ + --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ + ' + .include |= ( + map( + # First, select only the versions that meet all minimum requirements + select( + (.php >= $minimum_php) and + (.wp == "latest" or .wp >= $minimum_wp) + ) | + + # Next, update the coverage flag on the remaining items + if $with_coverage_flag == false and .coverage == true then + .coverage = false + else + . + end + ) | + + # Finally, get the unique entries + unique_by([.php, .os]) + ) + ' <<< "$BASE_MATRIX")" >> "$GITHUB_OUTPUT" + else + echo "matrix=" >> "$GITHUB_OUTPUT" + fi + + - name: Set functional test matrix + id: functional + env: + BASE_MATRIX: ${{ steps.base.outputs.matrix }} + FILE_EXISTS: ${{ steps.check_functional_files.outputs.files_exists }} + DOCS_ONLY: ${{ steps.docs-only.outputs.value }} + INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} + INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} + WITH_COVERAGE: ${{ inputs.with-coverage }} + run: | + if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then + echo "matrix=$(jq -c \ + --argjson with_coverage_flag "${WITH_COVERAGE}" \ + --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ + --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ + ' + # First, select only the versions that meet all minimum requirements + .include |= ( + map( + select( + .php >= $minimum_php + ) | + # Next, update the coverage flag on the remaining items + if $with_coverage_flag == false and .coverage == true then + .coverage = false + else + . + end + ) + ) | + + # Reassign WP4.9 to minimum_wp + .include |= ( + map( + select( + .wp == "4.9" + ).wp |= $minimum_wp + ) + ) + ' <<< "$BASE_MATRIX" )" >> "$GITHUB_OUTPUT" + else + echo "matrix=" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/reusable-testing.yml b/.github/workflows/reusable-testing.yml index 339f994..8caa474 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -37,478 +37,16 @@ concurrency: cancel-in-progress: true jobs: + # The matrix computation lives in its own reusable workflow so a caller can + # invoke it directly and fan out from its own top-level jobs, which is what + # makes the legs group by PHP version in the run view. See the README. prepare: - name: Prepare test matrices - runs-on: ubuntu-22.04 - timeout-minutes: 10 - outputs: - unit: ${{ steps.unit.outputs.matrix }} - functional: ${{ steps.functional.outputs.matrix }} - steps: - - name: Check out source code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - # Enough history to diff the change against its base. Other events do - # not inspect a diff and do not need it. The operands are quoted because - # an unquoted 0 is falsy, which would make `&& 0 || 1` always yield 1. - fetch-depth: ${{ ( github.event_name == 'pull_request' || github.event_name == 'push' ) && '0' || '1' }} - - # A change that only touches documentation cannot affect the test result, so - # there is no reason to spend 50 jobs on it. This is deliberately a deny list - # rather than an allow list: the reusable workflow cannot know how any given - # package lays out its source, so anything not provably irrelevant still runs - # the full suite. Every failure path below also falls back to testing. - - name: Determine whether the change is documentation only - id: docs-only - env: - EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: | - # Scheduled and manually dispatched runs always test everything. - if [ "$EVENT_NAME" != 'pull_request' ] && [ "$EVENT_NAME" != 'push' ]; then - echo 'Not a pull request or push; testing everything.' - echo "value=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # A newly created branch reports an all-zero base. - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = '0000000000000000000000000000000000000000' ]; then - echo 'No usable base commit; testing everything.' - echo "value=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! CHANGED=$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" 2>/dev/null); then - echo "Could not diff ${BASE_SHA}...${HEAD_SHA}; testing everything." - echo "value=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo 'Changed files:' - printf '%s\n' "$CHANGED" - - RELEVANT=$(printf '%s\n' "$CHANGED" | grep -Ev \ - -e '\.md$' \ - -e '^\.github/ISSUE_TEMPLATE/' \ - -e '^\.github/(CODEOWNERS|FUNDING\.yml)$' \ - -e '^(LICENSE|\.editorconfig|\.gitattributes|\.gitignore)$' \ - || true) - - if [ -z "$RELEVANT" ]; then - echo 'Only documentation and repository metadata changed; skipping the test matrix.' - echo "value=true" >> "$GITHUB_OUTPUT" - else - echo 'Test-relevant files changed:' - printf '%s\n' "$RELEVANT" - echo "value=false" >> "$GITHUB_OUTPUT" - fi - - # Entries flagged with "nightly": true are the ones the test jobs run with - # `continue-on-error`. They can never gate a merge, so running them on every - # pull request spends runner time without producing a signal. They run on the - # nightly schedule and on manual dispatch instead. - - name: Build the base matrix - id: base - env: - ADDITIONAL_MATRIX: ${{ inputs.matrix }} - INCLUDE_NIGHTLY: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} - run: | - MATRIX=$(cat << 'EOF' - { - "include": [ - { - "php": "7.2", - "wp": "4.9", - "mysql": "mysql-5.6" - }, - { - "php": "7.2", - "wp": "6.9", - "mysql": "mysql-8.0" - }, - { - "php": "7.2", - "wp": "6.9", - "dbtype": "sqlite" - }, - { - "php": "7.3", - "wp": "6.9", - "mysql": "mysql-8.0" - }, - { - "php": "7.3", - "wp": "6.9", - "dbtype": "sqlite" - }, - { - "php": "7.4", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "7.4", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.0", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "8.0", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.1", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "8.1", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.2", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "8.2", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.3", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "8.3", - "wp": "latest", - "mysql": "mysql-8.4" - }, - { - "php": "8.3", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.3", - "wp": "latest", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "8.4", - "wp": "latest", - "mysql": "mysql-8.0" - }, - { - "php": "8.4", - "wp": "latest", - "mysql": "mysql-8.4" - }, - { - "php": "8.4", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.4", - "wp": "latest", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "8.5", - "wp": "latest", - "mysql": "mysql-8.0", - "coverage": true - }, - { - "php": "8.5", - "wp": "latest", - "mysql": "mysql-8.4" - }, - { - "php": "8.5", - "wp": "latest", - "dbtype": "sqlite" - }, - { - "php": "8.5", - "wp": "latest", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "7.4", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "7.4", - "wp": "trunk", - "mysql": "mysql-5.7" - }, - { - "php": "7.4", - "wp": "trunk", - "mysql": "mysql-5.6" - }, - { - "php": "8.0", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.0", - "wp": "trunk", - "mysql": "mysql-5.7" - }, - { - "php": "8.0", - "wp": "trunk", - "mysql": "mysql-5.6" - }, - { - "php": "8.1", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.2", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.3", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.3", - "wp": "trunk", - "mysql": "mysql-8.4" - }, - { - "php": "8.3", - "wp": "trunk", - "dbtype": "sqlite" - }, - { - "php": "8.3", - "wp": "trunk", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "8.4", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.4", - "wp": "trunk", - "mysql": "mysql-8.4" - }, - { - "php": "8.4", - "wp": "trunk", - "dbtype": "sqlite" - }, - { - "php": "8.4", - "wp": "trunk", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "8.5", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "php": "8.5", - "wp": "trunk", - "mysql": "mysql-8.4" - }, - { - "php": "8.5", - "wp": "trunk", - "dbtype": "sqlite" - }, - { - "php": "8.5", - "wp": "trunk", - "mysql": "mariadb-11.4", - "dbtype": "mariadb", - "nightly": true - }, - { - "php": "nightly", - "wp": "trunk", - "mysql": "mysql-8.4", - "nightly": true - }, - { - "php": "nightly", - "wp": "trunk", - "dbtype": "sqlite", - "nightly": true - }, - { - "php": "8.5", - "wp": "latest", - "dbtype": "sqlite", - "object_cache": "sqlite" - }, - { - "php": "8.5", - "wp": "trunk", - "dbtype": "sqlite", - "object_cache": "sqlite" - }, - { - "php": "8.5", - "wp": "trunk", - "dbtype": "sqlite", - "os": "macos-latest", - "nightly": true - }, - { - "php": "8.5", - "wp": "trunk", - "dbtype": "sqlite", - "os": "windows-2022", - "nightly": true - } - ] - } - EOF - ) - MERGED_MATRIX=$(printf '%s\n%s\n' "$MATRIX" "$ADDITIONAL_MATRIX" | jq -sc \ - --argjson include_nightly "$INCLUDE_NIGHTLY" ' - . as $root | - (($root[0].exclude // []) + ($root[1].exclude // [])) as $excludes | - { - include: ( - (.[0].include + .[1].include | map(if .os == null then .os = "" else . end)) | - map(. as $item | select($excludes | any(. as $rule | all($rule|keys[]; $item[.] == $rule[.])) | not)) | - unique | - - # Drop the soft-failing entries unless this is a nightly or manual run. - # Entries supplied through the `matrix` input have no `nightly` key and - # are therefore always kept. - map(select($include_nightly or (.nightly // false) == false)) - ) - } - ') - echo "matrix=${MERGED_MATRIX}" >> "$GITHUB_OUTPUT" - - - name: Check existence of composer.json & phpunit.xml.dist files - id: check_unit_files - run: echo "files_exists=$([ -f composer.json ] && [ -f phpunit.xml.dist ] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Check existence of composer.json & behat.yml files - id: check_functional_files - run: echo "files_exists=$([ -f composer.json ] && [ -f behat.yml ] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Set unit test matrix - id: unit - env: - BASE_MATRIX: ${{ steps.base.outputs.matrix }} - FILE_EXISTS: ${{ steps.check_unit_files.outputs.files_exists }} - DOCS_ONLY: ${{ steps.docs-only.outputs.value }} - INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} - INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} - WITH_COVERAGE: ${{ inputs.with-coverage }} - run: | - if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then - echo "matrix=$(jq -c \ - --argjson with_coverage_flag "${WITH_COVERAGE}" \ - --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ - --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ - ' - .include |= ( - map( - # First, select only the versions that meet all minimum requirements - select( - (.php >= $minimum_php) and - (.wp == "latest" or .wp >= $minimum_wp) - ) | - - # Next, update the coverage flag on the remaining items - if $with_coverage_flag == false and .coverage == true then - .coverage = false - else - . - end - ) | - - # Finally, get the unique entries - unique_by([.php, .os]) - ) - ' <<< "$BASE_MATRIX")" >> "$GITHUB_OUTPUT" - else - echo "matrix=" >> "$GITHUB_OUTPUT" - fi - - - name: Set functional test matrix - id: functional - env: - BASE_MATRIX: ${{ steps.base.outputs.matrix }} - FILE_EXISTS: ${{ steps.check_functional_files.outputs.files_exists }} - DOCS_ONLY: ${{ steps.docs-only.outputs.value }} - INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} - INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} - WITH_COVERAGE: ${{ inputs.with-coverage }} - run: | - if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then - echo "matrix=$(jq -c \ - --argjson with_coverage_flag "${WITH_COVERAGE}" \ - --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ - --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ - ' - # First, select only the versions that meet all minimum requirements - .include |= ( - map( - select( - .php >= $minimum_php - ) | - # Next, update the coverage flag on the remaining items - if $with_coverage_flag == false and .coverage == true then - .coverage = false - else - . - end - ) - ) | - - # Reassign WP4.9 to minimum_wp - .include |= ( - map( - select( - .wp == "4.9" - ).wp |= $minimum_wp - ) - ) - ' <<< "$BASE_MATRIX" )" >> "$GITHUB_OUTPUT" - else - echo "matrix=" >> "$GITHUB_OUTPUT" - fi + uses: ./.github/workflows/reusable-prepare-matrix.yml + with: + minimum-php: ${{ inputs.minimum-php }} + minimum-wp: ${{ inputs.minimum-wp }} + with-coverage: ${{ inputs.with-coverage }} + matrix: ${{ inputs.matrix }} unit: needs: prepare diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 35d7562..a1be293 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,6 +13,53 @@ on: permissions: contents: read +# Lives here rather than in the called workflow, because the fan-out below is +# owned by this file. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +# This repository uses the fanned-out form as the reference implementation for +# the rest of the organisation, and refers to the workflows by local path so that +# a pull request here exercises its own changes rather than whatever is on main. +# Packages adopting this shape use `wp-cli/.github/.github/workflows/...@main`. +# See the README for what this buys and what it costs. jobs: - test: - uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main + prepare: + name: Prepare test matrices + uses: ./.github/workflows/reusable-prepare-matrix.yml + + unit: + needs: prepare + if: ${{ needs.prepare.outputs.unit != '' }} + # A matrix only becomes a collapsible group in the run view when it sits on a + # job declared in the workflow the run belongs to, which is why this fan-out + # is here and not one level down. + name: Unit | PHP ${{ matrix.php }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.unit) }} + uses: ./.github/workflows/reusable-unit.yml + secrets: inherit + with: + php: ${{ matrix.php }} + coverage: ${{ matrix.coverage == true }} + os: ${{ matrix.os || '' }} + + functional: + needs: prepare + if: ${{ needs.prepare.outputs.functional != '' }} + name: Behat | PHP ${{ matrix.php }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.functional) }} + uses: ./.github/workflows/reusable-functional.yml + secrets: inherit + with: + php: ${{ matrix.php }} + wp: ${{ matrix.wp }} + dbtype: ${{ matrix.dbtype || 'mysql' }} + mysql: ${{ matrix.mysql || '' }} + object_cache: ${{ matrix.object_cache }} + coverage: ${{ matrix.coverage == true }} + os: ${{ matrix.os || '' }} diff --git a/README.md b/README.md index 2ebbd65..42d5b7c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,36 @@ This repository contains reusable GitHub Actions workflows that are automaticall - **Regenerate README** (`regenerate-readme.yml`) - Automatically regenerates README.md files from source - **Check Branch Alias** (`check-branch-alias.yml`) - Monitors and updates Composer branch-alias configuration +#### Test workflows + +A package's `testing.yml` can call the test workflows in one of two shapes. + +The **wrapped** shape is the default and needs three lines: + +```yaml +jobs: + test: + uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main + with: + minimum-php: '8.0' +``` + +The **fanned-out** shape calls `reusable-prepare-matrix.yml` for the matrix and +runs the legs from the package's own jobs. See this repository's own +`testing.yml` for a complete example. + +The only thing it buys is presentation: a matrix becomes a collapsible group in +the Actions run view only when it sits on a job declared in the workflow the run +belongs to. In the wrapped shape the matrix is one level down and is not +surfaced, so all fifty legs appear as one flat list. Fanning out puts the matrix +at the top level, so the run view collapses to one entry per PHP version. + +It costs about thirty lines in a file that is not synced, so every future change +to the fan-out has to be repeated in each package that adopts it. The leg names +are self-describing in both shapes and repeat the PHP version inside the group, +because the same called workflows serve both. Prefer the wrapped shape unless a +package has a large enough matrix that the flat list is genuinely hard to read. + #### Branch Alias Checker The branch alias checker workflow automatically ensures that the Composer `branch-alias` in each repository's `composer.json` is up-to-date. It: From df0b144d899571463b87349f2e2064e4cb3689cb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:57:45 +0000 Subject: [PATCH 2/6] Drop the redundant prefix from grouped leg names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed against wp-cli/wp-cli-tests#352 that the fanned-out shape does group the legs in the run view. What it also showed is that the leg names then repeat their group: a group called "Behat | PHP 8.5" containing "Behat | PHP 8.5 | WP latest | SQLite". wordpress-develop handles this by suppressing the prefix in the called workflow whenever the calling job already states it — its leg name only includes "PHP {0} with" for the test-group and coverage callers, which are exactly the ones whose job name is not "PHP {0}". Do the same, with an explicit `grouped` input rather than inferring it from unrelated inputs. Wrapped callers leave it at its default of false and are unaffected; their names still have to stand on their own because their calling job is not surfaced. The expression is written as `!grouped && || ''` rather than the more natural looking `grouped && '' || `, because an empty string is falsy and the latter would fall through to the prefix in both cases. That is the same trap that made fetch-depth always evaluate to 1 earlier in this branch; zizmor's unsound-ternary audit is clean on the form used here. Noted in the README that a grouped leg name is only unique within its group, so the back-out is to set `grouped: false` for that caller. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-functional.yml | 23 ++++++++++++++----- .github/workflows/reusable-unit.yml | 13 +++++++---- .github/workflows/testing.yml | 4 ++++ README.md | 28 +++++++++++++++++++---- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index d1ab1a8..74e16ff 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -29,18 +29,29 @@ on: type: string required: false default: '' + grouped: + description: 'Set when the calling job name already states the suite and PHP version, so this workflow leaves them out instead of repeating them inside the group.' + type: boolean + required: false + default: false permissions: contents: read jobs: functional: - # This name has to stand on its own. The run view labels a nested job with its - # own name only; the calling job's name is not surfaced at this depth, so - # anything omitted here is not shown anywhere. The database version is spelled - # out because "MySQL" alone rendered the mysql-8.0 and mysql-8.4 legs - # identically. - name: Behat | PHP ${{ inputs.php }} | WP ${{ inputs.wp }} | ${{ inputs.dbtype == 'sqlite' && 'SQLite' || inputs.mysql || 'MySQL' }}${{ inputs.object_cache == 'sqlite' && ' (Obj Cache)' || '' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # When the caller wraps this workflow, its own name is not surfaced in the run + # view, so this name has to carry everything. When the caller fans out, its job + # name becomes the group header and already states the suite and PHP version, + # so repeating them here reads as noise. `grouped` picks between the two. + # + # Written as `!grouped && || ''` rather than `grouped && '' || ` + # on purpose: an empty string is falsy, so the latter would fall through to the + # prefix in both cases. + # + # The database version is spelled out because "MySQL" alone rendered the + # mysql-8.0 and mysql-8.4 legs identically. + name: ${{ !inputs.grouped && format('Behat | PHP {0} | ', inputs.php) || '' }}WP ${{ inputs.wp }} | ${{ inputs.dbtype == 'sqlite' && 'SQLite' || inputs.mysql || 'MySQL' }}${{ inputs.object_cache == 'sqlite' && ' (Obj Cache)' || '' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} # Repositories with a heavy Behat suite can point the default Linux legs at a # larger runner by setting the `RUNNERS_NAME` repository variable, without # having to fork this workflow. Explicit macOS/Windows legs are unaffected. diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 6bcb8f7..7defbe2 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -14,16 +14,21 @@ on: type: string required: false default: '' + grouped: + description: 'Set when the calling job name already states the suite and PHP version, so this workflow leaves them out instead of repeating them inside the group.' + type: boolean + required: false + default: false permissions: contents: read jobs: unit: - # This name has to stand on its own. The run view labels a nested job with its - # own name only; the calling job's name is not surfaced at this depth, so - # anything omitted here is not shown anywhere. - name: Unit | PHP ${{ inputs.php }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # See the note on the equivalent name in reusable-functional.yml: `grouped` + # picks between a name that stands on its own and one that sits inside a group + # header which already states the suite and PHP version. + name: ${{ !inputs.grouped && format('Unit | PHP {0}', inputs.php) || 'PHPUnit' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} # Repositories can point the default Linux legs at a larger runner by setting # the `RUNNERS_NAME` repository variable, without having to fork this workflow. # Explicit macOS/Windows legs are unaffected. diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index a1be293..8977a7c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -45,6 +45,8 @@ jobs: php: ${{ matrix.php }} coverage: ${{ matrix.coverage == true }} os: ${{ matrix.os || '' }} + # The job name above already states the suite and PHP version. + grouped: true functional: needs: prepare @@ -63,3 +65,5 @@ jobs: object_cache: ${{ matrix.object_cache }} coverage: ${{ matrix.coverage == true }} os: ${{ matrix.os || '' }} + # The job name above already states the suite and PHP version. + grouped: true diff --git a/README.md b/README.md index 42d5b7c..7e87dab 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,30 @@ surfaced, so all fifty legs appear as one flat list. Fanning out puts the matrix at the top level, so the run view collapses to one entry per PHP version. It costs about thirty lines in a file that is not synced, so every future change -to the fan-out has to be repeated in each package that adopts it. The leg names -are self-describing in both shapes and repeat the PHP version inside the group, -because the same called workflows serve both. Prefer the wrapped shape unless a -package has a large enough matrix that the flat list is genuinely hard to read. +to the fan-out has to be repeated in each package that adopts it. Prefer the +wrapped shape unless a package has a large enough matrix that the flat list is +genuinely hard to read. + +A fanned-out caller should pass `grouped: true` to `reusable-unit.yml` and +`reusable-functional.yml`. Their job names then leave out the suite and PHP +version, because the group header already states both: + +``` +Behat | PHP 8.5 <- the calling job + WP latest | mysql-8.0 <- the called workflow, with grouped: true + WP trunk | SQLite +``` + +Without it the legs read `Behat | PHP 8.5 | WP latest | mysql-8.0` inside a group +already called `Behat | PHP 8.5`. Wrapped callers must leave it at its default of +`false`, because there is no group header for them and the name has to stand on +its own. + +The one thing to watch is that with `grouped: true` a leg name is only unique +within its group — `WP latest | SQLite` occurs under every PHP version. That is +fine wherever the group header is part of the name, which is how the run view and +the checks list both render it. If a leg ever shows up somewhere without its +group, set `grouped: false` for that caller and the full name comes back. #### Branch Alias Checker From 1e736df845fcd367dec8f0bdd922ab83f223492a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:30:40 +0000 Subject: [PATCH 3/6] Stop grouping the unit legs by PHP version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against wp-cli/wp-cli-tests#352. Grouping Behat by PHP version works — two to eight legs per group — but the unit matrix has exactly one leg per PHP version, so the same treatment produced nine groups containing one job each. Give the unit fan-out a single "Unit" group and let the PHP version distinguish the legs inside it. The grouped form of the unit name therefore keeps the version, rather than collapsing to a bare constant the way the Behat one does; the version is the only thing that tells those legs apart. Behat is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-unit.yml | 9 +++++++-- .github/workflows/testing.yml | 6 +++++- README.md | 23 +++++++++++++++++------ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 7defbe2..0e51a92 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -27,8 +27,13 @@ jobs: unit: # See the note on the equivalent name in reusable-functional.yml: `grouped` # picks between a name that stands on its own and one that sits inside a group - # header which already states the suite and PHP version. - name: ${{ !inputs.grouped && format('Unit | PHP {0}', inputs.php) || 'PHPUnit' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # header which already states the suite. + # + # Unlike Behat, the grouped form keeps the PHP version. There is only ever one + # unit leg per PHP version, so grouping by version would produce a column of + # single-job groups; a fanned-out caller should use a single "Unit" group and + # let the version distinguish the legs inside it. + name: ${{ !inputs.grouped && format('Unit | PHP {0}', inputs.php) || format('PHP {0}', inputs.php) }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} # Repositories can point the default Linux legs at a larger runner by setting # the `RUNNERS_NAME` repository variable, without having to fork this workflow. # Explicit macOS/Windows legs are unaffected. diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8977a7c..f922a12 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -35,7 +35,11 @@ jobs: # A matrix only becomes a collapsible group in the run view when it sits on a # job declared in the workflow the run belongs to, which is why this fan-out # is here and not one level down. - name: Unit | PHP ${{ matrix.php }} + # + # Deliberately not "Unit | PHP ${{ matrix.php }}": there is only one unit leg + # per PHP version, so that would produce nine groups of one job each. A single + # group with the version on each leg reads better. + name: Unit strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.unit) }} diff --git a/README.md b/README.md index 7e87dab..429f42b 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,11 @@ wrapped shape unless a package has a large enough matrix that the flat list is genuinely hard to read. A fanned-out caller should pass `grouped: true` to `reusable-unit.yml` and -`reusable-functional.yml`. Their job names then leave out the suite and PHP -version, because the group header already states both: +`reusable-functional.yml`, so their job names leave out what the group header +already states. Without it the legs read `Behat | PHP 8.5 | WP latest | SQLite` +inside a group already called `Behat | PHP 8.5`. + +Group Behat by PHP version, which gives two to eight legs per group: ``` Behat | PHP 8.5 <- the calling job @@ -58,10 +61,18 @@ Behat | PHP 8.5 <- the calling job WP trunk | SQLite ``` -Without it the legs read `Behat | PHP 8.5 | WP latest | mysql-8.0` inside a group -already called `Behat | PHP 8.5`. Wrapped callers must leave it at its default of -`false`, because there is no group header for them and the name has to stand on -its own. +Do not group unit tests the same way. There is only ever one unit leg per PHP +version, so `Unit | PHP ${{ matrix.php }}` produces a column of single-job +groups. Use one `Unit` group and let the version distinguish the legs: + +``` +Unit <- the calling job + PHP 7.2 <- the called workflow, with grouped: true + PHP 8.5 (with coverage) +``` + +Wrapped callers must leave `grouped` at its default of `false`, because there is +no group header for them and the name has to stand on its own. The one thing to watch is that with `grouped: true` a leg name is only unique within its group — `WP latest | SQLite` occurs under every PHP version. That is From cfd06464ca8f74d47c80714c9f6ca49e960a3461 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 20:05:22 +0000 Subject: [PATCH 4/6] Group the unit legs by PHP version after all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the previous commit, which was based on a bad measurement and on a wrong model of how the run view forms groups. The measurement only looked at the default matrix on a pull request, where the macOS and Windows entries are held back for the nightly schedule. That leaves one unit leg per PHP version and made grouping look pointless. On the schedule, and in any package that adds its own OS entries, PHP 8.5 has three unit legs — which is exactly the case worth grouping. The model was wrong in a more useful way. The run view groups legs whose calling job name is identical; a name that does not vary with the matrix does not produce one group holding everything, because GitHub appends the matrix combination to disambiguate it. `name: Unit` therefore produced one group per leg, labelled `Unit (8.5, latest, mysql-8.0)`. The name has to vary by the dimension being grouped on and collide across every other one. Both suites group by PHP version again, and the grouped unit leg name goes back to carrying only its runner and coverage suffixes. That rule is now written down in the README, since it is not obvious and this is the second time it has been guessed wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-unit.yml | 11 ++++------- .github/workflows/testing.yml | 10 ++++++---- README.md | 23 ++++++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 0e51a92..16f07e9 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -27,13 +27,10 @@ jobs: unit: # See the note on the equivalent name in reusable-functional.yml: `grouped` # picks between a name that stands on its own and one that sits inside a group - # header which already states the suite. - # - # Unlike Behat, the grouped form keeps the PHP version. There is only ever one - # unit leg per PHP version, so grouping by version would produce a column of - # single-job groups; a fanned-out caller should use a single "Unit" group and - # let the version distinguish the legs inside it. - name: ${{ !inputs.grouped && format('Unit | PHP {0}', inputs.php) || format('PHP {0}', inputs.php) }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # header which already states the suite and PHP version. A grouped unit leg is + # distinguished only by its runner and whether it collects coverage, so those + # suffixes carry the whole name. + name: ${{ !inputs.grouped && format('Unit | PHP {0}', inputs.php) || 'PHPUnit' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} # Repositories can point the default Linux legs at a larger runner by setting # the `RUNNERS_NAME` repository variable, without having to fork this workflow. # Explicit macOS/Windows legs are unaffected. diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f922a12..f917c6e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,10 +36,12 @@ jobs: # job declared in the workflow the run belongs to, which is why this fan-out # is here and not one level down. # - # Deliberately not "Unit | PHP ${{ matrix.php }}": there is only one unit leg - # per PHP version, so that would produce nine groups of one job each. A single - # group with the version on each leg reads better. - name: Unit + # The run view groups legs whose calling job name is identical, so this name + # must vary by the dimension being grouped on and collide across every other. + # A name that does not vary with the matrix at all does not produce one group; + # GitHub appends the matrix combination to disambiguate it, giving one group + # per leg labelled "Unit (8.5, latest, mysql-8.0)". + name: Unit | PHP ${{ matrix.php }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.unit) }} diff --git a/README.md b/README.md index 429f42b..e2142b4 100644 --- a/README.md +++ b/README.md @@ -53,23 +53,28 @@ A fanned-out caller should pass `grouped: true` to `reusable-unit.yml` and already states. Without it the legs read `Behat | PHP 8.5 | WP latest | SQLite` inside a group already called `Behat | PHP 8.5`. -Group Behat by PHP version, which gives two to eight legs per group: +The run view groups legs whose calling job name is identical, so that name must +vary by the dimension being grouped on and collide across every other one. Group +both suites by PHP version: ``` Behat | PHP 8.5 <- the calling job WP latest | mysql-8.0 <- the called workflow, with grouped: true WP trunk | SQLite + +Unit | PHP 8.5 + PHPUnit (with coverage) + PHPUnit (macOS) + PHPUnit (Windows) ``` -Do not group unit tests the same way. There is only ever one unit leg per PHP -version, so `Unit | PHP ${{ matrix.php }}` produces a column of single-job -groups. Use one `Unit` group and let the version distinguish the legs: +A name that does not vary with the matrix does not collapse the legs into one +group. GitHub appends the matrix combination to disambiguate it, so `name: Unit` +produces one group per leg, labelled `Unit (8.5, latest, mysql-8.0)`. -``` -Unit <- the calling job - PHP 7.2 <- the called workflow, with grouped: true - PHP 8.5 (with coverage) -``` +Some groups will hold a single leg — on a pull request there is one unit leg per +PHP version, because the macOS and Windows entries only run on the nightly +schedule. That is expected and still reads better than the alternative. Wrapped callers must leave `grouped` at its default of `false`, because there is no group header for them and the name has to stand on its own. From f3eb68e077bf8392d0058d95a571e1bb3fc30435 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 20:24:16 +0000 Subject: [PATCH 5/6] Move the default runner to ubuntu-24.04 The 22.04 image is being retired and wordpress-develop has already moved. Only the default matters here; legs that name an explicit runner, and the macOS and Windows entries, are untouched. Also match the ImageMagick policy file by glob instead of a hardcoded ImageMagick-6 path. The guard added earlier keeps `sed -i` from failing on a missing file, but on an image that ships ImageMagick 7 it would have skipped silently and left the PDF coder blocked, which surfaces much later as a confusing media test failure rather than as a clear error. Five of the 51 legs pin MySQL 5.6 or 5.7, and the matrix reaches back to PHP 7.2. Those are the combinations most likely to object to the newer image, and they are the ones to watch on the first run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-functional.yml | 26 ++++++++++++------- .github/workflows/reusable-prepare-matrix.yml | 2 +- .github/workflows/reusable-unit.yml | 4 +-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index 74e16ff..3bf0cc6 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -25,7 +25,7 @@ on: required: false default: false os: - description: 'Runner to use. Defaults to ubuntu-22.04, or the RUNNERS_NAME repository variable when set.' + description: 'Runner to use. Defaults to ubuntu-24.04, or the RUNNERS_NAME repository variable when set.' type: string required: false default: '' @@ -55,7 +55,7 @@ jobs: # Repositories with a heavy Behat suite can point the default Linux legs at a # larger runner by setting the `RUNNERS_NAME` repository variable, without # having to fork this workflow. Explicit macOS/Windows legs are unaffected. - runs-on: ${{ inputs.os || vars.RUNNERS_NAME || 'ubuntu-22.04' }} + runs-on: ${{ inputs.os || vars.RUNNERS_NAME || 'ubuntu-24.04' }} continue-on-error: ${{ inputs.dbtype == 'mariadb' || inputs.php == 'nightly' || startsWith( inputs.os, 'windows' ) || startsWith( inputs.os, 'macos' ) }} @@ -107,7 +107,7 @@ jobs: # image drops it. - name: Install Ghostscript # Keyed on the actual runner rather than the `os` input: an empty input no - # longer implies ubuntu-22.04 now that RUNNERS_NAME can select the image. + # longer implies the default image now that RUNNERS_NAME can select it. if: ${{ runner.os == 'Linux' }} run: | if command -v gs > /dev/null 2>&1; then @@ -142,16 +142,22 @@ jobs: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Change ImageMagick policy to allow pdf->png conversion. - # Keyed on the actual runner rather than the `os` input, and tolerant of - # images that ship a different ImageMagick layout — `sed -i` on a missing - # file exits non-zero and would fail the leg. + # Keyed on the actual runner rather than the `os` input, and matched by glob + # rather than a hardcoded ImageMagick-6 path, so this keeps working if an + # image ships ImageMagick 7. `sed -i` on a missing file exits non-zero and + # would fail the leg. if: ${{ runner.os == 'Linux' }} run: | - if [ -f /etc/ImageMagick-6/policy.xml ]; then - sudo sed -i 's/^.*policy.*coder.*none.*PDF.*//' /etc/ImageMagick-6/policy.xml - else - echo 'No ImageMagick 6 policy file found; nothing to relax.' + shopt -s nullglob + POLICIES=(/etc/ImageMagick-*/policy.xml) + if [ ${#POLICIES[@]} -eq 0 ]; then + echo 'No ImageMagick policy file found; nothing to relax.' + exit 0 fi + for policy in "${POLICIES[@]}"; do + echo "Relaxing the PDF coder policy in ${policy}." + sudo sed -i 's/^.*policy.*coder.*none.*PDF.*//' "$policy" + done # WP-CLI packages do not commit a lock file, so `composer update` resolves # dependencies on every run while the cache key stays pinned to composer.json. diff --git a/.github/workflows/reusable-prepare-matrix.yml b/.github/workflows/reusable-prepare-matrix.yml index 9753814..3a8b263 100644 --- a/.github/workflows/reusable-prepare-matrix.yml +++ b/.github/workflows/reusable-prepare-matrix.yml @@ -48,7 +48,7 @@ permissions: jobs: prepare: name: Prepare test matrices - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: unit: ${{ steps.unit.outputs.matrix }} diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 16f07e9..565cd18 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -10,7 +10,7 @@ on: required: false default: false os: - description: 'Runner to use. Defaults to ubuntu-22.04, or the RUNNERS_NAME repository variable when set.' + description: 'Runner to use. Defaults to ubuntu-24.04, or the RUNNERS_NAME repository variable when set.' type: string required: false default: '' @@ -34,7 +34,7 @@ jobs: # Repositories can point the default Linux legs at a larger runner by setting # the `RUNNERS_NAME` repository variable, without having to fork this workflow. # Explicit macOS/Windows legs are unaffected. - runs-on: ${{ inputs.os || vars.RUNNERS_NAME || 'ubuntu-22.04' }} + runs-on: ${{ inputs.os || vars.RUNNERS_NAME || 'ubuntu-24.04' }} continue-on-error: ${{ inputs.php == 'nightly' }} timeout-minutes: ${{ inputs.coverage && 30 || 15 }} From c2efc8f7f97be652172d7ac96b3a67a8bc7664b0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sat, 8 Aug 2026 08:05:36 +0200 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/reusable-prepare-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-prepare-matrix.yml b/.github/workflows/reusable-prepare-matrix.yml index 3a8b263..ad82704 100644 --- a/.github/workflows/reusable-prepare-matrix.yml +++ b/.github/workflows/reusable-prepare-matrix.yml @@ -25,7 +25,7 @@ on: required: false default: '4.9' with-coverage: - description: 'Include coverages tests.' + description: 'Include coverage tests.' type: boolean required: false default: true