Skip to content

About

Shared CI workflows and composite actions for Matomo plugin repositories

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

plugin-ci-workflows

Shared CI workflows and composite actions for Matomo plugin repositories.

Why this exists

Every Matomo plugin repository currently carries its own copy of the same CI definitions. A typical plugin has matomo-ai-checklist.yml, matomo-tests.yml, phpcs.yml and often phpstan.yml under .github/workflows/, and apart from the plugin name they are near-identical from one repository to the next. Changing how a check runs — a runner label, a PHP version, a cache key — means opening the same pull request against dozens of repositories and waiting for dozens of approvals.

Holding those definitions here means a change is made once. Plugin repositories keep only a thin caller that says which checks to run.

This repository is public for a reason. A reusable workflow stored in a private repository is resolved against that repository's Actions access policy when the caller's workflow is parsed, which happens before any job starts and before any secret is available. Public and internal caller repositories therefore cannot use one at all — the run fails with "workflow was not found" and zero jobs, and no access setting fixes it. Keeping the shared definitions in a public repository avoids that class of failure entirely, whatever the visibility of the plugin repository calling in.

What lives here

Path Contents
.github/workflows/plugin-*.yml Reusable workflows, called with uses: at job level. Each one defines a complete job.
.github/workflows/*.yml Everything else here is this repository's own CI, not offered to callers.
actions/ Composite actions, called with uses: at step level. Each one defines steps you drop into a job you have written yourself.
scripts/, hooks/, artifacts/ Files the workflows above check this repository out to use.

Reach for a reusable workflow when the whole job is the same everywhere, and a composite action when the caller needs its own matrix, permissions or surrounding steps.

The examples below use secrets: inherit for brevity. Codex review is the exception and names its two secrets explicitly, because inherit hands every secret the calling repository can see to a workflow defined in another repository.

The plugin- prefix is what marks a reusable workflow as part of the public surface. Anything without it — the checklist gate, the script tests — runs against this repository only, and may change without notice to callers.

Catalogue

Name Kind Purpose
plugin-phpcs.yml Reusable workflow Checks the plugin against the Matomo coding standards
plugin-phpstan.yml Reusable workflow Runs PHPStan against the plugin, on one or more Matomo targets
plugin-compatibility.yml Reusable workflow Compiles the plugin's stylesheets and templates against the oldest and newest Matomo it supports
plugin-license-check.yml Reusable workflow Checks the LICENSE file and source file license headers
plugin-timezone-safety.yml Reusable workflow Runs the timezone safety scan and an optional focused regression suite
scripts/bash/check_timezone_safety.sh Standalone script Finds known server-timezone date and database patterns in plugin source
plugin-ai-checklist.yml Reusable workflow Runs the org checklist gate against the pull request description
plugin-hook-check.yml Reusable workflow Fails when a plugin's vendored pre-push hook has drifted from the copy here
plugin-ci.yml Reusable workflow The whole pull request check set behind one caller
plugin-codex-review.yml Reusable workflow Runs the Codex pull request review when a maintainer applies the trigger label
plugin-branch-sweep.yml Reusable workflow Dispatches the weekly build for each maintained branch that is not the default one
plugin-dev-release.yml Reusable workflow Releases a development branch's new plugin.json version, dating its changelog entry on the day
plugin-release-date-check.yml Reusable workflow Fails a changelog date written by hand in a plugin that uses the weekly release
plugin-min-php-lint.yml Reusable workflow Parses a plugin's scoped dependencies against the oldest PHP that plugin supports
plugin-vendored-deps-update.yml Reusable workflow Updates a plugin's scoped dependencies, rebuilds vendor/prefixed and opens a pull request
actions/scope-dependencies Composite action Scopes a plugin's dependencies with matomo-scoper, downgrades them with Rector, and checks the result
scripts/bash/scope_plugin_dependencies.sh Standalone script What the action runs, and how a developer rebuilds vendor/prefixed locally
scripts/bash/check_scoped_tree.sh Standalone script Checks a rebuilt vendor/prefixed tree is really scoped and really new
scripts/bash/find_new_unprefixed_strings.sh Standalone script Lists strings a rebuild added that name a scoped namespace without the prefix
scripts/bash/find_new_floor_php_gaps.sh Standalone script Lists uses of PHP a rebuild added that the plugin's floor does not have
scripts/bash/find_new_unloadable_classes.sh Standalone script Lists classes in a rebuilt vendor/prefixed that loaded before the rebuild and no longer do
hooks/pre-push Local git hook Runs PHPStan over a push's own changed files, before the push leaves the machine

Plugins CI

Runs PHPCS, PHPStan, the compatibility check, the license check, the minimum PHP lint, the timezone safety check, the release date check and the AI checklist gate from a single caller — plus the pre-push hook check, for the repositories that ask for it — so a plugin repository carries its name and nothing else, and a check added here reaches every plugin without a pull request against any of them.

name: Plugins CI

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]
  push:
    branches:
      - '**.x-dev'
  workflow_dispatch:

permissions:
  actions: read
  contents: read
  pull-requests: read

jobs:
  ci:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-ci.yml@main
    with:
      plugin-name: LoginLdap
    secrets: inherit

The push and workflow_dispatch triggers are what make a build badge mean anything, and they follow the shape matomo-tests.yml already uses across the fleet. A workflow that only runs on pull_request never runs on the default branch, so GitHub's badge — which takes the newest run on any branch when it cannot find one on the default branch — reports whichever pull request someone opened last. That is why plugin-LogViewer's PHPStan badge reads failing off a feature branch while its 6.x-dev is fine. With the triggers above, one ?branch=6.x-dev badge on this workflow says whether the plugin's checks pass on the branch that ships.

The AI checklist gate is the one job that cannot run outside a pull request — it reads the description — so it is conditioned to pull_request and simply does not appear on a push or a dispatch. On the weekly release's own release date pull request, which no person describes, it passes without reading the description once the pull request's head is the commit the version was tagged at. Everything else runs on all three, but the timezone scan gates only on a pull request, where it compares with the base branch; on a push or a dispatch it reports findings without failing on them.

Checks are opt out, through skip-phpcs, skip-phpstan, skip-compatibility, skip-license-check, skip-min-php-lint, skip-timezone-safety, skip-release-date and skip-ai-checklist. The one exception is hook-check, which is opt in through verify-hook and so needs no switch to turn off: a plugin declines it by not asking, and running it by default would fail every repository whose vendored hook has not been synced, which is most of them. Opt-in switches would leave a newly added check running nowhere until every caller added a line, which is the problem this workflow exists to remove. Most inputs the individual workflows take are passed through; the four that take a PHP version are named phpcs-php-version, phpstan-php-version, compatibility-php-version and min-php-lint-php-version. PHPStan and the compatibility check share dependent-plugins and matomo-targets. The optional timezone regression job is called separately from a plugin's test workflow so it does not rerun on description edits in this umbrella.

The caller subscribes to edited so the checklist gate re-runs when someone fixes a description, and every check runs on that event like any other. Skipping the code checks on an edit is the obvious economy and it is the one thing this workflow must not do: a run whose checks are all skipped concludes success, and GitHub resolves a commit's verdict from the newest check suite per workflow, ordered by suite creation time — so the edited run's green suite replaces the code run's verdict, while the analysis is still running, and still after it fails. Nine of the fleet's migration pull requests had a red license check hidden that way before this was found. tests/plugin_ci_invariants_test.sh fails the build if any job conditions itself on github.event.action.

The price is one extra run of these checks per description edit. Most of that is the compatibility check, which installs Matomo against MySQL once per matomo-targets entry and runs PHPUnit, so an edit costs real runner minutes, billed to the repository's owner when it is private. And because one lane means an edit supersedes a run in flight, an edit made while the analysis is still going discards it and starts again. Filling in the AI checklist just after opening a pull request is exactly that shape, so expect the verdict to restart once or twice. The full test matrix lives in matomo-tests.yml, which does not subscribe to edited, so it never re-runs for this.

The caller declares no concurrency of its own. This workflow declares it instead, as one lane per pull request. It was briefly two — a code lane and an edited lane — to stop an edit cancelling an in-flight analysis, as happened on UsersFlow#135. That was the wrong fix for the right problem: it stopped the cancellation and left the masking above. With every run doing the whole check set, one lane is correct, because a run that supersedes another has checked the same things. The Codex review wrapper hit two different concurrency failures — a ${{ github.workflow }} group deadlocking against the caller's, and a group claimed by an unrelated label event — but all of these have the same answer: the group belongs in the workflow that knows what its own runs do, not in each caller.

No job declares a lane of its own. The checklist gate used to, so that it could span the two workflow-level lanes and stop a stale verdict landing last; with one lane the workflow-level group already does that, and a second group would only give two runs of a commit a side door to disagree through. The invariants enforce this too.

Migrating a plugin off a standalone AI checklist workflow is where this bites: that file rightly carries its own concurrency, and renaming it to ci.yml carries the block along with it, unnoticed. Delete the block as part of the rename.

The caller-concurrency job checks that you did, rather than leaving it to this paragraph. It reads the calling workflow — on a pull request, the merge ref's copy, which is the one whose group governed the run — and fails when that file declares a group of its own, at the top level or on the job that calls Plugins CI. A group on any other job in the file cancels only that job, so it is the caller's own business and passes. It has no skip- input: a caller can always satisfy it by deleting the block, and a switch would reopen the hole it closes. scripts/bash/check_caller_concurrency.sh is the check; tests/caller_concurrency_test.sh holds it in both directions, since a false positive here reddens the fleet.

The permissions above are the union of what the checks need. That is the cost of one caller: a plugin that only wants PHPCS previously needed no scopes at all.

What it does not cover

Codex review stays a separate workflow in each plugin, deliberately. It runs on pull_request_target with secrets available and gated on a label, while every check above runs on pull_request. One file declaring both events means the code checks need an if: excluding pull_request_target, or they execute pull request code in a context that can read secrets — a missing condition there is a credential leak rather than a red build. Its automation-paths guard also names .github/workflows/codex-review.yml as a file requiring human review before a review runs, and a differently named wrapper drops that control. Its wrapper is a stable file, so the per-plugin cost is paid once while the review logic keeps living here.

Pinning does not reach through this workflow. GitHub does not accept an expression in a uses: reference, so the calls below are fixed at @main: a caller that pins plugin-ci.yml to a tag still runs main versions of the checks themselves. A repository that needs to hold a check steady — a plugin mid-migration to a new Matomo major, say — should call the individual workflows directly and pin those, rather than use this one.

PHPCS

Runs the plugin's own phpcs.xml against the matomo-coding-standards ruleset, and annotates the pull request with any violations.

Input Required Default Description
plugin-name yes — Name of the plugin, e.g. LoginLdap
php-version no matomo6_min_php A literal version, or one of the shared aliases. PHPCS itself needs at least 7.4, so matomo5_min_php is too low to use here.
scripts-ref no main Ref of matomo-org/github-action-tests for the version resolver
name: PHPCS check
on: pull_request

jobs:
  phpcs:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-phpcs.yml@main
    with:
      plugin-name: MyPlugin

The plugin repository supplies the ruleset: the job runs phpcs --standard=phpcs.xml, so a phpcs.xml must exist at the repository root.

Prefer an alias to a literal PHP version wherever a workflow accepts one. The aliases resolve through resolve_php_version.sh in github-action-tests, so when a Matomo major's floor or ceiling moves, every caller follows without a pull request each.

PHPStan

Analyses the plugin with PHPStan against a checked-out Matomo. By default it runs twice, against the oldest Matomo the plugin's plugin.json supports and against the newest, which is what catches a plugin calling a core API that does not exist yet on its own floor.

Input Required Default Description
plugin-name yes — Name of the plugin, e.g. LoginLdap
dependent-plugins no '' Space-separated repository slugs to check out, e.g. innocraft/plugin-Funnels
php-version no matomo6_min_php A literal version, or one of the shared aliases matomo5_min_php, matomo5_max_php, matomo6_min_php, matomo6_max_php. The default clears Matomo 6's floor of 8.1: a plugin declaring >=6.0.0-b1 resolves both legs to 6.x-dev until 6.0.0 is tagged, and 7.2 cannot bootstrap it.
matomo-targets no min and max JSON array of {target, php} objects, one analysis run each
scripts-ref no main Ref of matomo-org/github-action-tests for the shared helper scripts
workflows-ref no main Ref of this repository for the pre-push hook and the PHPStan bootstrap

TESTS_ACCESS_TOKEN is an optional secret, needed only when dependent-plugins names a private repository.

name: PHPStan check
on: pull_request

jobs:
  phpstan:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-phpstan.yml@main
    with:
      plugin-name: MyPlugin
    secrets: inherit

Set php-version per target when the targets span Matomo majors — a Matomo 6 checkout cannot be bootstrapped by the PHP 7.2 that Matomo 5 still allows:

    with:
      plugin-name: MyPlugin
      matomo-targets: >-
        [{"target": "minimum_required_matomo", "php": "matomo5_min_php"},
         {"target": "maximum_supported_matomo", "php": "matomo6_min_php"}]

A plugin that guards a newer core API behind class_exists can put the resulting ignores in phpstan-min-matomo.neon; the minimum leg uses that config in place of phpstan.neon when it exists.

Where the pieces live

This workflow checks out two repositories. The shared helpers that Matomo core CI uses as well — checkout_matomo.sh, checkout_dependent_plugins.sh and resolve_php_version.sh — stay in github-action-tests and come from scripts-ref. The plugin-only pieces, hooks/pre-push, artifacts/bootstrap-phpstan.php and the compatibility check's generator and templates, live here and come from workflows-ref.

A reusable workflow does not bring its own repository into the caller's workspace, which is why this repository has to be checked out explicitly even though the workflow is defined in it.

Compatibility check

Compiles the plugin's stylesheets and Twig templates against a checked-out Matomo, by default twice: against the oldest Matomo the plugin's plugin.json supports and against the newest. PHPStan cannot see either kind of dependency on core. A Less mixin or variable, or a Twig function, filter, test or tag, is resolved only when the file is compiled, so a plugin using one its floor does not have yet, or one its ceiling has removed, installs cleanly and then breaks every page that needs it. The minimum leg catches the first, the maximum leg the second.

Input Required Default Description
plugin-name yes — Name of the plugin, e.g. LoginLdap
dependent-plugins no '' Space-separated repository slugs to check out, e.g. innocraft/plugin-Funnels
php-version no matomo6_min_php PHP for any target that sets no php of its own. A literal version, or one of the shared aliases.
matomo-targets no min and max JSON array of {target, php} objects, one compilation run each
scripts-ref no main Ref of matomo-org/github-action-tests, whose composite action installs Matomo and runs the tests
workflows-ref no main Ref of this repository for the test generator and its templates

TESTS_ACCESS_TOKEN is an optional secret, needed only when dependent-plugins names a private repository, and passed to the action only when dependent-plugins is set. Set php per target when the targets span Matomo majors, exactly as for PHPStan.

name: Compatibility check
on: pull_request

jobs:
  compatibility:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-compatibility.yml@main
    with:
      plugin-name: MyPlugin
    secrets: inherit

Each leg runs the github-action-tests composite action with test-type: PluginTests, a MySQL service and a Matomo install, and hands it scripts/bash/generate_compatibility_checks.sh as its setup script. That writes two integration tests into the runner's copy of the plugin — into Test/Integration when the plugin has a Test/ directory, otherwise tests/Integration, the same precedence the action uses to decide what PHPUnit runs — and PHPUnit is filtered to those two. Nothing is written to the plugin repository, and the generator fails rather than overwrite a file of the same name that the plugin ships.

  • GeneratedAssetCompilationTest merges Matomo's stylesheet with the plugin loaded, which compiles the plugin's Less along with core's. A theme plugin is made the active theme first, because a theme's own stylesheet is only merged while it is enabled.
  • GeneratedTwigCompilationTest loads every .twig file under the plugin's templates/ through Matomo's Twig environment as @MyPlugin/<path>, and is skipped when there are none.

Each test records its outcome in a compatibility-results/ directory at the Matomo root, which the generator creates empty. A step after the action, scripts/bash/check_compatibility_results.sh, reads those markers and fails the leg unless the asset test ran and the Twig test ran or was skipped, so a leg cannot go green having checked nothing. The markers replace a PHPUnit JUnit log because the action's run_tests.sh appends its own --log-junit on push runs, which overrides one passed through phpunit-test-options.

Compilation is all it checks. Twig resolves an extends, include, embed or import of another template, and every variable, when the template is rendered rather than compiled, so a plugin extending a core template that was renamed or removed passes here. The maximum leg covers the ceiling plugin.json declares, not whatever Matomo comes after it. PHPUnit loads every test file in the plugin's test directory before --filter narrows the run, so an existing test that cannot load on a target, for example one extending a test-framework class that Matomo lacks, fails the leg even though neither check is at fault.

The generator first fails if any dependent-plugins entry was not checked out, as PHPStan does. It then deletes every plugins/*/.git directory and composer's global github-oauth entry, and fails if either is still there, before PHPUnit starts. The action leaves the checkout token in both, and PHPUnit executes the pull request's code.

