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..48c1fe5 100644 --- a/.github/workflows/reusable-functional.yml +++ b/.github/workflows/reusable-functional.yml @@ -25,20 +25,30 @@ 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 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' }} + # 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. + 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 +87,21 @@ 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 == '' }} + # 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." + exit 0 + fi sudo apt-get update sudo apt-get install ghostscript -y @@ -90,7 +111,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 @@ -104,12 +130,31 @@ 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. + # 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-testing.yml b/.github/workflows/reusable-testing.yml index 71700a7..ff2b541 100644 --- a/.github/workflows/reusable-testing.yml +++ b/.github/workflows/reusable-testing.yml @@ -29,335 +29,416 @@ 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 }} + # 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: - get-matrix: - name: Get base test matrix + prepare: + name: Prepare test matrices runs-on: ubuntu-22.04 + timeout-minutes: 10 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' }} + # 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_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 }} + 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 "${{ inputs.with-coverage }}" \ + --argjson with_coverage_flag "${WITH_COVERAGE}" \ --arg minimum_php "${INPUTS_MINIMUM_PHP}" \ --arg minimum_wp "${INPUTS_MINIMUM_WP}" \ ' @@ -380,52 +461,24 @@ 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 }} + DOCS_ONLY: ${{ steps.docs-only.outputs.value }} 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 + if [[ $FILE_EXISTS == 'true' && $DOCS_ONLY != '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 +505,36 @@ 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 != '' }} + # 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) }} + 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 != '' }} - name: Behat + needs: prepare + if: ${{ needs.prepare.outputs.functional != '' }} + # 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-functional.outputs.matrix) }} + matrix: ${{ fromJson(needs.prepare.outputs.functional) }} uses: ./.github/workflows/reusable-functional.yml secrets: inherit with: diff --git a/.github/workflows/reusable-unit.yml b/.github/workflows/reusable-unit.yml index 03a183f..544bc54 100644 --- a/.github/workflows/reusable-unit.yml +++ b/.github/workflows/reusable-unit.yml @@ -10,24 +10,32 @@ 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 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' }} + # 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. + 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 +48,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 }} diff --git a/.github/workflows/sync-workflows.yml b/.github/workflows/sync-workflows.yml index 6491a4a..fab9d94 100644 --- a/.github/workflows/sync-workflows.yml +++ b/.github/workflows/sync-workflows.yml @@ -6,17 +6,40 @@ 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 + # 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 @@ -84,6 +107,10 @@ jobs: sync-dependabot: name: Sync Dependabot configuration runs-on: ubuntu-latest + # 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