Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ package-lock.json
phpcs*.xml*
.phpstan.neon
phpstan.dist.neon
phpunit.xml*
phpunit*.xml*
infection.json*
eslint.config.*
stylelint.config.*
Expand Down
3 changes: 1 addition & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ updates:
open-pull-requests-limit: 5
cooldown:
default-days: 7
# This source repo keeps action bumps grouped. Consumer repos split policy at review time:
# unprivileged build-side reusable bumps can automerge; credentialed deploy bumps need review.
# Action bumps stay grouped here, because consumers decide per bump which ones may automerge.
groups:
github-actions:
patterns:
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/reusable-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,12 @@ jobs:
return $lowest;
};

// Scoped packages ship from vendor-prefixed/ although Composer counts them as development packages.
// The project's own manifest states the floor of the plugin's code, and scoped packages ship from vendor-prefixed/ although Composer counts them as development packages.
$packages = [];
$project = is_file('composer.json') ? json_decode((string) file_get_contents('composer.json'), true) : null;
if (is_array($project)) {
$packages[] = $project;
}
if (is_file('vendor/composer/installed.json')) {
$installed = json_decode((string) file_get_contents('vendor/composer/installed.json'), true);
foreach ($installed['packages'] ?? [] as $package) {
Expand Down Expand Up @@ -444,7 +448,7 @@ jobs:
echo "::error::Archive must not contain $entry."
status=1
;;
"$PLUGIN_SLUG/phpunit.xml"|"$PLUGIN_SLUG/phpunit.xml."*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."*)
"$PLUGIN_SLUG/phpunit"*.xml*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."*)
echo "::error::Archive must not contain $entry."
status=1
;;
Expand All @@ -456,10 +460,7 @@ jobs:
echo "::error::Archive must not contain $entry."
status=1
;;
# Bundled CA/cert files under vendor/ or vendor-prefixed/ are legitimate
# runtime resources, not secrets. This exempt arm MUST stay ahead of the broad
# *.pem/*.key secret arm below — `case` stops at the first match, so reordering or
# merging the two would start flagging bundled certs (or suppress the secret check).
# Bundled certificates are runtime resources, and this arm stays ahead of the secret arm below because case stops at the first match.
"$PLUGIN_SLUG/vendor/"*.pem|"$PLUGIN_SLUG/"*"/vendor/"*.pem|"$PLUGIN_SLUG/vendor/"*.key|"$PLUGIN_SLUG/"*"/vendor/"*.key \
|"$PLUGIN_SLUG/vendor-prefixed/"*.pem|"$PLUGIN_SLUG/vendor-prefixed/"*.key)
;;
Expand Down
21 changes: 3 additions & 18 deletions .github/workflows/reusable-workflow-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
# code-scanning/alerts is GHAS-gated: a 200 (even an empty list) means the feature is
# available; a 403 naming Advanced Security is the exact failure the SARIF upload below
# would hit on a private repo without the feature, so probing this surfaces it first. A
# confirmed-absent 403 is trusted immediately. Everything else — a curl error, a transient
# 5xx, an unrecognized 403 body — is ambiguous and gets retried up to max_attempts times
# before the loop gives up.
#
# An unresolved ambiguity resolves to "unavailable": the native-mode zizmor branch below
# still scans and fails the job on any finding, so guessing wrong only costs the
# Security-tab upload — a safe degradation.
# The code-scanning alerts endpoint answers 403 naming Advanced Security exactly where the SARIF upload would fail, so an unclear answer is retried and then read as unavailable, which only skips the upload.
probe_body="${RUNNER_TEMP}/ghas-probe.json"
max_attempts=3
retry_sleep_seconds=5
Expand Down Expand Up @@ -106,10 +97,7 @@ jobs:
with:
version: ${{ env.ZIZMOR_VERSION }}

# No-GHAS path: the same pinned action in native mode. advanced-security:false skips the
# Security-tab upload and drops --format=sarif, so zizmor runs in plain mode and its own
# nonzero exit on any finding fails the job directly — no count gate on this branch.
# annotations:true surfaces findings inline on the PR.
# Without Advanced Security, zizmor runs in plain mode, where its own nonzero exit on a finding fails the job.
- name: Run zizmor without Advanced Security
if: needs.detect-ghas.outputs.ghas-available == 'false'
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
Expand All @@ -123,10 +111,7 @@ jobs:
env:
SARIF: ${{ steps.zizmor.outputs.output-file }}
run: |
# Fail closed: only a well-formed SARIF whose runs[].results count is a single integer is
# trusted. A missing/empty file, unparseable JSON, parseable-but-non-SARIF JSON ({}, null,
# {"runs":{}}), or a multi-document count is an unknown scan result and must not pass — an
# empty/non-integer $count would otherwise make `[ -ne 0 ]` error and the `if` fall through.
# Only a SARIF whose result count is one integer is trusted, because a non-integer count would make the comparison error and the check fall through.
if [ ! -s "$SARIF" ]; then
echo "::error::zizmor produced no SARIF at '$SARIF' — cannot confirm a clean scan."
exit 1
Expand Down
4 changes: 1 addition & 3 deletions .github/workflows/workflow-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ on:
pull_request:
paths: ['.github/workflows/**']
schedule:
# Weekly Monday 06:17 UTC — off the top of the hour, where GitHub sheds scheduled runs, and
# staggered against the other weekly crons. Keeps zizmor's online audits (e.g.
# known-vulnerable-actions) fresh between workflow changes.
# A weekly run off the hour keeps zizmor's online audits current between workflow changes.
- cron: '17 6 * * 1'
workflow_dispatch:

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ zizmor --config .github/zizmor.yml .github/
- Every reusable-workflow input has a `description:`; shipped input names, types and defaults change only as a coordinated migration (CONTRIBUTING.md).
- Self-CI calls the reusable workflows through `./`, and `.github/zizmor.yml` turns off zizmor's `self-repository` audit until actionlint accepts `$/`.
- The README is an index; workflow inputs live in `docs/workflows.md`, script and file contracts in `docs/scripts-contract.md`, test tiers in `tests/README.md`. A behavior change updates them in the same change.
- The README's Requirements state the PHP, WordPress, Node and npm floors; a floor change updates them in the same commit as `composer.json`, `package.json`, `php/quality-assurance/phpcs.base.dist.xml` and `php/quality-assurance/phpstan.dist.neon`.
- The README names each floor by the file and key that hold it (`composer.json`'s `require.php`, `package.json`'s `engines`, `phpcs.base.dist.xml`'s `testVersion` and `minimum_wp_version`, `phpstan.dist.neon`'s `requiresAtLeast`), never by its number, so a floor change edits only those files.
- Class and method docblocks open with one third-person sentence naming the role, such as "Returns …", and add a second paragraph only for a non-obvious why; property and constant docblocks open with "The …". A function or method docblock ends with `@return`, `@return void` included, unless it documents a constructor or opens with `{@inheritDoc}`. Each `@param` stays on one line, `@throws` reads "Thrown when …", members are `protected` rather than `private`, and class bodies outside `tests/` group members in `// region NAME` blocks.
- Inline comments state a non-obvious why in one capitalized sentence, and every file describes the present, never the change.
- Markdown keeps one line per paragraph, sentence-case headings and an impersonal, present-tense voice.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ Shared PHPCS, PHPStan and php-scoper configurations, Node tool baselines and reu

## Requirements

- PHP 8.5 or later, and Composer 2.
- For the Node baselines only, Node 26 or later and npm 11 or later.
- PHP at the floor `composer.json`'s `require.php` names, and Composer 2.
- For the Node baselines only, the Node and npm versions `package.json`'s `engines` names, and the tools its `peerDependencies` list.

The shared PHPCS profiles check code against PHP 8.5 or later and WordPress 7.1 or later, and the PHPStan profile against WordPress 7.1 or later; a project with lower floors lowers them as [the scripts contract](docs/scripts-contract.md#lowering-the-floors) shows for PHPCS and [its PHPStan section](docs/scripts-contract.md#phpstan) for PHPStan.
The shared PHPCS profiles check code against the PHP and WordPress floors that `php/quality-assurance/phpcs.base.dist.xml` sets in `testVersion` and `minimum_wp_version`, and the PHPStan profile against the WordPress floor that `php/quality-assurance/phpstan.dist.neon` sets in `requiresAtLeast`; a project with lower floors lowers them as [the scripts contract](docs/scripts-contract.md#lowering-the-floors) shows for PHPCS and [its PHPStan section](docs/scripts-contract.md#phpstan) for PHPStan.

## Installation

Expand Down Expand Up @@ -217,7 +217,7 @@ A recipe returns php-scoper settings for one package, with paths relative to it:

## Node baselines

`node/` holds one baseline per tool, which a project extends and overrides. The package declares the tools behind them as `>=` peer dependencies, floored at the versions it is tested against: `@wordpress/scripts`, `@wordpress/eslint-plugin`, `@wordpress/stylelint-config`, `@wordpress/postcss-plugins-preset` and `@playwright/test`.
`node/` holds one baseline per tool, which a project extends and overrides. The package declares the tools behind them as `>=` peer dependencies, floored at the versions it is tested against: `@wordpress/scripts`, `@wordpress/eslint-plugin`, `@wordpress/stylelint-config`, `@wordpress/postcss-plugins-preset`, `@playwright/test` and `typescript`, whose defaults `node/tsconfig.base.json` builds on.

| Baseline | Holds |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ The `build` job fails when:
- the entry file's `Version:` header differs from the tag, or from the `Stable tag:` of `readme.txt` when that file exists; a wp.org release requires `readme.txt`;
- the entry file's `Text Domain:` header, or `extra.text-domain` in `composer.json` when declared, differs from `plugin-slug`;
- `vendor-prefixed/` carries strings in the plugin's text domain but the regenerated POT has none of them;
- `Requires PHP` is below the PHP floor of a shipped package, the highest lower bound of the `require.php` constraints of the production packages in `vendor/` and the scoped packages in `vendor-prefixed/`;
- `Requires PHP` is below the PHP floor of a shipped package, the highest lower bound of the `require.php` constraints of the project's own `composer.json`, the production packages in `vendor/` and the scoped packages in `vendor-prefixed/`;
- `Requires at least` is below the `extra.requires-wp` version a shipped package declares, or `Requires Plugins` lacks a slug listed in a shipped package's `extra.requires-plugins`;
- the archive misses the entry file, `readme.txt` for a wp.org release, or, when `composer.json` declares `extra.scoping-prefix`, `vendor-prefixed/scoper-autoload.php` and the files of every scoped package;
- the archive holds a development or secret file, such as a `phpcs*.xml*` ruleset, `composer.json` or `.env`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"@wordpress/eslint-plugin": ">=28.0.0",
"@wordpress/postcss-plugins-preset": ">=6.0.0",
"@wordpress/scripts": ">=37.0.0",
"@wordpress/stylelint-config": ">=27.0.0"
"@wordpress/stylelint-config": ">=27.0.0",
"typescript": ">=6.0.0"
},
"dependencies": {
"prettier": "npm:wp-prettier@^3.0.3"
Expand Down
29 changes: 29 additions & 0 deletions tests/Unit/PhpstanConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ public function test_global_statements_take_the_types_their_function_documents()
self::assertSame( array( 'method.nonObject' ), $this->analyse( 'globals.php' ) );
}

public function test_the_path_discovery_adds_a_plugin_s_conventional_files_and_scans_its_scoped_dependencies(): void {
$project = ( \realpath( \sys_get_temp_dir() ) ?: \sys_get_temp_dir() ) . '/wp-configs-phpstan-' . \uniqid();
foreach ( array( 'src', 'includes', 'blocks', 'vendor-prefixed/acme/library', 'docs' ) as $dir ) {
\mkdir( "$project/$dir", 0777, true );
}
foreach ( array( 'functions.php', 'uninstall.php', 'footprint.php' ) as $file ) {
\file_put_contents( "$project/$file", "<?php\n" );
}

$cwd = (string) \getcwd();
\chdir( $project );
try {
$config = require \dirname( __DIR__, 2 ) . '/php/quality-assurance/phpstan.dist.neon.php';
} finally {
\chdir( $cwd );
\exec( 'rm -rf ' . \escapeshellarg( $project ) );
}

self::assertSame(
array(
'parameters' => array(
'paths' => array( "$project/functions.php", "$project/uninstall.php", "$project/src", "$project/includes", "$project/blocks" ),
'scanDirectories' => array( "$project/vendor-prefixed" ),
),
),
$config
);
}

/**
* @return list<string>
*/
Expand Down
Loading
Loading