In Plugins CI this runs by default and costs a Matomo install per target on every run, edited events included. skip-compatibility exists for a plugin mid-migration whose declared range cannot be installed yet, not as a way to save the minutes; fix the range in plugin.json instead where that is the real problem.

License check

Checks that the repository ships a LICENSE file matching the license declared in plugin.json, and that source files (*.php, *.js, *.ts, *.vue) carry the matching header: the GPL header for OSS plugins, the InnoCraft EULA header for premium ones.

A file carrying the opposite header fails the check. Files with no recognised header are reported as warnings by default. Glob patterns listed in a .license-check-ignore file at the repository root are skipped, which is how bundled third-party files under their own license are excluded.

Input Required Default Description
fail-on-missing-header no false Treat source files with no recognised header as errors rather than warnings
script-ref no main Ref of this repository to take license_check.sh from. When pinning the workflow to a SHA, pass the same SHA here.
name: License check
on: pull_request

jobs:
  license-check:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-license-check.yml@main

The check script lives at scripts/bash/license_check.sh and is covered by tests/license_check_test.sh, which runs on every pull request to this repository.

Timezone safety check

The standalone scripts/bash/check_timezone_safety.sh check looks for date and database patterns that have caused reports to use the server's calendar day instead of the website's timezone. It also reports timezone-specific tests or workflow configuration when a repository has them, but a test suite is not required: the static scan still runs when none exists.

