diff --git a/.github/workflows/reusable-functional.yml b/.github/workflows/reusable-functional.yml index d1ab1a8..3bf0cc6 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -25,26 +25,37 @@ 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: '' + 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. - 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' ) }} @@ -96,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 @@ -131,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 new file mode 100644 index 0000000..ad82704 --- /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 coverage 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-24.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/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 6bcb8f7..565cd18 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -10,24 +10,31 @@ 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: '' + 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. 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. - 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 }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 35d7562..f917c6e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,6 +13,63 @@ 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. + # + # 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) }} + uses: ./.github/workflows/reusable-unit.yml + secrets: inherit + with: + 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 + 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 || '' }} + # The job name above already states the suite and PHP version. + grouped: true diff --git a/README.md b/README.md index 2ebbd65..e2142b4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,72 @@ 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. 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`, 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`. + +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) +``` + +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)`. + +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. + +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 The branch alias checker workflow automatically ensures that the Composer `branch-alias` in each repository's `composer.json` is up-to-date. It: