Shared CI workflows and composite actions for Matomo plugin repositories.
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.
| 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.
| 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-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 |
hooks/pre-push |
Local git hook | Runs PHPStan over a push's own changed files, before the push leaves the machine |
Runs PHPCS, PHPStan, the compatibility check, the license check, the minimum PHP lint, the timezone safety 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: inheritThe 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. 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 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.
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.
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: MyPluginThe 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.
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: inheritSet 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.
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.
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: inheritEach 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.
GeneratedAssetCompilationTestmerges 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.GeneratedTwigCompilationTestloads every.twigfile under the plugin'stemplates/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.
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@mainThe 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.
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.shRun it against a complete repository:
bash scripts/bash/check_timezone_safety.sh /path/to/pluginTo 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.
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 dayThe 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/pluginFails 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@mainRuns 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, so the caller has to grant actions: 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
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@mainThe 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.
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.
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 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.
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 |
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@mainThree 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 — the same shape of silence the section above is about. Re-enabling it is a click in the Actions tab, but nothing prompts you to.
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.
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, which the plugin's composer.json must set |
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 from |
| 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 until someone pushes to the pull request or closes and reopens it |
name: Vendored dependencies update
on:
schedule:
- cron: '20 4 * * 1'
workflow_dispatch:
permissions: {}
jobs:
update:
strategy:
fail-fast: false
matrix:
branch: ['6.x-dev', '5.x-dev']
permissions:
contents: write
pull-requests: write
uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-vendored-deps-update.yml@main
with:
branch: ${{ matrix.branch }}
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 the changelog entry and version bump the pull request still needs, so that work is never overwritten. Once the pull request is merged or closed, the next run rebuilds the branch from scratch. The plugin's composer.json has to pin config.platform.php, because composer otherwise resolves against the runner's PHP.
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 caller still grants contents: write and pull-requests: write, as in the example, for the second job to use.
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. The action runs scripts/bash/scope_plugin_dependencies.sh, which does what DevPluginCommands' process-dependencies command does, without Matomo's console:
- It scopes the dependencies with matomo-scoper, which also writes the
vendor/autoload.phpproxy. - It transpiles
vendor/prefixedwith Rector, using the Rector version locked inactions/scope-dependencies/toolsand the config inactions/scope-dependencies/rector.php. Withauto, the target is 8.1 when the lowest Matomo that plugin.json accepts is 6 or later, and 7.3 otherwise. - 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.phpis no longer the scoper's proxy.- A namespace under
vendor/prefixedis outsideMatomo\Dependencies\<plugin>and not on the allowed list. composer.lockresolved different packages fromHEAD, but the tree did not change.
It needs php 8.1 or later, composer, jq, git and curl on PATH, 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, 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, and downgrade-php-version, which is empty when Rector was skipped.
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@mainSet 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.
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.shIt 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 |
--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.
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-matomoadd-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.
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-pushThen 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.
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.
jobs:
phpcs:
uses: matomo-org/plugin-ci-workflows/.github/workflows/<workflow-file>.yml@main
with:
plugin-name: YourPlugin
secrets: inheritjobs:
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: YourPluginCallers 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 and plugin-compatibility.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.
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.
GPL-3.0-or-later. See LICENSE.