The reusable workflow accepts these inputs:

Input Required Default Description
plugin-name yes — Plugin name used in the regression job's label
workflows-ref no main This repository ref containing the checker and runtime helper
timezone-test-command no empty Optional focused regression command; runs as a separate job
skip-static-scan no false Skip the static job when calling this workflow only for regression tests
timezone-regression-exempt no empty Why the plugin needs no regression suite; see regression coverage

For a direct call, pass the plugin name and optionally a pinned workflow ref or focused test command:

jobs:
  timezone:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-timezone-safety.yml@main
    with:
      plugin-name: MyPlugin
      workflows-ref: main
      timezone-test-command: >-
        ./tests/run-timezone-suite.sh

Run it against a complete repository:

bash scripts/bash/check_timezone_safety.sh /path/to/plugin

To add changed-line context since a Git revision, pass --base-ref. The complete current repository is still scanned, so existing findings remain visible; the output additionally reports findings located on changed production lines. Lines are compared with the working tree, so uncommitted edits and untracked files count as changed, and an unchanged call counts as changed when an import or namespace edit makes it name a different class, and a clock counts as changed when any line of its SQL expression changes. --fail-on-new-findings makes errors on changed production lines blocking, which is the mode used by Plugins CI for pull requests. Warnings are advisory by default; --fail-on-warnings makes selected warnings blocking. Combined with --fail-on-new-findings, only warnings on changed lines are selected. An intentional finding can be suppressed with a timezone-safety-ignore comment on any of its lines, or a comment line immediately before an unchanged finding; include a new suppression comment in the same change as a changed finding. A column default such as DEFAULT CURRENT_TIMESTAMP is reported deliberately: Matomo does not set the connection timezone, so the value reads back in the database server's timezone rather than UTC; suppress it once that has been checked. The script requires python3 and Bash 4+, and exits with status 2 without scanning when either is missing:

bash scripts/bash/check_timezone_safety.sh --base-ref origin/6.x-dev .
bash scripts/bash/check_timezone_safety.sh --base-ref origin/6.x-dev --fail-on-new-findings .
bash scripts/bash/check_timezone_safety.sh --fail-on-warnings .

The reusable plugin-timezone-safety.yml workflow runs this static scan for every plugin through Plugins CI. On pull requests it compares with the checked-out base branch and fails only for errors on changed production lines; older findings are still printed as notices for cleanup. Push and manual runs are advisory: they report findings, marking those on lines changed since the previous commit when it is available, but only structural scan failures fail the job. A push and a pull-request run for the same commit compare with different bases, so letting both gate could give the commit two verdicts; the pull-request result is the review gate. An older pinned workflows-ref fails closed rather than reporting a green job without running the scan; advance the pin or explicitly skip this direct workflow with skip-static-scan (skip-timezone-safety in Plugins CI) during rollout. It is static-only unless a plugin passes timezone-test-command when calling that reusable workflow directly, in which case a separate, 90-minute job runs the focused command with the TZ and MYSQL_TIMEZONE hint variables set to Pacific/Auckland. The command must bootstrap any PHP, Matomo, and database environment it needs, and remains responsible for configuring its test site's timezone. It should select the plugin's timezone regression tests rather than its entire suite. When the caller already runs the umbrella workflow's static check, pass skip-static-scan: true to avoid running that static check twice. A reported finding still needs code-level review and, where the behavior is report-facing, a regression test using sites on opposite sides of a UTC date boundary. The script and workflow contract are covered by tests/timezone_safety_test.sh, tests/timezone_workflow_invariants_test.sh and tests/timezone_workflow_runtime_test.sh.

Timezone regression coverage

The regression suite is opt-in per plugin, so the static job also runs scripts/bash/check_timezone_regression_coverage.sh to stop a plugin that needs one from being missed. A plugin needs one when its tracked production PHP reads a log table's event time (server_time, visit_first_action_time, visit_last_action_time), a period boundary (getDateStart(), getDateTimeEndUTC() and the like) or a site's timezone (getTimezone(), Site::getTimezoneFor()), in any letter case. Tests, vendor/, libs/, node_modules/, vue/dist/, Updates/ and PHP comments are not counted. Such a plugin passes when a job in one of its .github/workflows files calls plugin-timezone-safety.yml with a non-empty timezone-test-command from a workflow that runs on pull_request or workflow_call, or when Plugins CI is given the reason it does not need one:

jobs:
  ci:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-ci.yml@main
    with:
      plugin-name: ExamplePlugin
      timezone-regression-exempt: Only stores the dates users enter; never groups data by site day

The workflows are read with PyYAML, which the step installs if the runner lacks it. A job switched off with if: false does not count; any other condition, and an expression such as ${{ inputs.command }} as the command, is taken at face value, because the check cannot know what it resolves to. skip-timezone-safety skips this check along with the scan, and a direct call with skip-static-scan never reads timezone-regression-exempt.

The check currently warns. Once every plugin it reports runs the suite or is exempt, setting COVERAGE_MODE: enforce in plugin-timezone-safety.yml makes it fail pull requests; pushes and dispatches stay advisory, like the scan. A pinned workflows-ref that predates the script warns while the check warns, and fails pull requests once it enforces. Plugins CI always calls plugin-timezone-safety.yml@main, so a plugin pinned to a plugin-ci.yml older than timezone-regression-exempt gets the check but cannot pass the exemption until it moves its pin. Tested by tests/timezone_regression_coverage_test.sh and tests/timezone_coverage_step_test.sh. Run locally, the script requires Python 3.11 or later, for python3 -P, and PyYAML.

