diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index abd42cb0..11850e9b 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -13,10 +13,10 @@ on: type: string default: '' php-version: - description: "PHP version the job runs on; the analysis level and phpVersion come from the plugin's phpstan.neon" + description: "PHP version the job runs on; the analysis level and phpVersion come from the plugin's phpstan.neon. Accepts a literal version or one of the shared aliases (matomo5_min_php, matomo5_max_php, matomo6_min_php, matomo6_max_php), which track a Matomo major's floor and ceiling centrally." required: false type: string - default: '7.2' + default: 'matomo5_min_php' scripts-ref: description: "Ref of matomo-org/github-action-tests to take the helper scripts from. When pinning the workflow to a SHA, pass the same SHA here." required: false @@ -27,6 +27,16 @@ on: required: false type: boolean default: true + matomo-targets: + description: >- + JSON array of {target, php} objects, one per analysis run. Analysing the minimum target + is what catches a plugin calling a core API that does not exist yet in the oldest Matomo + its plugin.json supports. `php` is optional and falls back to the php-version input; set + it per target when the targets span Matomo majors, because a Matomo 6 checkout cannot be + bootstrapped by the PHP 7.2 that Matomo 5 allows. + required: false + type: string + default: '[{"target": "minimum_required_matomo"}, {"target": "maximum_supported_matomo"}]' secrets: TESTS_ACCESS_TOKEN: required: false @@ -38,8 +48,12 @@ permissions: jobs: phpstan: - name: PHPStan + name: PHPStan (${{ matrix.target }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.matomo-targets) }} env: PLUGIN_NAME: ${{ inputs.plugin-name }} DEPENDENT_PLUGINS: ${{ inputs.dependent-plugins }} @@ -55,14 +69,11 @@ jobs: with: lfs: false persist-credentials: false - - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: ${{ inputs.php-version }} # No GitHub context identifies the called workflow's own commit (verified # empirically: github.job_workflow_sha evaluates to empty), so pinning the - # scripts is the caller's job via scripts-ref. + # scripts is the caller's job via scripts-ref. This runs before PHP is set up + # because the version aliases are resolved by one of those scripts. - name: Check out github-action-tests repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -71,6 +82,23 @@ jobs: path: github-action-tests persist-credentials: false + - name: Resolve PHP version + id: resolve-php + shell: bash + env: + # per-target when the targets span Matomo majors, else the workflow-wide input + PHP_VERSION_INPUT: ${{ matrix.php || inputs.php-version }} + run: | + RESOLVED_VERSION=$("${{ github.workspace }}/github-action-tests/scripts/bash/resolve_php_version.sh" "$PHP_VERSION_INPUT") + + echo "Resolved php-version '$PHP_VERSION_INPUT' to '$RESOLVED_VERSION'" + echo "version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ steps.resolve-php.outputs.version }} + - name: Check the pre-push hook matches the canonical copy if: ${{ inputs.verify-hook }} shell: bash @@ -82,7 +110,45 @@ jobs: env: WORKSPACE: ${{ github.workspace }} ACTION_PATH: ${{ github.workspace }}/github-action-tests - MATOMO_TEST_TARGET: maximum_supported_matomo + MATOMO_TEST_TARGET: ${{ matrix.target }} + + - name: Check the PHP version can bootstrap this Matomo + shell: bash + working-directory: ${{ github.workspace }}/matomo + env: + MATOMO_TARGET: ${{ matrix.target }} + run: | + # bootstrap-phpstan.php executes core rather than only parsing it, so analysing a + # Matomo 6 checkout under the PHP 7.2 that Matomo 5 allows dies on 8.1 syntax partway + # through. Catch it here, where the message can say what to change. + MATOMO_MIN_PHP=$(grep -oE "piwik_minimumPHPVersion = '[0-9.]+'" core/testMinimumPhpVersion.php | grep -oE "[0-9]+\.[0-9.]+" | head -1) + + if [ -z "$MATOMO_MIN_PHP" ]; then + echo "Could not read piwik_minimumPHPVersion; skipping the compatibility check." + exit 0 + fi + + if ! php -r 'exit(version_compare(PHP_VERSION, $argv[1], ">=") ? 0 : 1);' "$MATOMO_MIN_PHP"; then + echo "::error::Matomo target '$MATOMO_TARGET' requires PHP >= $MATOMO_MIN_PHP but this job runs $(php -r 'echo PHP_VERSION;'). Give this target its own PHP in the matomo-targets input, for example {\"target\": \"$MATOMO_TARGET\", \"php\": \"matomo6_min_php\"}." + exit 1 + fi + + echo "PHP $(php -r 'echo PHP_VERSION;') satisfies this Matomo's floor of $MATOMO_MIN_PHP." + + - name: Provide the PHPStan bootstrap when Matomo predates it + shell: bash + working-directory: ${{ github.workspace }}/matomo + run: | + if [ -f bootstrap-phpstan.php ]; then + echo "Matomo provides bootstrap-phpstan.php." + exit 0 + fi + # Matomo only ships this file from 5.4.0, and plugin phpstan.neon files reference it + # as ../../bootstrap-phpstan.php, so without it PHPStan exits before analysing + # anything on older targets. It has to be copied into the Matomo root rather than + # referenced in place: it derives PIWIK_DOCUMENT_ROOT from its own location. + cp "${{ github.workspace }}/github-action-tests/artifacts/bootstrap-phpstan.php" bootstrap-phpstan.php + echo "Matomo predates bootstrap-phpstan.php; using the copy from github-action-tests." - name: prepare setup shell: bash @@ -91,6 +157,21 @@ jobs: echo -e "composer install" composer install --ignore-platform-reqs + - name: Provide PHPStan itself when Matomo predates it + shell: bash + working-directory: ${{ github.workspace }}/matomo + run: | + if [ -x vendor/bin/phpstan ]; then + echo "Matomo provides PHPStan." + exit 0 + fi + # Matomo gained phpstan/phpstan, the composer `phpstan` script and + # bootstrap-phpstan.php in one commit, released in 5.4.0. On an older target there + # is no analyser at all, so install the same constraint that Matomo declares rather + # than the newest release, whose own findings would differ from the max leg's. + echo "Matomo predates phpstan/phpstan; installing ~1.12 to analyse against it." + composer require --dev --ignore-platform-reqs --no-interaction "phpstan/phpstan:~1.12" + - name: checkout additional plugins if: ${{ inputs.dependent-plugins != '' }} shell: bash @@ -104,17 +185,40 @@ jobs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /tmp/phpstan # same as in phpstan.neon - key: "phpstan-result-cache-${{ github.run_id }}" + # the target is part of the key because the legs analyse different Matomo trees and + # both save within one run, which would otherwise collide on a single key + key: "phpstan-result-cache-${{ matrix.target }}-${{ github.run_id }}" restore-keys: | - phpstan-result-cache- + phpstan-result-cache-${{ matrix.target }}- - name: PHPStan whole repo id: phpstan-all - run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c "plugins/${PLUGIN_NAME}/phpstan.neon" + shell: bash + working-directory: ${{ github.workspace }}/matomo + env: + MATOMO_TARGET: ${{ matrix.target }} + # not `composer run phpstan`: that script arrived with PHPStan itself in 5.4.0, so it + # does not exist on older targets. The binary is equivalent — the script is only + # `phpstan analyse -c phpstan.neon`, whose config this command overrides anyway. + run: | + CONFIG="plugins/${PLUGIN_NAME}/phpstan.neon" + + # A plugin may guard a newer core API behind class_exists and still be analysed + # against an older Matomo that lacks it, which PHPStan cannot see through. An + # optional phpstan-min-matomo.neon holds those ignores. It applies to this leg only: + # on the maximum leg the error does not occur, so the same ignore would be unmatched + # and reportUnmatchedIgnoredErrors would fail that leg instead. + if [ "$MATOMO_TARGET" = 'minimum_required_matomo' ] \ + && [ -f "plugins/${PLUGIN_NAME}/phpstan-min-matomo.neon" ]; then + CONFIG="plugins/${PLUGIN_NAME}/phpstan-min-matomo.neon" + echo "Using the plugin's minimum-target config: $CONFIG" + fi + + vendor/bin/phpstan analyse -vvv -c "$CONFIG" - name: "Save result cache" uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: ${{ !cancelled() }} with: path: /tmp/phpstan # same as in phpstan.neon - key: "phpstan-result-cache-${{ github.run_id }}" + key: "phpstan-result-cache-${{ matrix.target }}-${{ github.run_id }}" diff --git a/README.md b/README.md index ff23811d..16bb5a8e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ This action is able to run certain test suites for Matomo or any Matomo plugin. Defines the PHP version to set up for testing. (Not needed for Client tests) - Use `matomo5_min_php`/`matomo5_max_php` (Matomo 5) or `matomo6_min_php`/`matomo6_max_php` (Matomo 6) to resolve to the centrally managed (defined in action.yml) minimum or maximum PHP versions supported by Matomo tests. + Use `matomo5_min_php`/`matomo5_max_php` (Matomo 5) or `matomo6_min_php`/`matomo6_max_php` (Matomo 6) to resolve to the centrally managed minimum or maximum PHP versions supported by Matomo tests. The alias table is `scripts/bash/resolve_php_version.sh`, shared with the reusable PHPStan workflow so the two cannot drift apart. The action uses `shivammathur/setup-php` to set up PHP. You can find supported PHP versions here: https://github.com/shivammathur/setup-php#tada-php-support @@ -171,7 +171,7 @@ jobs: with: plugin-name: MyPlugin # dependent-plugins: 'innocraft/plugin-Funnels' - # php-version: '8.2' + # php-version: matomo5_min_php # or a literal version such as '8.2' secrets: TESTS_ACCESS_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN }} ``` @@ -181,6 +181,56 @@ scripts. For immutability, pin the `uses:` reference to a full commit SHA — re mutable unless the repository enforces immutable releases — and pass the same SHA as `scripts-ref`, which the workflow uses to check out its helper scripts (default: `main`). +The analysis runs once per entry in `matomo-targets`, which defaults to the minimum Matomo the +plugin's `plugin.json` declares and the maximum it supports. The minimum leg is the one that +catches a plugin calling a core API that does not exist yet in the oldest Matomo it claims to +support — a call that analyses cleanly against current core and then fatals for those users. +Pass a single-element array to analyse against one target: + +```yaml + matomo-targets: '[{"target": "maximum_supported_matomo"}]' +``` + +Each entry may carry its own `php`, falling back to the `php-version` input when it does not. +Set it per target once a plugin's targets span Matomo majors: `bootstrap-phpstan.php` executes +core rather than only parsing it, so a Matomo 6 checkout cannot be bootstrapped by the PHP 7.2 +that Matomo 5 still allows. The workflow compares the resolved PHP against the checked-out +Matomo's `piwik_minimumPHPVersion` and fails with that instruction rather than dying on 8.1 +syntax partway through the analysis. + +```yaml + matomo-targets: >- + [{"target": "minimum_required_matomo", "php": "matomo5_min_php"}, + {"target": "maximum_supported_matomo", "php": "matomo6_min_php"}] +``` + +Matomo gained `phpstan/phpstan`, the composer `phpstan` script and `bootstrap-phpstan.php` in a +single commit, released in 5.4.0. On an older target none of them exist, so the workflow copies +`artifacts/bootstrap-phpstan.php` into the Matomo root, installs the `~1.12` PHPStan that Matomo +itself declares, and runs `vendor/bin/phpstan` rather than the composer script. That is what +keeps the minimum leg usable for the many plugins whose declared floor predates 5.4.0. + +A plugin may legitimately call a core API that its minimum Matomo does not have, guarded by +`class_exists` so the path is unreachable there. PHPStan cannot see through that guard and +reports the call on the minimum leg. Put those ignores in an optional +`plugins//phpstan-min-matomo.neon`, which the workflow uses **for the minimum leg only**: + +```neon +includes: + - phpstan.neon + +parameters: + ignoreErrors: + - + message: '#unknown class Piwik\\Plugins\\CoreHome\\EntityDuplicator\\EntityDuplicatorHelper#' + path: API.php +``` + +Keeping these out of the plugin's main `phpstan.neon` matters. On the maximum leg the class +exists, so the same entry would match nothing, and `reportUnmatchedIgnoredErrors` — on by +default — would fail that leg instead. Unmatched entries in the minimum-only file are still +reported on the minimum leg, so an ignore that outlives the incompatibility still surfaces. + ## Git hooks (`hooks/`) `hooks/pre-push` is the canonical copy of the PHPStan pre-push hook that plugin repositories diff --git a/action.yml b/action.yml index bdbf78a4..f223668b 100644 --- a/action.yml +++ b/action.yml @@ -194,27 +194,13 @@ runs: - name: Resolve PHP version id: resolve-php shell: bash + # the alias table lives in scripts/bash/resolve_php_version.sh so this action and the + # reusable PHPStan workflow cannot drift apart on it. The input arrives through the + # environment rather than an expression, so a caller's value is never expanded as shell. + env: + PHP_VERSION_INPUT: ${{ inputs.php-version }} run: | - case "${{ inputs.php-version }}" in - matomo5_min_php) - RESOLVED_VERSION="7.2" - ;; - matomo5_max_php) - RESOLVED_VERSION="8.5" - ;; - matomo6_min_php) - RESOLVED_VERSION="8.1" - ;; - matomo6_max_php) - RESOLVED_VERSION="8.5" - ;; - '') - exit 0 - ;; - *) - RESOLVED_VERSION="${{ inputs.php-version }}" - ;; - esac + RESOLVED_VERSION=$("${{ github.action_path }}/scripts/bash/resolve_php_version.sh" "$PHP_VERSION_INPUT") echo "version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT" diff --git a/artifacts/bootstrap-phpstan.php b/artifacts/bootstrap-phpstan.php new file mode 100644 index 00000000..06b9c4f7 --- /dev/null +++ b/artifacts/bootstrap-phpstan.php @@ -0,0 +1,32 @@ +init(); +} catch (NotYetInstalledException $e) { +} diff --git a/scripts/bash/resolve_php_version.sh b/scripts/bash/resolve_php_version.sh new file mode 100755 index 00000000..60022480 --- /dev/null +++ b/scripts/bash/resolve_php_version.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Resolves the centrally managed PHP version aliases to concrete versions, so that a caller can +# track a Matomo major's floor or ceiling without repeating the number. Anything unrecognised is +# echoed back unchanged, which is what lets callers pass a literal version such as 8.2. Empty +# input echoes empty, which callers use to mean "do not set PHP up at all". +# +# This table is shared by action.yml and .github/workflows/plugin-phpstan.yml. Keep it here +# rather than inline in either: a copy in both drifts exactly when a floor moves, which is the +# moment the value matters. + +case "$1" in + matomo5_min_php) + RESOLVED_VERSION="7.2" + ;; + matomo5_max_php) + RESOLVED_VERSION="8.5" + ;; + matomo6_min_php) + RESOLVED_VERSION="8.1" + ;; + matomo6_max_php) + RESOLVED_VERSION="8.5" + ;; + *) + RESOLVED_VERSION="$1" + ;; +esac + +printf '%s' "$RESOLVED_VERSION"