From 977e729c944419a4b688d52289a17242fdc18786 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:35:41 +0000 Subject: [PATCH 1/6] Stop the workflow sync from overlapping itself The sync workflow ran on a `*/10 * * * *` cron with no concurrency group. Two things went wrong with that: * GitHub throttles high-frequency schedules. Across the last 30 runs the workflow actually fired every ~48 minutes (min 23, max 82), so the ten minute cadence was never real to begin with. * Without a concurrency group, runs overlapped. Several recent runs took 15, 84, 126 and 141 minutes while pushing to the same ~45 target repositories at once, and roughly one in six failed. Serialise the workflow, put a bound on how long a run may take, and let the push trigger do the work it was already doing. The cron drops to daily and now only serves as a drift check for out-of-band edits. The push trigger also gains a paths filter so unrelated commits no longer start a sync. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/sync-workflows.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-workflows.yml b/.github/workflows/sync-workflows.yml index 6491a4a..1999c46 100644 --- a/.github/workflows/sync-workflows.yml +++ b/.github/workflows/sync-workflows.yml @@ -6,17 +6,37 @@ on: branches: - main - master + paths: + - '.actrc' + - '.editorconfig' + - 'AGENTS.md' + - '.github/dependabot.yml' + - '.github/workflows/check-branch-alias.yml' + - '.github/workflows/copilot-setup-steps.yml' + - '.github/workflows/issue-triage.yml' + - '.github/workflows/manage-labels.yml' + - '.github/workflows/regenerate-readme.yml' + - '.github/workflows/welcome-new-contributors.yml' + - '.github/workflows/sync-workflows.yml' schedule: - - cron: '*/10 * * * *' # Run every 10 minutes. + - cron: '43 4 * * *' # Daily, to catch drift from out-of-band edits. permissions: contents: read +# A sync pushes to every target repository, so only one may be in flight at a +# time. Queued runs wait instead of cancelling, so a sync that has already +# started always gets to finish. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: sync-workflows: name: Sync GitHub Actions workflows runs-on: ubuntu-latest + timeout-minutes: 20 if: ${{ github.repository_owner == 'wp-cli' }} permissions: contents: write @@ -84,6 +104,7 @@ jobs: sync-dependabot: name: Sync Dependabot configuration runs-on: ubuntu-latest + timeout-minutes: 20 if: ${{ github.repository_owner == 'wp-cli' }} permissions: contents: write From cca7c24bdf4fe8397ae2e3aabc137535362ed8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:35:52 +0000 Subject: [PATCH 2/6] Keep the soft-failing test legs off the pull request path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten of the 51 Behat legs run with `continue-on-error` — the six MariaDB combinations, both PHP nightly entries, macOS and Windows. They cannot gate a merge, so running them on every pull request spent runner time without producing a signal, and the Windows leg in particular sat on the critical path. Flag those entries with `"nightly": true` and filter them out unless the run came from the nightly schedule or a manual dispatch. Entries supplied through the `matrix` input carry no such flag and are always kept, so per-repository customisation is unaffected. Per pull request this takes Behat from 51 jobs to 41 and PHPUnit from 12 to 9. The scheduled run still covers all 63. Also fold `get-matrix`, `prepare-unit` and `prepare-functional` into a single `prepare` job. They were three serial job dispatches before any test started, and two of them checked out the whole repository just to test for the presence of a file. Along the way: * Drop a duplicated `{php: 7.4, wp: trunk, mysql: mysql-8.0}` entry that the `unique` filter had been quietly absorbing. * Emit the matrix with `jq -c` and quote the expansion. It previously relied on unquoted word splitting to flatten pretty printed JSON into one line. * Group the concurrency key by event, so a push to the default branch and the nightly schedule no longer cancel one another. The matrix JSON is reindented by two spaces as a result of moving into the new job; its contents are otherwise unchanged apart from the flags noted above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-testing.yml | 724 +++++++++++++------------ 1 file changed, 364 insertions(+), 360 deletions(-) diff --git a/.github/workflows/reusable-testing.yml b/.github/workflows/reusable-testing.yml index 71700a7..881620a 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -29,335 +29,359 @@ permissions: # Cancels all previous workflow runs for the same branch that have not yet completed. concurrency: - # The concurrency group contains the workflow name and the branch name. - group: ${{ github.workflow }}-${{ github.ref }} + # Pull requests group per branch so a new push supersedes the previous run. + # Everything else groups per event and commit, so a push to the default branch + # and the nightly schedule never cancel each other. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: true jobs: - get-matrix: - name: Get base test matrix + prepare: + name: Prepare test matrices runs-on: ubuntu-22.04 + timeout-minutes: 5 outputs: - matrix: ${{ steps.base-matrix.outputs.matrix }} - steps: - - name: Set matrix - id: base-matrix - env: - ADDITIONAL_MATRIX: ${{ inputs.matrix }} - 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" - }, - { - "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" - }, - { - "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" - }, - { - "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": "7.4", - "wp": "trunk", - "mysql": "mysql-8.0" - }, - { - "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" - }, - { - "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" - }, - { - "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" - }, - { - "php": "nightly", - "wp": "trunk", - "mysql": "mysql-8.4" - }, - { - "php": "nightly", - "wp": "trunk", - "dbtype": "sqlite" - }, - { - "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" - }, - { - "php": "8.5", - "wp": "trunk", - "dbtype": "sqlite", - "os": "windows-2022" - } - ] - } - EOF - ) - MERGED_MATRIX=$(echo "$MATRIX" "$ADDITIONAL_MATRIX" | jq -s ' - . 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 - ) - } - ') - echo matrix=$MERGED_MATRIX >> $GITHUB_OUTPUT - - prepare-unit: - name: Prepare matrix for unit tests - needs: get-matrix - runs-on: ubuntu-22.04 - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} + 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' }} + + # 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_files + id: check_unit_files run: echo "files_exists=$([ -f composer.json ] && [ -f phpunit.xml.dist ] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - name: Set matrix - id: set-matrix + - 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 }} + INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} + INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} + WITH_COVERAGE: ${{ inputs.with-coverage }} run: | if [[ $FILE_EXISTS == 'true' ]]; then echo "matrix=$(jq -c \ - --argjson with_coverage_flag "${{ inputs.with-coverage }}" \ + --argjson with_coverage_flag "${WITH_COVERAGE}" \ --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ ' @@ -380,52 +404,23 @@ jobs: # Finally, get the unique entries unique_by([.php, .os]) ) - ' <<< "$BASE_MATRIX")" >> $GITHUB_OUTPUT + ' <<< "$BASE_MATRIX")" >> "$GITHUB_OUTPUT" else - echo "matrix=" >> $GITHUB_OUTPUT + echo "matrix=" >> "$GITHUB_OUTPUT" fi + + - name: Set functional test matrix + id: functional env: - BASE_MATRIX: ${{ needs.get-matrix.outputs.matrix }} - FILE_EXISTS: ${{ steps.check_files.outputs.files_exists == 'true' }} + BASE_MATRIX: ${{ steps.base.outputs.matrix }} + FILE_EXISTS: ${{ steps.check_functional_files.outputs.files_exists }} INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} - - unit: - needs: prepare-unit - if: ${{ needs.prepare-unit.outputs.matrix != '' }} - name: Unit - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.prepare-unit.outputs.matrix) }} - uses: ./.github/workflows/reusable-unit.yml - secrets: inherit - with: - php: ${{ matrix.php }} - coverage: ${{ matrix.coverage == true }} - os: ${{ matrix.os || '' }} - - prepare-functional: - name: Prepare matrix for functional tests - needs: get-matrix - runs-on: ubuntu-22.04 - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - name: Check out source code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Check existence of composer.json & behat.yml files - id: check_files - run: echo "files_exists=$([ -f composer.json ] && [ -f behat.yml ] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Set matrix - id: set-matrix + WITH_COVERAGE: ${{ inputs.with-coverage }} run: | if [[ $FILE_EXISTS == 'true' ]]; then echo "matrix=$(jq -c \ - --argjson with_coverage_flag "${{ inputs.with-coverage }}" \ + --argjson with_coverage_flag "${WITH_COVERAGE}" \ --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ ' @@ -452,23 +447,32 @@ jobs: ).wp |= $minimum_wp ) ) - ' <<< "$BASE_MATRIX" )" >> $GITHUB_OUTPUT + ' <<< "$BASE_MATRIX" )" >> "$GITHUB_OUTPUT" else - echo "matrix=" >> $GITHUB_OUTPUT + echo "matrix=" >> "$GITHUB_OUTPUT" fi - env: - BASE_MATRIX: ${{ needs.get-matrix.outputs.matrix }} - FILE_EXISTS: ${{ steps.check_files.outputs.files_exists == 'true' }} - INPUTS_MINIMUM_PHP: ${{ inputs.minimum-php }} - INPUTS_MINIMUM_WP: ${{ inputs.minimum-wp }} + + unit: + needs: prepare + if: ${{ needs.prepare.outputs.unit != '' }} + name: Unit + 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-functional - if: ${{ needs.prepare-functional.outputs.matrix != '' }} + needs: prepare + if: ${{ needs.prepare.outputs.functional != '' }} name: Behat strategy: fail-fast: false - matrix: ${{ fromJson(needs.prepare-functional.outputs.matrix) }} + matrix: ${{ fromJson(needs.prepare.outputs.functional) }} uses: ./.github/workflows/reusable-functional.yml secrets: inherit with: From d24e7a93d0ccc63917cb424ab5b103d789b6c8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:36:05 +0000 Subject: [PATCH 3/6] Trim per-job overhead in the reusable test and quality workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostscript ships with the GitHub hosted images, but every Behat leg ran `apt-get update && apt-get install ghostscript` regardless — roughly 25 seconds a leg, across 41 legs, in every repository. Skip it when `gs` is already on PATH and keep the install as a fallback. Same treatment for the macOS leg, where `brew install` is slower still. 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. A cache populated months ago therefore keeps serving `dev-*` requirements indefinitely. Rotate the key weekly, as wordpress-develop does. `%Y-%W` is used rather than the GNU-only `date --date='last Mon'` because these jobs also run on macOS and Windows. Other changes: * Give every job a `timeout-minutes`. None of them had one, so a hung Behat run could occupy a runner for the full six hour default. * Pin the actionlint image by digest instead of `:latest`, so the linter cannot change underneath ~45 repositories without a commit here. * Add `--fail` to the two curl calls. Without it curl writes the error page to the output file and the tools then run against garbage. * Pin the Node version used for the Gherkin lint job. * Let repositories with a heavy suite select a larger runner through a `RUNNERS_NAME` repository variable, without forking these workflows. The `os` inputs now default to an empty string so that fallback chain applies to direct callers too; the effective default is still ubuntu-22.04. * Skip checkout progress output unless the run is in debug mode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-code-quality.yml | 52 +++++++++++++++++++-- .github/workflows/reusable-functional.yml | 38 +++++++++++++-- .github/workflows/reusable-unit.yml | 22 ++++++++- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/.github/workflows/reusable-code-quality.yml b/.github/workflows/reusable-code-quality.yml index b1a4de7..5d1a81f 100644 --- a/.github/workflows/reusable-code-quality.yml +++ b/.github/workflows/reusable-code-quality.yml @@ -23,35 +23,48 @@ jobs: actionlint: name: Lint GitHub Actions workflows runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Add problem matcher run: | - curl -s -o .github/actionlint-matcher.json https://raw.githubusercontent.com/rhysd/actionlint/main/.github/actionlint-matcher.json + curl --fail --silent --show-error -o .github/actionlint-matcher.json https://raw.githubusercontent.com/rhysd/actionlint/main/.github/actionlint-matcher.json echo "::add-matcher::.github/actionlint-matcher.json" + # Pinned by digest rather than `:latest`, so the linter cannot change + # underneath ~45 repositories without a commit here, and so the image layer + # can be reused instead of re-resolved on every run. - name: Check workflow files - uses: docker://rhysd/actionlint:latest + uses: docker://rhysd/actionlint@sha256:5457037ba91acd225478edac3d4b32e45cf6c10291e0dabbfd2491c63129afe1 # v1.7.11 with: args: -color -shellcheck= lint: name: Lint PHP files runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Check existence of composer.json file id: check_composer_file run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" + # See the note on the equivalent step in reusable-unit.yml: no lock file is + # committed, so the cache key must be rotated on a schedule. + - name: Determine the weekly Composer cache suffix + id: composer-cache-suffix + run: echo "value=$(date -u +%Y-%W)" >> "$GITHUB_OUTPUT" + - name: Set up PHP environment if: steps.check_composer_file.outputs.files_exists == 'true' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -65,6 +78,8 @@ jobs: - name: Install Composer dependencies & cache dependencies if: steps.check_composer_file.outputs.files_exists == 'true' uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # 4.0.0 + with: + custom-cache-suffix: ${{ steps.composer-cache-suffix.outputs.value }} env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} @@ -98,29 +113,37 @@ jobs: lint-gherkin: name: Lint Gherkin Feature files runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Setup node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: 'lts/*' + # `--fail` matters here: without it curl happily writes an error page to the + # config file and the linter then runs against garbage rules. - name: Download lint rules - run: curl https://raw.githubusercontent.com/wp-cli/.github/refs/heads/main/.gherkin-lintrc -o $RUNNER_TEMP/.gherkin-lintrc + run: curl --fail --silent --show-error https://raw.githubusercontent.com/wp-cli/.github/refs/heads/main/.gherkin-lintrc -o "$RUNNER_TEMP/.gherkin-lintrc" - name: Run linter - run: npx --yes gherkin-lint -c $RUNNER_TEMP/.gherkin-lintrc + run: npx --yes gherkin-lint -c "$RUNNER_TEMP/.gherkin-lintrc" lint-spellcheck: name: Spell check runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Check existence of config file id: check_files @@ -133,17 +156,25 @@ jobs: phpcs: name: PHPCS runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Check existence of composer.json & phpcs.xml.dist files id: check_files run: echo "files_exists=$([ -f composer.json ] && [ -f phpcs.xml.dist ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + # See the note on the equivalent step in reusable-unit.yml: no lock file is + # committed, so the cache key must be rotated on a schedule. + - name: Determine the weekly Composer cache suffix + id: composer-cache-suffix + run: echo "value=$(date -u +%Y-%W)" >> "$GITHUB_OUTPUT" + - name: Set up PHP environment if: steps.check_files.outputs.files_exists == 'true' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -156,6 +187,8 @@ jobs: - name: Install Composer dependencies & cache dependencies if: steps.check_files.outputs.files_exists == 'true' uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # 4.0.0 + with: + custom-cache-suffix: ${{ steps.composer-cache-suffix.outputs.value }} env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} @@ -177,17 +210,25 @@ jobs: phpstan: name: PHPStan runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Check existence of composer.json file id: check_files run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" + # See the note on the equivalent step in reusable-unit.yml: no lock file is + # committed, so the cache key must be rotated on a schedule. + - name: Determine the weekly Composer cache suffix + id: composer-cache-suffix + run: echo "value=$(date -u +%Y-%W)" >> "$GITHUB_OUTPUT" + - name: Set up PHP environment if: steps.check_files.outputs.files_exists == 'true' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -200,10 +241,11 @@ jobs: - name: Install Composer dependencies & cache dependencies if: steps.check_files.outputs.files_exists == 'true' uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # 4.0.0 + with: + custom-cache-suffix: ${{ steps.composer-cache-suffix.outputs.value }} env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} - - name: Check existence of vendor/bin/phpstan file id: check_phpstan_binary_file run: echo "files_exists=$(test -f vendor/bin/phpstan && echo true || echo false)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index 5beb62a..58f6ab9 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -25,9 +25,10 @@ on: required: false default: false os: + description: 'Runner to use. Defaults to ubuntu-22.04, or the RUNNERS_NAME repository variable when set.' type: string required: false - default: 'ubuntu-22.04' + default: '' permissions: contents: read @@ -35,10 +36,15 @@ permissions: jobs: functional: name: Behat | PHP ${{ inputs.php }} | WP ${{ inputs.wp }} | ${{ inputs.dbtype == 'sqlite' && 'SQLite' || inputs.dbtype == 'mariadb' && 'MariaDB' || 'MySQL' }}${{ inputs.object_cache == 'sqlite' && ' (Obj Cache)' || '' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} - runs-on: ${{ inputs.os || 'ubuntu-22.04' }} + # 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' }} continue-on-error: ${{ inputs.dbtype == 'mariadb' || inputs.php == 'nightly' || startsWith( inputs.os, 'windows' ) || startsWith( inputs.os, 'macos' ) }} + timeout-minutes: ${{ ( startsWith( inputs.os, 'windows' ) || startsWith( inputs.os, 'macos' ) ) && 120 || inputs.coverage && 90 || 60 }} + env: MYSQL_HOST: 127.0.0.1 MYSQL_TCP_PORT: 3306 @@ -77,10 +83,19 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + # Ghostscript ships with the GitHub-hosted images, so the `apt-get update` + # this used to run unconditionally cost every Behat leg ~25s for a package + # that was already there. Keep the install as a fallback in case a future + # image drops it. - name: Install Ghostscript if: ${{ inputs.os == 'ubuntu-22.04' || inputs.os == '' }} run: | + if command -v gs > /dev/null 2>&1; then + echo "Ghostscript $(gs --version) is already installed; skipping." + exit 0 + fi sudo apt-get update sudo apt-get install ghostscript -y @@ -90,7 +105,12 @@ jobs: - name: Install dependencies (macOS) if: ${{ startsWith(inputs.os, 'macos') }} - run: brew install ghostscript + run: | + if command -v gs > /dev/null 2>&1; then + echo "Ghostscript $(gs --version) is already installed; skipping." + exit 0 + fi + brew install ghostscript - name: Set up PHP environment uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -108,8 +128,20 @@ jobs: run: | sudo sed -i 's/^.*policy.*coder.*none.*PDF.*//' /etc/ImageMagick-6/policy.xml + # 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. + # Rotating the suffix weekly stops `dev-*` requirements from being served out + # of an indefinitely stale cache. `%Y-%W` is portable across the GNU, BSD and + # Git-for-Windows implementations of `date`. + - name: Determine the weekly Composer cache suffix + id: composer-cache-suffix + shell: bash + run: echo "value=$(date -u +%Y-%W)" >> "$GITHUB_OUTPUT" + - name: Install Composer dependencies & cache dependencies uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # 4.0.0 + with: + custom-cache-suffix: ${{ steps.composer-cache-suffix.outputs.value }} env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 03a183f..f526d1e 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -10,9 +10,10 @@ on: required: false default: false os: + description: 'Runner to use. Defaults to ubuntu-22.04, or the RUNNERS_NAME repository variable when set.' type: string required: false - default: 'ubuntu-22.04' + default: '' permissions: contents: read @@ -20,14 +21,19 @@ permissions: jobs: unit: name: Unit | PHP ${{ inputs.php }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} - runs-on: ${{ inputs.os || 'ubuntu-22.04' }} + # 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' }} continue-on-error: ${{ inputs.php == 'nightly' }} + timeout-minutes: ${{ inputs.coverage && 30 || 15 }} steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up PHP environment uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -40,8 +46,20 @@ jobs: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # 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. + # Rotating the suffix weekly stops `dev-*` requirements from being served out + # of an indefinitely stale cache. `%Y-%W` is portable across the GNU, BSD and + # Git-for-Windows implementations of `date`. + - name: Determine the weekly Composer cache suffix + id: composer-cache-suffix + shell: bash + run: echo "value=$(date -u +%Y-%W)" >> "$GITHUB_OUTPUT" + - name: Install Composer dependencies & cache dependencies uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # 4.0.0 + with: + custom-cache-suffix: ${{ steps.composer-cache-suffix.outputs.value }} env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} From 3766bc454b4f72d4bac97d7cec29bc64681d356e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:47:40 +0000 Subject: [PATCH 4/6] Skip the test matrix for documentation-only changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull request that only touches a README currently runs the full matrix. wordpress-develop solves this with `paths` filters on the caller workflow, but that would not work here: `testing.yml` lives in each of the ~45 consumer repositories and carries their per-repository `minimum-php`, `minimum-wp` and `matrix` inputs, so it cannot be centrally synced without overwriting that configuration. Do the equivalent inside the reusable workflow instead, where it is already centrally controlled. The `prepare` job diffs the change against its base and emits empty matrices when nothing test-relevant was touched, which the existing `if: needs.prepare.outputs.* != ''` guards already turn into skipped jobs. This is deliberately a deny list rather than an allow list. The reusable workflow cannot know how a given package lays out its source, so the default is to test, and only changes that are provably irrelevant — Markdown, issue templates, LICENSE, editor and git metadata — are skipped. Every failure path falls back to testing as well: a non-diffable event, a newly created branch reporting an all-zero base, or a base commit that cannot be resolved all run the full matrix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-testing.yml | 62 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-testing.yml b/.github/workflows/reusable-testing.yml index 881620a..37d1d83 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -39,7 +39,7 @@ jobs: prepare: name: Prepare test matrices runs-on: ubuntu-22.04 - timeout-minutes: 5 + timeout-minutes: 10 outputs: unit: ${{ steps.unit.outputs.matrix }} functional: ${{ steps.functional.outputs.matrix }} @@ -49,6 +49,60 @@ jobs: 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. + 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 @@ -375,11 +429,12 @@ jobs: 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' ]]; then + if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then echo "matrix=$(jq -c \ --argjson with_coverage_flag "${WITH_COVERAGE}" \ --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ @@ -414,11 +469,12 @@ jobs: 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' ]]; then + if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != 'true' ]]; then echo "matrix=$(jq -c \ --argjson with_coverage_flag "${WITH_COVERAGE}" \ --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ From 5b96c48cc5c473486b49b8291caeef17275c127d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 06:37:05 +0000 Subject: [PATCH 5/6] Group the test legs by PHP version in the run view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calling job's name is what the Actions run view groups by, and it was the static string "Behat". All 41 legs therefore landed in one flat group, and because the called workflow repeated the same word the checks read "test / Behat / Behat | PHP 8.3 | WP latest | MySQL". Move the grouping key into the calling job, as wordpress-develop does with "PHP ${{ matrix.php }}", and leave the called workflow to identify the leg within its group. The run view now shows one collapsible entry per PHP version. This also fixes an ambiguity. The leg name rendered every non-SQLite database as plain "MySQL", so mysql-5.6, 5.7, 8.0 and 8.4 were indistinguishable — seven pairs of check runs shared an identical name and a failure could not be attributed to a database version without opening the job. Spell the version out instead. All 41 Behat check names are now unique. Note for anyone with branch protection: these check names change, so any required status check configured against the old names needs updating. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-functional.yml | 6 +++++- .github/workflows/reusable-testing.yml | 8 ++++++-- .github/workflows/reusable-unit.yml | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index 58f6ab9..66ef1e2 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -35,7 +35,11 @@ permissions: jobs: functional: - name: Behat | PHP ${{ inputs.php }} | WP ${{ inputs.wp }} | ${{ inputs.dbtype == 'sqlite' && 'SQLite' || inputs.dbtype == 'mariadb' && 'MariaDB' || 'MySQL' }}${{ inputs.object_cache == 'sqlite' && ' (Obj Cache)' || '' }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # The calling job already states "Behat | PHP x" and groups on it, so this only + # needs to identify the leg within that group. The database version is spelled + # out because "MySQL" alone rendered the mysql-8.0 and mysql-8.4 legs + # identically. + name: 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-testing.yml b/.github/workflows/reusable-testing.yml index 37d1d83..3bfc4be 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -511,7 +511,9 @@ jobs: unit: needs: prepare if: ${{ needs.prepare.outputs.unit != '' }} - name: Unit + # The calling job's name is what the run view groups by, so it carries the + # grouping key. The called workflow names the individual leg within the group. + name: Unit | PHP ${{ matrix.php }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.unit) }} @@ -525,7 +527,9 @@ jobs: functional: needs: prepare if: ${{ needs.prepare.outputs.functional != '' }} - name: Behat + # Groups the Behat legs by PHP version, so the run view shows one collapsible + # entry per version instead of a single flat list of 41 jobs. + name: Behat | PHP ${{ matrix.php }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.functional) }} diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index f526d1e..544bc54 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -20,7 +20,9 @@ permissions: jobs: unit: - name: Unit | PHP ${{ inputs.php }}${{ inputs.coverage && ' (with coverage)' || '' }}${{ startsWith( inputs.os, 'windows' ) && ' (Windows)' || '' }}${{ startsWith( inputs.os, 'macos' ) && ' (macOS)' || '' }} + # The calling job already states "Unit | PHP x" and groups on it, so this only + # needs to identify the leg within that group. + name: 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. From 3c757a291b472bb5a2fe015eab60e52d96389a8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:44:11 +0000 Subject: [PATCH 6/6] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: * `fetch-depth` was always 1. In GitHub expressions `A && 0 || 1` yields 1 even when A holds, because 0 is falsy and the `||` falls through. Pull request and push runs were therefore checking out a shallow clone, the diff against the base failed, and the documentation-only detection silently fell back to testing everything on every run. Quote the operands so the true branch is truthy. Confirmed against zizmor's unsound-ternary audit, which flags the old form and accepts the new one. * The Ubuntu-only steps keyed off `inputs.os` being empty, which no longer implies ubuntu-22.04 now that `RUNNERS_NAME` can select the image. Key them on `runner.os` instead, and skip the ImageMagick policy edit when the file is absent — `sed -i` on a missing file exits non-zero and would fail the leg. * Group the concurrency key on `github.ref` rather than `github.sha`. The event name in the key is already what keeps a push to the default branch and the nightly schedule apart, so keying on the commit only prevented a new push from superseding the run it replaces. Also raise the sync timeouts from 20 to 60 minutes. They are meant to bound a pathological run rather than pace a healthy one, and the sync is idempotent, so a run cut short is completed by the next push or the daily schedule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jy4dmjymj9VmoTBaqrV4iX --- .github/workflows/reusable-functional.yml | 15 ++++++++++++--- .github/workflows/reusable-testing.yml | 14 ++++++++------ .github/workflows/sync-workflows.yml | 10 ++++++++-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index 66ef1e2..48c1fe5 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -94,7 +94,9 @@ jobs: # that was already there. Keep the install as a fallback in case a future # image drops it. - name: Install Ghostscript - if: ${{ inputs.os == 'ubuntu-22.04' || inputs.os == '' }} + # 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. + if: ${{ runner.os == 'Linux' }} run: | if command -v gs > /dev/null 2>&1; then echo "Ghostscript $(gs --version) is already installed; skipping." @@ -128,9 +130,16 @@ jobs: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Change ImageMagick policy to allow pdf->png conversion. - if: ${{ inputs.os == 'ubuntu-22.04' || inputs.os == '' }} + # 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. + if: ${{ runner.os == 'Linux' }} run: | - sudo sed -i 's/^.*policy.*coder.*none.*PDF.*//' /etc/ImageMagick-6/policy.xml + 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.' + fi # 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-testing.yml b/.github/workflows/reusable-testing.yml index 3bfc4be..ff2b541 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -29,10 +29,11 @@ permissions: # Cancels all previous workflow runs for the same branch that have not yet completed. concurrency: - # Pull requests group per branch so a new push supersedes the previous run. - # Everything else groups per event and commit, so a push to the default branch - # and the nightly schedule never cancel each other. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + # Keying on the ref lets a new push supersede the run it replaces, for pull + # requests and branches alike. Keying on the event as well keeps a push to the + # default branch and the nightly schedule from cancelling each other, since + # both report the same ref. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -50,8 +51,9 @@ jobs: 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. - fetch-depth: ${{ ( github.event_name == 'pull_request' || github.event_name == 'push' ) && 0 || 1 }} + # 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 diff --git a/.github/workflows/sync-workflows.yml b/.github/workflows/sync-workflows.yml index 1999c46..fab9d94 100644 --- a/.github/workflows/sync-workflows.yml +++ b/.github/workflows/sync-workflows.yml @@ -36,7 +36,10 @@ jobs: sync-workflows: name: Sync GitHub Actions workflows runs-on: ubuntu-latest - timeout-minutes: 20 + # Generous, because the job pushes to ~45 repositories. It exists to bound a + # pathological run, not to pace a healthy one. The sync is idempotent, so a + # run cut short here is completed by the next push or the daily schedule. + timeout-minutes: 60 if: ${{ github.repository_owner == 'wp-cli' }} permissions: contents: write @@ -104,7 +107,10 @@ jobs: sync-dependabot: name: Sync Dependabot configuration runs-on: ubuntu-latest - timeout-minutes: 20 + # Generous, because the job pushes to ~45 repositories. It exists to bound a + # pathological run, not to pace a healthy one. The sync is idempotent, so a + # run cut short here is completed by the next push or the daily schedule. + timeout-minutes: 60 if: ${{ github.repository_owner == 'wp-cli' }} permissions: contents: write