bash scripts/bash/check_timezone_regression_coverage.sh /path/to/plugin
bash scripts/bash/check_timezone_regression_coverage.sh --enforce /path/to/plugin

Hook check

Fails when the plugin's vendored .git-hooks-matomo/pre-push differs from the canonical copy in this repository, so a copy taken once cannot drift unnoticed. Called from Plugins CI through verify-hook, which is off by default; see Keeping a plugin's copy in sync for what to do before turning it on.

Input Required Default Description
workflows-ref no main Ref of this repository to take hooks/pre-push and check_hook_sync.sh from. When pinning the workflow to a SHA, pass the same SHA here.
name: Hook check
on: pull_request

jobs:
  hook-check:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-hook-check.yml@main

AI checklist

Runs github-action-checklist-gate against the pull request description, which is what enforces the two AI attestation items in the pull request template. No inputs.

The gate reads the pull request description and looks up the release tag that lets the weekly release's date pull request pass, so the caller has to grant actions: read, contents: read and pull-requests: read in its own permissions block — a called workflow cannot widen the caller's token.

name: AI Checklist
on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

permissions:
  actions: read
  contents: read
  pull-requests: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  AiChecklist:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-ai-checklist.yml@main

The edited trigger matters: without it the gate does not re-run when someone fills the checklist in, and the check stays red.

The single concurrency group is right here, because every run of this workflow does the same thing — re-read the description — so a later run always supersedes an earlier one safely. Drop the block when folding this workflow into Plugins CI, which declares its own and fails the run if the caller declares one too.

Codex review

Runs an AI review over a pull request when someone applies the codex-review label, and posts the result as a pull request review. The review itself — preflight checks, prompt, output schema and posting — lives in composite actions in innocraft/github-action-tests-private, which this workflow checks out with TESTS_ACCESS_TOKEN.

That indirection is not a style choice. GitHub resolves a reusable workflow from the callee repository's Actions access policy at workflow-parse time, before any job or secret exists, and that policy cannot grant a public caller access to a workflow in a private repository — the run fails with "workflow was not found" and no jobs. Composite actions have no such restriction once a token has checked them out. This workflow exists so that the job structure a plugin repository cannot import lives somewhere it can.

Input Required Default Description
plugin-name no read from plugin.json Name of the plugin, e.g. LoginLdap. Passing it keeps the name out of the untrusted pull request.
trigger-label no codex-review Label that triggers the review. The caller's own if: condition names the label too, so change both together or the caller never invokes this workflow.
allowed-owners no matomo-org,innocraft Comma-separated repository owners allowed to run a review
automation-paths no the caller's codex-review.yml and .github/codex/ Paths that must receive human review before Codex runs
review-actions-ref no main Ref of innocraft/github-action-tests-private to run. When pinning this workflow to a SHA, pin the actions too.
matomo-core-repository no matomo-org/matomo Core repository checked out for read-only review context
matomo-core-ref no derived from the base branch Core ref checked out for read-only review context. A pull request against 6.x-dev is reviewed against 6.x core; a base branch that names no core branch falls back to 5.x-dev.
matomo-agent-skills-ref no main Ref of matomo-org/matomo-agent-skills to install
codex-model no gpt-5.6-sol Model passed to openai/codex-action
codex-effort no xhigh Reasoning effort passed to openai/codex-action
Secret Required Description
OPENAI_API_KEY yes Supplied by the calling repository or organization; this repository ships no key
TESTS_ACCESS_TOKEN yes Read access to the review actions repository. A caller's GITHUB_TOKEN cannot read a private repository.
# Save as .github/workflows/codex-review.yml. The automation-paths default names that exact
# path as a file that must get human review before Codex runs; a wrapper saved under another
# name silently loses that guard.
name: Codex Review

on:
  # nosec — label-gated; this wrapper runs no pull request code
  pull_request_target:
    types: [labeled]

permissions:
  contents: none

jobs:
  codex-review:
    # Keep this condition. Every job below gates on the label too, but this workflow's
    # cancel-in-progress concurrency group is claimed as soon as it is instantiated, so without it
    # an unrelated label cancels a review already running on the same pull request.
    # This label must match `trigger-label` below, which defaults to codex-review.
    if: ${{ github.event.label.name == 'codex-review' }}
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-codex-review.yml@main
    permissions:
      actions: read
      contents: read
      issues: write
      pull-requests: write
    with:
      plugin-name: MyPlugin
    secrets:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      TESTS_ACCESS_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN }}

Name the two secrets rather than using secrets: inherit here: inherit would hand every secret the plugin repository can see to a workflow defined in another repository, and this one only needs those two.

Two things differ from the other workflows in this catalogue. It is the only one triggered by pull_request_target, so the caller must keep that wrapper free of any step that checks out or runs pull request code. And it is the only one that runs with write permissions on the calling repository, which is why main here is protected — anyone who can change this file changes what runs with issues: write and pull-requests: write on every plugin pull request.

Who can start a review: the label is a trigger, not an authorisation check. Anyone with triage access or above on the calling repository can apply it, and the workflow does not test the labeller's permission. What it does enforce is allowed-owners, and the preflight step refuses pull requests from forks — failing closed when it cannot identify the repository — because this workflow never checks out fork code. A review therefore costs OpenAI credits at the discretion of anyone already trusted with triage on the repository.

Refs into our own organisations — the review actions and the agent skills — track main on purpose, as they do elsewhere in this repository. Third-party actions are pinned to a full commit SHA. The distinction matters more here than in the other workflows, because these jobs hold OPENAI_API_KEY, TESTS_ACCESS_TOKEN and write permission on the calling repository, so a change to either of those repositories takes effect on the next review with those credentials in scope. Pin review-actions-ref and matomo-agent-skills-ref to SHAs for a caller that needs that fixed.

The security model, the trust boundaries and the review prompt are documented in review/README.md in the review actions repository.

Minimum PHP lint

matomo-scoper prefixes a plugin's dependencies but does not downgrade them — it keeps attributes deliberately and ships no rector pass — so the syntax in vendor/prefixed is whatever composer resolved. Nothing else proves that resolution matched the plugin's floor: a file is only parsed when something loads it, so the tests reach whichever prefixed files they happen to exercise, and PHPStan need not reach them at all — GoogleAnalyticsImporter puts vendor/ under excludePaths.analyse precisely so the prefixed tree does not drown its report. This parses every file, at the floor.

The floor is derived, not pinned, and it is the lowest of the two manifests that mean something: config.platform.php in composer.json, which is what composer solved for and so bounds the syntax the tree may contain, and require.php in plugin.json, which is the lowest PHP a user can actually reach because Matomo gates activation on it. When neither is declared the Matomo major's floor applies.

Lowest rather than first-found, and OAuth2 is why. It declares >=8.1.0 in plugin.json while its composer platform is 8.2.0, and platform-check is off in that tree — so Matomo will happily activate it on PHP 8.1 and load code resolved for 8.2, with nothing to stop it. Parsing at 8.2 cannot see that; parsing at 8.1 reports it, and that red is a true positive. The remedy is a choice the maintainer makes: lower the platform and re-resolve, or raise plugin.json to what the tree really needs. Branches differ too — ApiReference declares >=7.4 on 5.x-dev and >=8.1 on 6.x-dev — so a hardcoded version is wrong on one of them whatever it says.

When it fails, the fix is composer-side. Re-running the scoper re-prefixes the same code and produces the same failure; align config.platform.php, or the platform-php the vendored dependencies update is passed, with the floor and re-resolve.

