From f3106a7ef1ad9c224766c1fa08cb4f94445541f7 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:06:11 +1200 Subject: [PATCH 01/12] Add a reusable PHPStan workflow and the canonical pre-push hook, PG-4897 Plugin repos currently carry near-identical copies of both; four rounds of hook fixes each meant twelve pull requests. The workflow also fails when a plugin's hook copy drifts from the canonical one. --- .github/workflows/plugin-phpstan.yml | 111 +++++++++++++++++++++++++ README.md | 28 +++++++ hooks/pre-push | 117 +++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 .github/workflows/plugin-phpstan.yml create mode 100755 hooks/pre-push diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml new file mode 100644 index 0000000..6860d29 --- /dev/null +++ b/.github/workflows/plugin-phpstan.yml @@ -0,0 +1,111 @@ +name: PHPStan check + +on: + workflow_call: + inputs: + plugin-name: + description: "Name of the plugin, e.g. LoginLdap" + required: true + type: string + dependent-plugins: + description: "Space-separated repository slugs to check out, e.g. 'innocraft/plugin-Funnels'" + required: false + type: string + default: '' + php-version: + description: "PHP version the job runs on; the analysis level and phpVersion come from the plugin's phpstan.neon" + required: false + type: string + default: '7.2' + verify-hook: + description: "Fail when the plugin's .git-hooks-matomo/pre-push differs from the canonical copy in this repository" + required: false + type: boolean + default: true + secrets: + TESTS_ACCESS_TOKEN: + required: false + +permissions: + actions: read + checks: read + contents: read + deployments: none + issues: read + packages: none + pull-requests: read + repository-projects: none + security-events: none + statuses: read + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + env: + PLUGIN_NAME: ${{ inputs.plugin-name }} + DEPENDENT_PLUGINS: ${{ inputs.dependent-plugins }} + steps: + - uses: actions/checkout@v4 + with: + lfs: false + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + + - name: Check out github-action-tests repository + uses: actions/checkout@v4 + with: + repository: matomo-org/github-action-tests + ref: main + path: github-action-tests + persist-credentials: false + + - name: Check the pre-push hook matches the canonical copy + if: ${{ inputs.verify-hook }} + shell: bash + run: diff -u github-action-tests/hooks/pre-push .git-hooks-matomo/pre-push + + - name: checkout matomo for plugin builds + shell: bash + run: ${{ github.workspace }}/github-action-tests/scripts/bash/checkout_matomo.sh + env: + WORKSPACE: ${{ github.workspace }} + ACTION_PATH: ${{ github.workspace }}/github-action-tests + MATOMO_TEST_TARGET: maximum_supported_matomo + + - name: prepare setup + shell: bash + run: | + cd ${{ github.workspace }}/matomo + echo -e "composer install" + composer install --ignore-platform-reqs + + - name: checkout additional plugins + if: ${{ inputs.dependent-plugins != '' }} + shell: bash + working-directory: ${{ github.workspace }}/matomo + run: ${{ github.workspace }}/github-action-tests/scripts/bash/checkout_dependent_plugins.sh + env: + GITHUB_USER_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN || github.token }} + + - name: "Restore result cache" + uses: actions/cache/restore@v4 + with: + path: /tmp/phpstan # same as in phpstan.neon + key: "phpstan-result-cache-${{ github.run_id }}" + restore-keys: | + phpstan-result-cache- + + - name: PHPStan whole repo + id: phpstan-all + run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c plugins/${{ env.PLUGIN_NAME }}/phpstan.neon + + - name: "Save result cache" + uses: actions/cache/save@v4 + if: ${{ !cancelled() }} + with: + path: /tmp/phpstan # same as in phpstan.neon + key: "phpstan-result-cache-${{ github.run_id }}" diff --git a/README.md b/README.md index c4c668b..7e8c83e 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,31 @@ This action is able to run certain test suites for Matomo or any Matomo plugin. dependent-plugins: 'slug/plugin-AdditionalPlugin' github-token: ${{ secrets.TESTS_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} ``` + +## Reusable workflows + +### PHPStan (`.github/workflows/plugin-phpstan.yml`) + +Runs the plugin's own `phpstan.neon` against a full Matomo checkout. A plugin repository's +`.github/workflows/phpstan.yml` reduces to: + +```yaml +name: PHPStan check +on: pull_request +jobs: + phpstan: + uses: matomo-org/github-action-tests/.github/workflows/plugin-phpstan.yml@main + with: + plugin-name: MyPlugin + # dependent-plugins: 'innocraft/plugin-Funnels' + # php-version: '8.2' + secrets: inherit +``` + +## Git hooks (`hooks/`) + +`hooks/pre-push` is the canonical copy of the PHPStan pre-push hook that plugin repositories +carry as `.git-hooks-matomo/pre-push` (developers opt in with +`git config core.hooksPath .git-hooks-matomo`). Hooks must exist as local files, so plugins keep +a copy; the reusable PHPStan workflow fails when a plugin's copy drifts from the canonical one +(disable with `verify-hook: false`). diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..bcbefd3 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,117 @@ +#!/bin/bash + +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# + + + +### Check we're running in the context of a plugin and get helpful dir variables ### + +REPO_DIR="$(git rev-parse --show-toplevel)" +echo "Running pre-push hook in repo: $REPO_DIR" + +if [[ "$REPO_DIR" =~ /plugins/(.*) ]]; then + PLUGIN_PATH="plugins/${BASH_REMATCH[1]}/" +else + echo "Not a plugin, not running any further checks" + exit 1 +fi +MATOMO_DIR=$(echo "$REPO_DIR" | sed -E 's|/plugins/.*$||') + + + +### Figure out how to run PHPStan - ddev or not. ### + +COMMAND="" +# Use local PHP if setup +if command -v php >/dev/null 2>&1 && [ -f "${MATOMO_DIR}/vendor/bin/phpstan" ]; then + COMMAND="${MATOMO_DIR}/vendor/bin/phpstan" + PLUGIN_PATH='' +elif command -v ddev >/dev/null 2>&1; then + # Use ddev if setup (overridding local setup) + if [ -d "$MATOMO_DIR/.ddev" ]; then + cd "$MATOMO_DIR" || exit 1 + if ddev status 2>&1 > /dev/null; then + COMMAND="ddev exec phpstan" + fi + fi +fi +# If no command, exit +if [[ -z "$COMMAND" ]]; then + echo "No way to run phpstan found." + exit 1 +fi + + + +# Basic setup +cd "$REPO_DIR" +STATUS=0 + + + + +### Resolve the base to diff against. ### + +# Use the merge base with the remote main branch: the local branch can be stale +# or missing, which silently widens the diff to files the push doesn't touch. +MAIN_BRANCH='5.x-dev' +DIFF_BASE=$(git merge-base HEAD "origin/${MAIN_BRANCH}" 2>/dev/null) +if [[ -z "$DIFF_BASE" ]]; then + echo "Could not resolve the merge base between HEAD and origin/${MAIN_BRANCH}." + echo "Run 'git fetch origin ${MAIN_BRANCH}' and push again." + exit 1 +fi + + + +### Run PHPStan on newly created files. ### + +PHPSTAN_CREATED_CONFIG=phpstan/phpstan.created.neon +if [[ -f "$PHPSTAN_CREATED_CONFIG" ]]; then + CHANGED_FILES=$(git diff --name-only ${DIFF_BASE} HEAD --diff-filter=A | grep '\.php$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No created PHP files" + else + echo "Running PHPstan at a very high level on new files" + CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` + echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_CREATED_CONFIG} || STATUS=1 + fi +fi + + + +### Run PHPStan on modified files. ### +PHPSTAN_MODIFIED_CONFIG=phpstan/phpstan.modified.neon +if [[ -f "$PHPSTAN_MODIFIED_CONFIG" ]]; then + CHANGED_FILES=$(git diff --name-only ${DIFF_BASE} HEAD --diff-filter=CM | grep '\.php$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No changed PHP files" + else + echo "Running PHPstan on modified files" + CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` + echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_MODIFIED_CONFIG} || STATUS=1 + fi +fi + +# Don't bother running the full check, as we check changes files already, and +# can assume that the unchanged files don't need rechecking. +# +# Github will check this anyway. +# +# PHPSTAN_BASE_CONFIG=phpstan.neon +# if [[ -f "$PHPSTAN_BASE_CONFIG" ]]; then +# echo "Running PHPstan at a base level on all plugin files" +# $COMMAND analyse -c ${PLUGIN_PATH}/${PHPSTAN_BASE_CONFIG} || STATUS=1 +# fi + +exit $STATUS From e13a0ca090b92718b99d284c815a8be5bde5cb8c Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:12:08 +1200 Subject: [PATCH 02/12] Pin setup-php to a commit hash --- .github/workflows/plugin-phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index 6860d29..0b46139 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -51,7 +51,7 @@ jobs: lfs: false persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} From 2d627d4360121b829a146491d92ca0efd5d5874b Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:12:08 +1200 Subject: [PATCH 03/12] Analyse the commits supplied on the hook's stdin rather than HEAD Pushing another local branch, or several refs at once, analysed the checked-out branch instead of what was actually pushed. --- hooks/pre-push | 79 +++++++++++++++++++++++++++----------------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/hooks/pre-push b/hooks/pre-push index bcbefd3..54bb7e5 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -56,52 +56,59 @@ fi # Basic setup cd "$REPO_DIR" STATUS=0 - - - - -### Resolve the base to diff against. ### - -# Use the merge base with the remote main branch: the local branch can be stale -# or missing, which silently widens the diff to files the push doesn't touch. MAIN_BRANCH='5.x-dev' -DIFF_BASE=$(git merge-base HEAD "origin/${MAIN_BRANCH}" 2>/dev/null) -if [[ -z "$DIFF_BASE" ]]; then - echo "Could not resolve the merge base between HEAD and origin/${MAIN_BRANCH}." - echo "Run 'git fetch origin ${MAIN_BRANCH}' and push again." - exit 1 -fi +ZERO_OID='0000000000000000000000000000000000000000' +PHPSTAN_CREATED_CONFIG=phpstan/phpstan.created.neon +PHPSTAN_MODIFIED_CONFIG=phpstan/phpstan.modified.neon -### Run PHPStan on newly created files. ### +### Run PHPStan on the files a pushed commit adds or changes. ### -PHPSTAN_CREATED_CONFIG=phpstan/phpstan.created.neon -if [[ -f "$PHPSTAN_CREATED_CONFIG" ]]; then - CHANGED_FILES=$(git diff --name-only ${DIFF_BASE} HEAD --diff-filter=A | grep '\.php$' || true) - if [ -z "$CHANGED_FILES" ]; then - echo "No created PHP files" - else - echo "Running PHPstan at a very high level on new files" - CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` - echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_CREATED_CONFIG} || STATUS=1 +# $1 -- the pushed commit +# $2 -- git diff filter (A for created files, CM for modified files) +# $3 -- the phpstan config to use +# $4 -- log label for the file kind +check_pushed_commit() { + local commit="$1" filter="$2" config="$3" label="$4" + + if [[ ! -f "$config" ]]; then + return 0 fi -fi + # Use the merge base with the remote main branch: the local branch can be stale + # or missing, which silently widens the diff to files the push doesn't touch. + local diff_base + diff_base=$(git merge-base "$commit" "origin/${MAIN_BRANCH}" 2>/dev/null) + if [[ -z "$diff_base" ]]; then + echo "Could not resolve the merge base between ${commit} and origin/${MAIN_BRANCH}." + echo "Run 'git fetch origin ${MAIN_BRANCH}' and push again." + return 1 + fi + local changed_files + changed_files=$(git diff --name-only "$diff_base" "$commit" --diff-filter="$filter" | grep '\.php$' || true) + if [ -z "$changed_files" ]; then + echo "No ${label} PHP files" + return 0 + fi -### Run PHPStan on modified files. ### -PHPSTAN_MODIFIED_CONFIG=phpstan/phpstan.modified.neon -if [[ -f "$PHPSTAN_MODIFIED_CONFIG" ]]; then - CHANGED_FILES=$(git diff --name-only ${DIFF_BASE} HEAD --diff-filter=CM | grep '\.php$' || true) - if [ -z "$CHANGED_FILES" ]; then - echo "No changed PHP files" - else - echo "Running PHPstan on modified files" - CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` - echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_MODIFIED_CONFIG} || STATUS=1 + echo "Running PHPstan on ${label} files" + changed_files=`echo "$changed_files" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` + echo "$changed_files" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${config} || return 1 +} + +# Check the commits actually being pushed, as supplied on stdin: HEAD is wrong +# when pushing another local branch or several refs at once. The inner commands +# read /dev/null so they cannot consume the remaining stdin lines. +while read -r local_ref local_oid remote_ref remote_oid; do + if [[ "$local_oid" == "$ZERO_OID" ]]; then + continue # deleting the remote ref, nothing is pushed fi -fi + echo "Checking ${local_ref} (${local_oid})" + check_pushed_commit "$local_oid" A "$PHPSTAN_CREATED_CONFIG" "created" < /dev/null || STATUS=1 + check_pushed_commit "$local_oid" CM "$PHPSTAN_MODIFIED_CONFIG" "modified" < /dev/null || STATUS=1 +done # Don't bother running the full check, as we check changes files already, and # can assume that the unchanged files don't need rechecking. From d47d60dd66763bd6dba2bf4b60c1df44d0c9d1a0 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:21:05 +1200 Subject: [PATCH 04/12] Pass the plugin name to the shell via env rather than template interpolation --- .github/workflows/plugin-phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index 0b46139..fa97338 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -101,7 +101,7 @@ jobs: - name: PHPStan whole repo id: phpstan-all - run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c plugins/${{ env.PLUGIN_NAME }}/phpstan.neon + run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c "plugins/${PLUGIN_NAME}/phpstan.neon" - name: "Save result cache" uses: actions/cache/save@v4 From df4c692e2f5ff86006c05f71426a4761d207e18b Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:31:35 +1200 Subject: [PATCH 05/12] Validate dependent plugin names before touching plugins/ A slug the derivation regex does not match passed through unchanged and reached rm -rf. Also narrows the README example to pass only the secret the workflow uses instead of secrets: inherit. --- README.md | 3 ++- scripts/bash/checkout_dependent_plugins.sh | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e8c83e..c8b0302 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,8 @@ jobs: plugin-name: MyPlugin # dependent-plugins: 'innocraft/plugin-Funnels' # php-version: '8.2' - secrets: inherit + secrets: + TESTS_ACCESS_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN }} ``` ## Git hooks (`hooks/`) diff --git a/scripts/bash/checkout_dependent_plugins.sh b/scripts/bash/checkout_dependent_plugins.sh index b2e5a11..bc71664 100755 --- a/scripts/bash/checkout_dependent_plugins.sh +++ b/scripts/bash/checkout_dependent_plugins.sh @@ -11,7 +11,10 @@ else for pluginSlug in ${PLUGINS[@]}; do dependentPluginName=$(echo "$pluginSlug" | sed -E 's/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_]+-(.*)/\1/') - if [ "$dependentPluginName" == "" ]; then + # A plugin name is strictly alphanumeric; anything else (including a slug the + # sed above passed through unchanged) could traverse out of plugins/ below. + if [[ ! "$dependentPluginName" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "Skipping invalid dependent plugin slug: $pluginSlug" continue fi From 5d1a9c134003a06a864d2dea7a3c472e7ab37959 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:35:27 +1200 Subject: [PATCH 06/12] Validate the plugin name and pass TARGET_BRANCH to the dependent-plugin checkout The name reaches path operations in checkout_matomo.sh, so reject anything but an alphanumeric plugin name up front. Without TARGET_BRANCH the dependent plugins silently stayed on their default branch instead of the caller's base branch. --- .github/workflows/plugin-phpstan.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index fa97338..ba0dd56 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -46,6 +46,13 @@ jobs: PLUGIN_NAME: ${{ inputs.plugin-name }} DEPENDENT_PLUGINS: ${{ inputs.dependent-plugins }} steps: + - name: Validate inputs + run: | + if [[ ! "$PLUGIN_NAME" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "Invalid plugin-name: $PLUGIN_NAME" + exit 1 + fi + - uses: actions/checkout@v4 with: lfs: false @@ -90,6 +97,7 @@ jobs: run: ${{ github.workspace }}/github-action-tests/scripts/bash/checkout_dependent_plugins.sh env: GITHUB_USER_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN || github.token }} + TARGET_BRANCH: ${{ github.base_ref || github.ref_name }} - name: "Restore result cache" uses: actions/cache/restore@v4 From 6e94c9c31fabd8588ac73257347c4a97f5d383bc Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:46:21 +1200 Subject: [PATCH 07/12] Lock helper scripts to the workflow's own commit and skip the hook outside plugins/ job.workflow_repository/job.workflow_sha make the caller's pin govern the scripts too, instead of floating on main. The hook no longer rejects pushes from standalone clones it cannot check. --- .github/workflows/plugin-phpstan.yml | 7 +++++-- README.md | 4 ++++ hooks/pre-push | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index ba0dd56..a7f1f8d 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -62,11 +62,14 @@ jobs: with: php-version: ${{ inputs.php-version }} + # job.workflow_repository/job.workflow_sha resolve to this reusable workflow's + # own repo and commit, so the helper scripts are version-locked to whatever + # ref the caller pinned instead of floating on main. - name: Check out github-action-tests repository uses: actions/checkout@v4 with: - repository: matomo-org/github-action-tests - ref: main + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} path: github-action-tests persist-credentials: false diff --git a/README.md b/README.md index c8b0302..dd575df 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,10 @@ jobs: TESTS_ACCESS_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN }} ``` +Pinning the `uses:` reference to a commit SHA or release tag instead of `@main` makes the whole +check immutable: the workflow checks out its helper scripts at its own resolved commit, so the +caller's pin governs everything that runs. + ## Git hooks (`hooks/`) `hooks/pre-push` is the canonical copy of the PHPStan pre-push hook that plugin repositories diff --git a/hooks/pre-push b/hooks/pre-push index 54bb7e5..eb24a2c 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -22,8 +22,8 @@ echo "Running pre-push hook in repo: $REPO_DIR" if [[ "$REPO_DIR" =~ /plugins/(.*) ]]; then PLUGIN_PATH="plugins/${BASH_REMATCH[1]}/" else - echo "Not a plugin, not running any further checks" - exit 1 + echo "Not inside a Matomo checkout's plugins/ directory, skipping PHPStan checks" + exit 0 fi MATOMO_DIR=$(echo "$REPO_DIR" | sed -E 's|/plugins/.*$||') From 6ba41cbeefa8dda3dab3ed0bc8fa4c1929b1666f Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:52:02 +1200 Subject: [PATCH 08/12] Pin all actions to commit SHAs --- .github/workflows/plugin-phpstan.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index a7f1f8d..03cf639 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -53,7 +53,7 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: lfs: false persist-credentials: false @@ -66,7 +66,7 @@ jobs: # own repo and commit, so the helper scripts are version-locked to whatever # ref the caller pinned instead of floating on main. - name: Check out github-action-tests repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} @@ -103,7 +103,7 @@ jobs: TARGET_BRANCH: ${{ github.base_ref || github.ref_name }} - name: "Restore result cache" - uses: actions/cache/restore@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /tmp/phpstan # same as in phpstan.neon key: "phpstan-result-cache-${{ github.run_id }}" @@ -115,7 +115,7 @@ jobs: run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c "plugins/${PLUGIN_NAME}/phpstan.neon" - name: "Save result cache" - uses: actions/cache/save@v4 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: ${{ !cancelled() }} with: path: /tmp/phpstan # same as in phpstan.neon From 2576430690e74556c5b15542dcbd251702c41081 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 14:54:36 +1200 Subject: [PATCH 09/12] Recommend full commit SHAs for pinning; tags are not immutable --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd575df..52d2059 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,10 @@ jobs: TESTS_ACCESS_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN }} ``` -Pinning the `uses:` reference to a commit SHA or release tag instead of `@main` makes the whole -check immutable: the workflow checks out its helper scripts at its own resolved commit, so the -caller's pin governs everything that runs. +The example uses `@main` to match how plugin repositories currently consume this repository's +scripts. For immutability, pin the `uses:` reference to a full commit SHA — release tags stay +mutable unless the repository enforces immutable releases. The workflow checks out its helper +scripts at its own resolved commit, so the caller's pin governs everything that runs. ## Git hooks (`hooks/`) From 476ccdcfcfecab7f3ca03df4e8c2a98062431407 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 15:10:42 +1200 Subject: [PATCH 10/12] Reduce the callee permissions to contents: read; smoke-test debug step [temp] --- .github/workflows/plugin-phpstan.yml | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index 03cf639..d2f3c54 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -26,17 +26,10 @@ on: TESTS_ACCESS_TOKEN: required: false +# The job only reads the two repositories; everything else stays at none so any +# caller with default workflow permissions can use this without granting scopes. permissions: - actions: read - checks: read contents: read - deployments: none - issues: read - packages: none - pull-requests: read - repository-projects: none - security-events: none - statuses: read jobs: phpstan: @@ -62,14 +55,19 @@ jobs: with: php-version: ${{ inputs.php-version }} - # job.workflow_repository/job.workflow_sha resolve to this reusable workflow's - # own repo and commit, so the helper scripts are version-locked to whatever - # ref the caller pinned instead of floating on main. + # TODO(smoke): if the debug step below prints a SHA, pin ref to + # github.job_workflow_sha so the caller's pin governs the scripts too. + - name: Debug reusable workflow contexts + run: | + echo "github.job_workflow_sha='${{ github.job_workflow_sha }}'" + echo "github.workflow_ref='${{ github.workflow_ref }}'" + echo "github.workflow_sha='${{ github.workflow_sha }}'" + - name: Check out github-action-tests repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} + repository: matomo-org/github-action-tests + ref: PG-4897-shared-phpstan path: github-action-tests persist-credentials: false From e2f08bcc2702776aa3226a65d8f1cfd8f7ebae0d Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 4 Aug 2026 15:13:42 +1200 Subject: [PATCH 11/12] Let callers pin the helper scripts via scripts-ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No GitHub context identifies the called workflow's own commit — github.job_workflow_sha evaluates to empty (verified in a live run) — so an explicit input is the only honest pinning mechanism. --- .github/workflows/plugin-phpstan.yml | 18 +++++++++--------- README.md | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/plugin-phpstan.yml b/.github/workflows/plugin-phpstan.yml index d2f3c54..abd42cb 100644 --- a/.github/workflows/plugin-phpstan.yml +++ b/.github/workflows/plugin-phpstan.yml @@ -17,6 +17,11 @@ on: required: false type: string default: '7.2' + 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 + type: string + default: 'main' verify-hook: description: "Fail when the plugin's .git-hooks-matomo/pre-push differs from the canonical copy in this repository" required: false @@ -55,19 +60,14 @@ jobs: with: php-version: ${{ inputs.php-version }} - # TODO(smoke): if the debug step below prints a SHA, pin ref to - # github.job_workflow_sha so the caller's pin governs the scripts too. - - name: Debug reusable workflow contexts - run: | - echo "github.job_workflow_sha='${{ github.job_workflow_sha }}'" - echo "github.workflow_ref='${{ github.workflow_ref }}'" - echo "github.workflow_sha='${{ github.workflow_sha }}'" - + # 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. - name: Check out github-action-tests repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: matomo-org/github-action-tests - ref: PG-4897-shared-phpstan + ref: ${{ inputs.scripts-ref }} path: github-action-tests persist-credentials: false diff --git a/README.md b/README.md index 52d2059..10fe12f 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ jobs: The example uses `@main` to match how plugin repositories currently consume this repository's scripts. For immutability, pin the `uses:` reference to a full commit SHA — release tags stay -mutable unless the repository enforces immutable releases. The workflow checks out its helper -scripts at its own resolved commit, so the caller's pin governs everything that runs. +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`). ## Git hooks (`hooks/`) From 879d0ba7551638b37ee35906959f714eb6581fd1 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Wed, 5 Aug 2026 11:46:07 +1200 Subject: [PATCH 12/12] Accept both documented dependent-plugin slug forms --- scripts/bash/checkout_dependent_plugins.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/bash/checkout_dependent_plugins.sh b/scripts/bash/checkout_dependent_plugins.sh index bc71664..7a6c5b6 100755 --- a/scripts/bash/checkout_dependent_plugins.sh +++ b/scripts/bash/checkout_dependent_plugins.sh @@ -9,14 +9,13 @@ else echo "" PLUGINS=($DEPENDENT_PLUGINS) for pluginSlug in ${PLUGINS[@]}; do - dependentPluginName=$(echo "$pluginSlug" | sed -E 's/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_]+-(.*)/\1/') - - # A plugin name is strictly alphanumeric; anything else (including a slug the - # sed above passed through unchanged) could traverse out of plugins/ below. - if [[ ! "$dependentPluginName" =~ ^[A-Za-z0-9_]+$ ]]; then + # Both documented repository forms: owner/PluginName and owner/plugin-PluginName. + # The strict plugin-name charset keeps the derived path inside plugins/ below. + if [[ ! "$pluginSlug" =~ ^[A-Za-z0-9_.-]+/(plugin-)?([A-Za-z0-9_]+)$ ]]; then echo "Skipping invalid dependent plugin slug: $pluginSlug" continue fi + dependentPluginName="${BASH_REMATCH[2]}" echo "Cloning $pluginSlug into plugins/$dependentPluginName..."