What it catches, and what it cannot. php -l reports what the parser rejects, so at an 8.x floor it catches syntax the older 8.x does not know. At a 7.2 or 7.4 floor it is blinder than the paragraph above implies: #[Attr] written on one line is a # comment to PHP 7, so the parser accepts it silently, and only a multi-line attribute argument list — where the continuation is no longer commented out — produces an error. So the attribute case that motivates this check is caught on the 8.x branches and, on the 5.x ones, only in its multi-line form. Nothing short of a real static parse would close that, and this check is deliberately not one.

Running it costs nothing on a plugin with no scoped dependencies: the job checks for the directory before it installs anything, so it finishes in seconds without setting up PHP.

Input Required Default Description
lint-path no vendor/prefixed Directory to parse. Skipped when absent
php-version no derived Override the floor derived from composer.json and plugin.json. A major.minor version such as 8.1, or a shared alias
scripts-ref no main Ref of matomo-org/github-action-tests for the alias resolver
workflows-ref no main Ref of this repository for the plugin floor resolver

There is no per-file ignore list, so one vendored file that legitimately targets a newer PHP fails the whole job. The intended escape hatch is min-php-lint-php-version, which lowers the bar for the whole tree, or skip-min-php-lint to stand the check down entirely — both deliberate and both visible in the caller, which a silent per-file exemption would not be.

It runs as part of Plugins CI, so a plugin calling that gets it already; skip-min-php-lint opts out. Call it directly only if you are not using the umbrella.

Branch sweep

GitHub only ever runs schedule from the default branch's copy of a workflow file. So the moment a plugin's default flips from 5.x-dev to 6.x-dev, the older line stops getting a weekly build and nothing announces it — the dashboard badge simply keeps showing an ageing run. This dispatches that build from inside the plugin repository, on the repository's own token: workflow_dispatch is a documented exception to the rule that GITHUB_TOKEN-triggered events create no workflow run, so no PAT and no cross-repository App is involved.

It dispatches rather than building another branch's source from here, which matters twice over. The run uses the target branch's own workflow file, whose test matrix genuinely differs per branch (matomo5_min_php against matomo6_min_php, and different dependent plugins), and the run is attributed to the branch it built, which is what the build dashboard's per-branch badges read.

Input Required Default Description
maintained-branches no 6.x-dev 5.x-dev Whitespace-separated branches to keep built, whichever of them is not the default. A YAML block scalar works as well as a single line
workflow-file no matomo-tests.yml Workflow file to dispatch in the plugin repository. A branch without it, or one that does not exist, is skipped with a notice
name: Weekly branch sweep
on:
  schedule:
    - cron: '5 3 * * 0'
  workflow_dispatch:

permissions: {}

jobs:
  sweep:
    permissions:
      actions: write
      contents: read
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-branch-sweep.yml@main

Three things are the caller's. Two of them cannot move here. The schedule trigger, because a cron in this repository would fire here rather than in the plugin — take the minute and hour from the plugin's own matomo-tests.yml cron rather than copying the example's, so the fleet stays staggered across the window. And the permissions, because they can only be maintained or reduced down a call chain and never elevated: actions: write for the dispatch and contents: read for the branch probe, which calls GET /repos/{owner}/{repo}/branches/{branch}. A caller that omits either leaves the run failing rather than silently doing nothing.

The third is a prohibition rather than a requirement: the caller must declare no concurrency block of its own. Concurrency governs the run and the run belongs to the caller, so a caller-level group replaces the queueing this workflow declares — and because the names never match, GitHub raises no deadlock error to say so. cancel-in-progress: true there would let a later run supersede a queued sweep, which costs that branch a week.

Two preconditions are the caller's to meet, and both fail loudly rather than silently. The workflow being dispatched has to carry a workflow_dispatch trigger on the target ref, not merely on the default branch, or GitHub rejects the dispatch with a 422 — every workflow generate:test-action produces already has one. And this caller file has to be carried onto the new default branch whenever a plugin's default flips, which is the same failure this workflow exists to fix, recurring one level up.

One bound is worth knowing before adopting this: GitHub disables scheduled workflows in a public repository after 60 days without repository activity. A plugin in pure maintenance is both the case this sweep is for and the case that reaches 60 days, and when the cron is disabled the sweep stops without announcing it. So the workflow's activity job fails once the last commit on the default branch is 45 days old, and GitHub notifies whoever last modified the caller's cron. It does not keep the schedule alive itself: GitHub took down a popular action that did, for circumventing the policy. Either merge outstanding work to the default branch, or re-enable the workflows in the Actions tab once GitHub has disabled them. Private repositories and runs on other branches skip the check.

The branch list is deliberately explicit rather than every *.x-dev branch a repository has: most still carry dead 2.x-dev, 3.x-dev and 4.x-dev lines. Dispatching 4.x-dev queues for 24 hours and is then auto-cancelled, because its workflow requests a runner label that no longer exists, and the older two carry no test workflow at all.

Weekly release

plugin-dev-release.yml releases a plugin from its N.x-dev branch when plugin.json carries a stable version that has no tag yet. Merge the version bump with its CHANGELOG.md entry undated or marked Unreleased, such as * 6.0.8 - Fix the export, ## [6.0.8], ## v6.0.8 TBD or ## 6.0.8 Unreleased, and the run:

  1. dates the entry with the day it runs, in UTC, on automated/release-date-<version>, with a warning when the branch already dates it otherwise;
  2. tags that commit and publishes its GitHub Release, with the entry's changes and a link to CHANGELOG.md as its notes (only the link when no changes can be read), marked latest only when its version is higher than the current latest release, but leaves a draft or prerelease someone else made for that version to them;
  3. opens a pull request bringing the date into the branch, approves its held checks and sets it to merge once the branch protection's required checks pass.

Nothing is released while a push-triggered workflow run on the branch's head has failed, or is still running after ci-wait-minutes: the new version is skipped and the run fails once any earlier version it is finishing is done, and the newest run of each workflow is the one that counts, so rerunning a failed one clears it. A head with no push runs, such as a merge made with GITHUB_TOKEN, is released, and runs that cannot be read hold the version the same way. When the failure is not the release's concern, run the workflow by hand with skip-ci-check.

A version the workflow tagged is finished by later runs until its release is published and its date is on the branch, even after plugin.json has moved on: each run publishes the release and opens or merges the pull request from the tagged commit.

The pull request merges through auto-merge, so turn on the repository setting "Allow auto-merge"; without it, a pull request waiting on required checks is left open. When the branch protection does not let it merge, the pull request is left open for a person with a warning, labelled Needs Review, and, with approver-token, sent for review to reviewer-team; a rerun does not request it again while someone is requested or anyone other than the approver has reviewed. Without approver-token, nothing can request the review, so a comment mentions reviewer-team instead, once. A user mention from github-actions[bot] notifies; whether a team mention does is not yet confirmed, so the run still warns. A pull request that merges by itself is neither labelled nor sent for review. The release is out either way. It only merges the commit it tagged, with a merge commit so that commit stays on the branch, and never a pull request that changes more than CHANGELOG.md.

GITHUB_TOKEN opens the pull request, which needs the repository or organisation setting "Allow GitHub Actions to create and approve pull requests". Its checks are then held for approval, which the workflow gives.

Input Required Default Description
workflows-ref no main Ref of this repository for the release scripts. When uses: is pinned to a commit, pin this to the same commit, or the scripts still run from main
reviewer-team no plugin-reviewers in matomo-org, premium-plugins-reviewers in innocraft Slug of the team in the repository's organisation that a release date pull request left waiting on a person requests review from, or mentions without approver-token. In any other organisation nobody is asked unless this is set
skip-ci-check no false Release even when a push CI run on the branch's head failed or is still running. Meant for a run by hand
ci-wait-minutes no 45 How long to wait, from 0 to 45 minutes, for push CI still running on the branch's head before failing without a release
release-branch no '' Release from this one branch instead of N.x-dev, without checking that plugin.json's major matches the branch. For a plugin that keeps a single branch and versions apart from Matomo, such as a bundle on main. Empty releases only from N.x-dev. Give a literal, quoted branch name: Plugins CI's release-date job fails on an expression
require-approver-token no false Fail before releasing when approver-token is empty, so a secret that was renamed, removed or not passed is noticed
Secret Required Description
approver-token no A token for a user other than github-actions, which approves and merges the release date pull request, and requests the team review on one left waiting on a person. GITHUB_TOKEN cannot approve a pull request it opened, and a classic push restriction cannot list github-actions. The token's user or App needs write access to the plugin repository and must be allowed by any push restriction. A token scoped to the one plugin repository is the least-privilege choice: a fine-grained token, or a GitHub App, with contents and pull requests read and write; a GitHub App also needs organisation Members read access to request the team review.
name: Weekly release
on:
  schedule:
    # Mondays 02:15 UTC, the NZ afternoon, so the release is dated that Monday.
    - cron: '15 2 * * 1'
  workflow_dispatch:
    inputs:
      all-branches:
        description: "Also release the other maintained branches, as the schedule does"
        type: boolean
        default: false
      skip-ci-check:
        description: "Release even though CI on the branch's head failed or is still running"
        type: boolean
        default: false

permissions: {}

jobs:
  release:
    permissions:
      actions: write
      contents: write
      pull-requests: write
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-dev-release.yml@main
    with:
      skip-ci-check: ${{ inputs.skip-ci-check || false }}
    # Optional; leave it out to merge with the workflow's own token.
    secrets:
      approver-token: ${{ secrets.RELEASE_APPROVER_TOKEN }}

  # A dispatched run must not dispatch again, or the branches would keep dispatching each other.
  other-branches:
    if: github.event_name == 'schedule' || inputs.all-branches
    permissions:
      actions: write
      contents: read
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-branch-sweep.yml@main
    with:
      workflow-file: weekly-release.yml

Save it as .github/workflows/weekly-release.yml on every maintained branch, since workflow-file names it. The schedule only runs on the default branch, so the branch sweep dispatches the same file on the other maintained branch, which releases itself from its own copy. Run by hand, it releases the branch it is run on, and all-branches dispatches the others too. As with Plugins CI, the caller grants the permissions above and declares no concurrency of its own, which the release job checks. GitHub disables a public repository's schedule after 60 days without activity.

A plugin with a single branch, such as a bundle releasing 1.0.x from main, passes release-branch: main and leaves out the other-branches job, which would run the weekly release on any N.x-dev branch that still has weekly-release.yml, from that branch's own copy. The CI check reads push runs on the release branch, so add it to the push trigger of any CI the release should wait for, such as branches: ['**.x-dev', main] in Plugins CI above; with no push runs the version is released, with a warning. A plugin without Plugins CI can still guard its changelog by calling plugin-release-date-check.yml on pull requests into the release branch. Two safeguards of the N.x-dev path do not cover a release branch. Only date branches of plugin.json's current major are resumed, so a version left tagged but unfinished when the major is bumped has to be finished by hand. And a plugin that moves to a release branch from a higher N.x-dev line keeps that line's release as GitHub's latest, since a release is only marked latest when its version is higher.

Plugins CI's release-date job keeps the entry undated: on a pull request into N.x-dev, or into the release-branch the plugin passes to plugin-dev-release.yml, it fails when the untagged version's entry carries a date, or has no entry the release could date. It also fails when the entry below it is undated and its version has no tag, since the release only takes plugin.json's version and one bumped past before the run would never be released. A tag named v6.0.2 counts as released, and an entry dated in a format the release cannot write counts as dated. Only the entry the release reads up to is looked at, so an undated one after a --- rule or a grouping heading such as ### Version 6 is not caught. It passes in a plugin that does not call plugin-dev-release.yml (though it fails on any branch when a workflow cannot be read), so adopting the weekly release is what turns it on, and it passes on the release date pull request, whose version is tagged by then.

Vendored dependencies update

Dependabot cannot update a plugin that commits only vendor/prefixed, such as SearchEngineKeywordsPerformance or GoogleAnalyticsImporter. It edits composer.json and composer.lock and nothing else, so its pull request would test the old prefixed code under a new lock file and pass. This workflow does what a developer does locally instead: composer update, then the scope-dependencies action, which runs matomo-scoper and Rector and checks the result. It then opens a pull request with the rebuilt tree, or refreshes the one already open.

Keep Dependabot alerts on for these repositories, since they still report advisories against composer.lock, but don't give them a composer entry in dependabot.yml.

Input Required Default Description
branch no the caller's ref Branch to update and open the pull request against
php-version no 8.3 PHP to run the tooling on, 8.1 or later. Resolution follows config.platform.php in the plugin's composer.json, or platform-php when it has none
platform-php when composer.json has no config.platform.php '' PHP to resolve dependencies against, usually the plugin's minimum, such as 8.1.0. Set only for the update: neither composer.json nor composer.lock keeps it. A pin in composer.json takes precedence
downgrade-php no auto Passed to the action: auto, none, 7.3 or 8.1
allowed-unprefixed-namespaces no '' Passed to the action
scoper-ref no '' Passed to the action
workflows-ref no main Ref of this repository to take the action and the plugin floor resolver from
scripts-ref no main Ref of matomo-org/github-action-tests for the alias resolver
bump-version no true Record the update in plugin.json and CHANGELOG.md, as described below
changelog-entry no Updated dependencies The changelog line bump-version adds
reviewer-team no '' Team in the repository's organisation whose review the pull request requests while nobody is requested and nobody has reviewed. Empty picks plugin-reviewers in matomo-org and premium-plugins-reviewers in innocraft
Secret Required Description
DEPS_PR_TOKEN no Pushes the branch and opens the pull request. Without it, GITHUB_TOKEN opens it, which needs the repository or organisation setting "Allow GitHub Actions to create and approve pull requests", off by default, and the plugin's CI does not start by itself. After each refresh, close and reopen the pull request to run Plugins CI, and run matomo-tests.yml on the update branch from the Actions tab, since the matomo-tests.yml that generate:test-action writes does not subscribe to reopened. Pushing to the branch starts both, but stops the scheduled update refreshing it. It also requests the team review, so a token that cannot request one fails the run; a GitHub App needs organisation Members read access for that. Without the token no review is requested
name: Vendored dependencies update
on:
  schedule:
    - cron: '20 4 * * 1'
  workflow_dispatch:

permissions: {}

jobs:
  update:
    strategy:
      fail-fast: false
      matrix:
        include:
          - branch: '6.x-dev'
            platform: '8.1.0' # the require.php minimum in this branch's plugin.json
          - branch: '5.x-dev'
            platform: '7.2.5' # likewise
    permissions:
      contents: write
      pull-requests: write
    uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-vendored-deps-update.yml@main
    with:
      branch: ${{ matrix.branch }}
      platform-php: ${{ matrix.platform }}
    secrets:
      DEPS_PR_TOKEN: ${{ secrets.DEPS_PR_TOKEN }}

Each branch gets its own pull request, from automated/vendored-dependencies-<branch>. A later run force-pushes that branch with a fresh rebuild. While the pull request is open, a run skips the branch once anyone other than the workflow has committed to it, such as for a changelog entry and version bump when bump-version is off or the bump was skipped, so that work is never overwritten. Once the pull request is merged or closed, the next run rebuilds the branch from scratch. Every run that opens or refreshes the pull request labels it Needs Review, and requests review from the reviewer-team while nobody is requested and nobody has reviewed, so a refused request is retried on the next refresh without notifying a team that is already reviewing. Composer otherwise resolves against the runner's PHP, so the example passes each branch's minimum as platform-php. A plugin whose composer.json pins config.platform.php can leave it out.

Without that pin the lock records no platform, so working on the tree locally needs the minimum passed by hand. On a PHP newer than the minimum, composer install refuses any locked package whose own PHP constraint stops short of the PHP running it, so install with composer install --ignore-platform-req='php*'. To update, resolve against the minimum and then take it back out, as the workflow does: cp composer.json composer.json.orig && composer config platform.php 8.1.0 && composer update, then mv composer.json.orig composer.json && composer update --lock --no-install --ignore-platform-req='php*' --no-scripts. Restore the copy rather than running composer config --unset, which leaves an empty platform object that the lock's hash still counts. A plain composer update resolves against the local PHP, and the next scheduled run reverts it.

Before the update, the run compares the platform it resolves against with the plugin's minimum, read from plugin.json alone with the resolver the minimum PHP lint uses. A platform on a later PHP minor than the minimum gets a warning, on the run and in the pull request body, because the update can then lock packages that fail for users on the minimum. Only major and minor are compared, because the resolver and the alias table report the minimum only to that precision. It never fails the run: closing the gap means changing the plugin, by raising require.php or resolving against the minimum, and failing would hold back every update until someone did. A platform below the minimum only resolves more conservatively, so it passes without a warning. When the comparison cannot run, for example because the alias table does not know the plugin's Matomo major, the run and the pull request body say so in a note instead.

Rector transpiles the rebuilt tree's syntax down to the plugin's floor, but not what newer PHP added to the standard library, so a release that starts calling array_find() or json_validate() still transpiles, and fails at runtime on the floor. The pull request lists each such use the rebuild added, found by scripts/bash/find_new_floor_php_gaps.sh: PHPStan, run on the floor PHP with the floor as phpVersion, over the tree before and after the rebuild. Only PHP's own functions, classes and members are kept, since a scoped tree refers to many packages it does not install, and only uses the rebuilt tree makes more often than the earlier tree did, listed for each file that makes them more often, since every tree already makes some guarded uses. It warns rather than fails, because a use behind a function_exists() or version check is fine. PHPStan gets five minutes for each tree, and past that the pull request says the check could not run. A global function or class is taken as PHP's own, so one from a package the plugin does not install is listed too. A method the floor lacks is missed when it is called through a scoped subclass, since PHPStan names the subclass rather than PHP's class. It cannot see members of extension classes whose stubs are incomplete, such as ZipArchive, and a 7.3 floor is checked on PHP 7.4, the oldest PHPStan runs on, so what 7.4 added is not reported. With downgrade-php: none nothing is checked.

Two kinds of breakage fail the run instead of opening a pull request, because each is a fatal error for whatever touches it. First, every class in the rebuilt tree is loaded on the floor PHP by scripts/bash/find_new_unloadable_classes.sh, and a class that loaded before the rebuild and no longer does fails the run: linting reads one file at a time, so it misses a class whose method lost a return type its interface keeps. Classes that never loaded, such as integrations with packages the plugin does not install, are not counted. Second, the PHPStan pass above also fails the run when the rebuilt tree newly uses an enum's from(), tryFrom() or cases() on a class of the tree itself that lacks it, or ->value or ->name on one that was an enum before Rector turned it into a class of constants. When the floor PHP cannot be set up or a check cannot run, it is skipped, and the run and the pull request say so.

The rebuild changes code the plugin ships, so with bump-version, the default, the pull request also records it for release. When the version in plugin.json is already tagged, as 6.0.3 or v6.0.3, it is bumped to the next patch and CHANGELOG.md gets a new entry above the newest one, in the same heading and list style, holding changelog-entry. When the version is not tagged yet and its entry is undated, the version stays and changelog-entry is added to its entry, or the entry is created if the newest one is older; a line already saying the same is not repeated. Either way the entry is left undated, for whoever releases it to date. Each refresh recomputes this from the base branch, so a version bumped there in the meantime is picked up rather than bumped twice. A changelog it cannot edit with confidence, such as one entry per line (* 6.0.1 - 2026-09-14 Fixed ...) or sections with headings of their own, is left alone, and the pull request body says why so that a person can add the entry. So is a changelog whose newest entry is ahead of plugin.json, a version whose next patch is already tagged, an untagged version whose entry is dated, a failed tag lookup, and a repository with no tags at all. None of these fails the run.

The pull request body ends with the repository's pull_request_template.md, with its checklist answered so the AI checklist passes without an edit: "Version bumped?" is ✔ when the version is bumped or the entry is added to, or already in, an unreleased version and ✖ otherwise, "Tested locally" is ✖, and every other item, the AI items included, is NA, since a rebuild has no AI output, new text or tests of its own. Whoever adds to the pull request answers again for what they add. Only items written with the template's [✔/✖] or [✔/✖/NA] placeholder are answered.

The rebuild can run code the dependencies it installs ship, such as a Composer plugin, so it runs in a job with read-only permissions and hands the rebuilt composer.lock and vendor/ to a second job as an artifact. Only that second job holds the write token, and it runs nothing from the dependencies. The version bump is made in the first job before the dependencies are installed, and the second job applies it only if it still matches the hash recorded then. The caller still grants contents: write and pull-requests: write, as in the example, for the second job to use.

Scope dependencies

actions/scope-dependencies rebuilds vendor/prefixed from whatever the plugin's unprefixed vendor/ holds, so run composer install or composer update in the plugin first, passing the minimum PHP as above when composer.json pins no platform. The action runs scripts/bash/scope_plugin_dependencies.sh, which does what DevPluginCommands' process-dependencies command does, without Matomo's console:

  1. It scopes the dependencies with matomo-scoper, which also writes the vendor/autoload.php proxy.

  2. It transpiles vendor/prefixed with Rector, using the Rector version locked in actions/scope-dependencies/tools and the config in actions/scope-dependencies/rector.php. With auto, the target is 8.1 when the lowest Matomo that plugin.json accepts is 6 or later, or when the lowest PHP that plugin.json's require.php or composer.json's config.platform.php declares is 8.1 or later, and 7.3 otherwise.

  3. It runs scripts/bash/check_scoped_tree.sh, which fails on any of the following, each of which has happened locally while the tools reported success:

    • vendor/autoload.php is no longer the scoper's proxy.
    • A namespace under vendor/prefixed is outside Matomo\Dependencies\<plugin> and not on the allowed list.
    • composer.lock resolved different packages from HEAD, but the tree did not change.

    It also warns, without failing, about each string literal the rebuild added that names a scoped namespace without the prefix, such as 'phpseclib3\\Math\\BigInteger'. php-scoper does not rewrite class names built in strings, so a plugin's scoper.inc.php patchers do, and a patcher matching literal text silently stops working when a release builds the name another way. Every scoped tree already holds harmless strings like these, in error messages and comments, so only strings the tree at HEAD held nowhere are reported, which also keeps a file a release moved from being reported again. The comparison ignores the quotes, the escaping and a leading separator, so a string a release only rewrites in another form is not reported either. The vendored dependencies update also lists the first 50 in its pull request, for whoever merges it to check.

It needs php 8.1 or later, composer, jq, git and curl on PATH, and python3 when downgrade-php is auto on a plugin that supports Matomo 5, and sets up no PHP of its own, so the PHP of the steps after it is unchanged. Matomo core and DevPluginCommands aren't needed, and neither is any secret.

Input Required Default Description
plugin-path no . Path to the plugin, which must be a git checkout
downgrade-php no auto auto reads the target from plugin.json and the composer platform, none skips Rector, or pass 7.3 or 8.1
allowed-unprefixed-namespaces no '' Whitespace-separated namespaces allowed to stay global in vendor/prefixed, besides Composer\Autoload, which is always allowed
scoper-ref no '' Ref of matomo-org/matomo-scoper. Empty uses the commit the script pins

The outputs are plugin-name, read from plugin.json, downgrade-php-version, which is empty when Rector was skipped, and enum-list, a file naming the enums the scoped tree declared before Rector ran.

The scoper's output is committed as code the plugin ships, so the script pins what produces it: a matomo-scoper commit, and the sha256 of the php-scoper build that commit downloads. Before the scoper can run it, the script downloads that phar itself, replacing any kept copy with a different hash, and stops if the download does not match. A newer scoper means bumping SCOPER_PINNED_REF in scripts/bash/scope_plugin_dependencies.sh, and also PHP_SCOPER_URL and PHP_SCOPER_SHA256 if the new scoper uses a different php-scoper build. A scoper-ref that resolves to another commit, or a local --scoper-dir, runs whatever php-scoper build that scoper fetches, unchecked, and the script warns that it did.

steps:
  - uses: actions/checkout@v4
    with:
      persist-credentials: false
  - uses: shivammathur/setup-php@v2
    with:
      php-version: '8.3'
      coverage: none
  - run: composer install --no-interaction
  - uses: matomo-org/plugin-ci-workflows/actions/scope-dependencies@main

Set up PHP with coverage: none. Under Xdebug, php-scoper can copy deeply nested files through unprefixed while still reporting success. The script turns Xdebug off for itself, but coverage: none keeps it off for the composer steps as well.

Running it locally

The same script rebuilds the tree on a developer's machine, so a local rebuild matches the one CI produces. From the plugin's directory:

composer update
bash /path/to/plugin-ci-workflows/scripts/bash/scope_plugin_dependencies.sh

It fetches matomo-scoper and installs the locked Rector into ~/.cache/matomo-scope-dependencies (or under $XDG_CACHE_HOME), and reuses them on later runs. Its options mirror the action's inputs:

Option Default Description
--downgrade-php=TARGET auto auto, none, 7.3 or 8.1, as for the action
--allow-namespace=NS A namespace allowed to stay global, besides Composer\Autoload. Repeat it for more than one
--scoper-ref=REF the pinned commit Ref of matomo-org/matomo-scoper to fetch
--scoper-dir=PATH Use an existing matomo-scoper checkout instead of fetching one
--tools-dir=PATH ~/.cache/matomo-scope-dependencies Where the tools are installed
--enum-list=PATH Write the enums the scoped tree declares to PATH before Rector runs
--dry-run Print the plugin and the downgrade target, and stop

A plugin directory can be passed as the last argument instead of running from inside it. The plugin must be a git checkout, because the tree check compares composer.lock against HEAD.

The pre-push hook

hooks/pre-push runs PHPStan over the files a push actually changes, before the push leaves the machine. It is a developer convenience, not a gate: CI analyses the whole repository regardless, and never executes this hook.

This file is the canonical copy. Each plugin ships its own under .git-hooks-matomo/, and git runs it because core.hooksPath in that clone names the directory:

git config core.hooksPath .git-hooks-matomo

add-git-hooks-to-plugins.sh in matomo-developer-tools does that across a checkout, and the standard DDEV environment script already calls it, so a developer who set the environment up that way has the hooks running already.

The path stays repository-relative on purpose. An absolute path pointing outside the repository breaks the moment that directory is moved or renamed — and it breaks silently, because git runs no hook and reports nothing when core.hooksPath names somewhere that does not exist. A push then succeeds with no checks and no warning, which is worse than running a hook that is out of date. Keeping the copy in the repository also means someone who clones a single plugin gets the hook with it, without cloning this repository as well.

Keeping a plugin's copy in sync

The cost of a copy per repository is drift, and those copies currently sit at several different vintages. Sync one by taking this file:

cp path/to/plugin-ci-workflows/hooks/pre-push .git-hooks-matomo/pre-push

Then set verify-hook: true on that plugin's Plugins CI caller, which fails the build when the two differ, so the copy cannot drift again unnoticed. Set it only after syncing: the check is a hard failure, not a warning. The check reports as ci / hook-check / Hook check. It was ci / Hook check until the comparison moved into its own called workflow, so a repository that lists the old string as a required status check has to be updated — a required context that stops being reported does not fail, it waits forever. A plugin that has not migrated to Plugins CI cannot have this check: plugin-phpstan.yml used to carry it and no longer does, because a drifted hook reporting as red PHPStan was the problem.

The comparison is its own check, plugin-hook-check.yml, running scripts/bash/check_hook_sync.sh. It used to be a step inside the PHPStan job, which was wrong twice over: a vendored hook drifting is not a finding about the plugin's code, and the comparison ran before the analysis started, so a drift both reported as red PHPStan on two matrix legs and suppressed the analysis that would have told you something real. One consequence of the move: a repository setting both verify-hook: true and skip-phpstan: true used to get no hook check, because the check lived inside the workflow it was skipping. It now runs, and can fail.

It is a called workflow rather than a job written into plugin-ci.yml directly, and that still matters even though no check is skipped on an edit any more. verify-hook is off by default, so on most repositories this job is skipped — and a skipped job defined in the umbrella publishes its check run under the same name it uses when it runs, so any run that skipped it would land a skipped on top of a real verdict. A called workflow cannot: its skip reports as ci / hook-check while a run that happened reports as ci / hook-check / Hook check. tests/plugin_ci_invariants_test.sh requires it of every job in the umbrella that can be skipped — one carrying an if:, or one whose needs: can be skipped out from under it — so a check added later gets the property without anyone remembering it.

The hook works out for itself which plugin it is in, from the repository root git reports, so the same file works unmodified in every plugin. Where it cannot find a plugins/ directory above it — any repository that is not a Matomo plugin — it prints a line saying so and exits 0.

Finding plugins where the hook never runs

Shipping the file is not the same as running it: a plugin whose core.hooksPath is unset has the hook and no way to reach it, and because nothing runs, nothing says so. On a push that succeeds, the hook looks at its sibling plugins and names any that ship a copy without the setting, at most once a day:

NOTE: 3 plugin(s) ship a pre-push hook that never runs, because
      core.hooksPath is not set in them: AbTesting ActivityLog Cohorts
      Activate with add-git-hooks-to-plugins.sh from matomo-developer-tools.

It only reads, never writes. A plugin that ships no hook is left out of the report entirely, because there is nothing there to activate and setting core.hooksPath to a directory that does not exist is worse than leaving it alone: git then runs no hook at all — including anything the repository keeps in .git/hooks — and reports nothing when it does so.

Using a reusable workflow

jobs:
  phpcs:
    uses: matomo-org/plugin-ci-workflows/.github/workflows/<workflow-file>.yml@main
    with:
      plugin-name: YourPlugin
    secrets: inherit

Using a composite action

jobs:
  checks:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - uses: matomo-org/plugin-ci-workflows/actions/<action-name>@main
        with:
          plugin-name: YourPlugin

Pinning

Callers may track @main or pin to a tag.

Tracking @main is the default for Matomo plugin repositories, and it is what makes a single change here reach the whole fleet at once. The trade-off is that a mistake also reaches the whole fleet at once, so treat main as production.

Pin to a tag where a repository needs to hold a check steady — for example while a plugin is mid-migration to a new Matomo major version and cannot yet take an updated check.

Pinning the uses: reference alone is not a full pin. plugin-phpcs.yml, plugin-phpstan.yml, plugin-min-php-lint.yml, plugin-compatibility.yml and plugin-vendored-deps-update.yml also run helper scripts checked out at scripts-ref; plugin-phpstan.yml, plugin-min-php-lint.yml, plugin-compatibility.yml, plugin-hook-check.yml, plugin-timezone-safety.yml and plugin-vendored-deps-update.yml take files from this repository at workflows-ref; and plugin-license-check.yml takes its script at script-ref. Those default to main, so a caller that pins only the workflow still executes mutable helper code. A caller that needs an immutable pin has to set every ref it uses — and scripts-ref takes a SHA from github-action-tests, which is a different repository with different SHAs. One thing stays mutable regardless: plugin-phpcs.yml installs matomo-org/matomo-coding-standards:dev-master, deliberately, so that a coding-standards change reaches the fleet without a pull request per repository. No input pins it, so a fully immutable PHPCS run is not on offer — pin the rest and accept that one, or run PHPCS from your own pinned install.

Contributing

main is protected. Changes land through a pull request with at least one approval, and only the plugin-reviewers team can merge.

Because callers tracking @main pick a change up on their next run, with no release step in between, a merge here is a deployment. Check what a change does to an existing caller before merging it.

License

GPL-3.0-or-later. See LICENSE.

About

Shared CI workflows and composite actions for Matomo plugin repositories

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages