diff --git a/.distignore b/.distignore index 0ff15c2..2776436 100644 --- a/.distignore +++ b/.distignore @@ -20,7 +20,7 @@ package-lock.json phpcs*.xml* .phpstan.neon phpstan.dist.neon -phpunit.xml* +phpunit*.xml* infection.json* eslint.config.* stylelint.config.* diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ecc52cb..4287759 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index e7d0418..4bfe0e8 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -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) { @@ -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 ;; @@ -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) ;; diff --git a/.github/workflows/reusable-workflow-checks.yml b/.github/workflows/reusable-workflow-checks.yml index ea7b818..9f86c5b 100644 --- a/.github/workflows/reusable-workflow-checks.yml +++ b/.github/workflows/reusable-workflow-checks.yml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index f2b27c7..5f422c1 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 5880e2e..5fcb5f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 62f38b4..bb080e6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | | --- | --- | diff --git a/docs/workflows.md b/docs/workflows.md index cb55828..909f274 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -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`. diff --git a/package-lock.json b/package-lock.json index 68c808b..0987b56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,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" } }, "node_modules/@babel/code-frame": { @@ -18313,7 +18314,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/package.json b/package.json index 9ee5fe6..e66a88f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/Unit/PhpstanConfigTest.php b/tests/Unit/PhpstanConfigTest.php index 9143e5d..06a16fb 100644 --- a/tests/Unit/PhpstanConfigTest.php +++ b/tests/Unit/PhpstanConfigTest.php @@ -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", " array( + 'paths' => array( "$project/functions.php", "$project/uninstall.php", "$project/src", "$project/includes", "$project/blocks" ), + 'scanDirectories' => array( "$project/vendor-prefixed" ), + ), + ), + $config + ); + } + /** * @return list */ diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index 54432f4..2401178 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -241,6 +241,38 @@ public function test_headers_must_cover_the_shipped_packages( array $headers, ar } } + /** + * @return array + */ + public static function provide_project_floors(): array { + return array( + 'header-meets-the-project-floor' => array( '8.5', 0 ), + 'header-below-the-project-floor' => array( '7.4', 1 ), + ); + } + + #[DataProvider( 'provide_project_floors' )] + public function test_headers_must_cover_the_project_s_own_floor( string $requires_php, int $expected_status ): void { + $this->write_project( + array( + 'main.php' => self::plugin_header( array( 'Requires PHP' => $requires_php ) ), + 'composer.json' => (string) \json_encode( + array( + 'name' => 'acme/plugin', + 'require' => array( 'php' => '>=8.5' ), + ) + ), + ) + ); + + $result = $this->run_build_step( 'Verify the headers cover the shipped packages', array() ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( 1 === $expected_status ) { + self::assertMatchesRegularExpression( '/^::error::.*acme\/plugin/m', $result['output'] ); + } + } + /** * @return array */ @@ -250,6 +282,7 @@ public static function provide_archive_entries(): array { 'tests-ruleset' => array( 'phpcs.tests.dist.xml', 1 ), 'hidden-tests-ruleset' => array( '.phpcs.tests.xml', 1 ), 'dist-suffixed-ruleset' => array( 'phpcs.xml.dist', 1 ), + 'dist-named-phpunit' => array( 'phpunit.dist.xml', 1 ), ); } @@ -267,6 +300,137 @@ public function test_archive_must_not_ship_development_files( string $entry, int self::assertSame( $expected_status, $result['status'], $result['output'] ); } + /** + * @return array, bool, int, string}> + */ + public static function provide_scoped_archives(): array { + $complete = array( 'acme-plugin.php', 'vendor-prefixed/scoper-autoload.php', 'vendor-prefixed/acme/library/src/Client.php' ); + + return array( + 'every-scoped-file-shipped' => array( $complete, true, 0, '' ), + 'autoloader-missing' => array( array( 'acme-plugin.php', 'vendor-prefixed/acme/library/src/Client.php' ), true, 1, 'scoper-autoload.php' ), + 'scoped-package-missing' => array( array( 'acme-plugin.php', 'vendor-prefixed/scoper-autoload.php' ), true, 1, 'vendor-prefixed/acme/library/' ), + 'scoping-never-ran' => array( $complete, false, 1, 'did not run' ), + ); + } + + /** + * @param list $archived + */ + #[DataProvider( 'provide_scoped_archives' )] + public function test_the_archive_must_ship_every_scoped_package( array $archived, bool $scoped, int $expected_status, string $named ): void { + $stage = $this->work_dir . '/stage/acme-plugin'; + foreach ( $archived as $file ) { + if ( ! \is_dir( \dirname( "$stage/$file" ) ) ) { + \mkdir( \dirname( "$stage/$file" ), 0777, true ); + } + \file_put_contents( "$stage/$file", "work_dir . '/project/acme-plugin.zip' ) ), $output, $zip_status ); + self::assertSame( 0, $zip_status ); + if ( $scoped ) { + $this->write_project( array( 'vendor-prefixed/acme/library/composer.json' => '{"name": "acme/library"}' ) ); + } + + $result = $this->run_build_step( 'Assert scoped packages bundled', array() ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( '' !== $named ) { + self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $named, '/' ) . '/m', $result['output'] ); + } + } + + /** + * @return array + */ + public static function provide_provenance_runs(): array { + $required = '[".github/workflows/tests.yml", ".github/workflows/quality.yml"]'; + + return array( + 'every-required-workflow-passed' => array( $required, ".github/workflows/quality.yml\n.github/workflows/tests.yml\n", 0, '' ), + 'one-required-workflow-missing' => array( $required, ".github/workflows/tests.yml\n", 1, '.github/workflows/quality.yml' ), + 'required-workflows-not-a-list' => array( '".github/workflows/tests.yml"', '', 1, 'non-empty JSON array' ), + 'actions-api-unavailable' => array( $required, 'FAIL', 1, 'Unable to list' ), + ); + } + + #[DataProvider( 'provide_provenance_runs' )] + public function test_a_release_needs_a_successful_push_run_of_every_required_workflow( string $required, string $runs, int $expected_status, string $named ): void { + $this->write_executable( 'gh', "#!/usr/bin/env bash\nif [ \"\$(cat \"\$HOME/runs\")\" = FAIL ]; then exit 1; fi\ncat \"\$HOME/runs\"\n" ); + \file_put_contents( $this->work_dir . '/runs', $runs ); + + $result = $this->run_step( + 'reusable-release.yml', + 'provenance', + 'Verify required workflows passed', + array( + 'GH_TOKEN' => 'token', + 'REPOSITORY' => 'acme/acme-plugin', + 'SHA' => 'abc123', + 'REQUIRED_WORKFLOWS' => $required, + ) + ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( '' !== $named ) { + self::assertMatchesRegularExpression( '/^::error::.*' . \preg_quote( $named, '/' ) . '/m', $result['output'] ); + } + } + + /** + * @return array + */ + public static function provide_changelogs(): array { + return array( + 'dated-section' => array( "# Changelog\n\n## 1.2.0 - 2026-10-29\n\n- Fixed a thing.\n\n## 1.1.0 - 2026-10-01\n\n- An older thing.\n", 0, "\n- Fixed a thing.\n\n" ), + 'bracketed-section' => array( "## [1.2.0] - 2026-10-29\n\n- Fixed a thing.\n", 0, "\n- Fixed a thing.\n" ), + 'dots-are-not-wildcards' => array( "## 1a2a0 - 2026-10-29\n\n- Not this version.\n", 1, '' ), + 'no-section-for-the-version' => array( "## 1.1.0 - 2026-10-01\n\n- An older thing.\n", 1, '' ), + 'no-changelog' => array( null, 0, '' ), + ); + } + + #[DataProvider( 'provide_changelogs' )] + public function test_the_release_notes_are_the_version_s_changelog_section( ?string $changelog, int $expected_status, string $notes ): void { + $this->write_executable( 'gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\n" ); + if ( ! \is_null( $changelog ) ) { + $this->write_project( array( 'CHANGELOG.md' => $changelog ) ); + } + \mkdir( $this->work_dir . '/runner-temp' ); + + $result = $this->run_step( + 'reusable-release.yml', + 'release', + 'Create the GitHub release', + array( + 'GH_TOKEN' => 'token', + 'CHANGELOG' => 'CHANGELOG.md', + 'VERSION' => '1.2.0', + 'RUNNER_TEMP' => $this->work_dir . '/runner-temp', + 'GITHUB_REPOSITORY' => 'acme/acme-plugin', + 'GITHUB_REF_NAME' => 'v1.2.0', + 'PLUGIN_SLUG' => 'acme-plugin', + ) + ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( 1 === $expected_status ) { + self::assertMatchesRegularExpression( '/^::error::.*no section for 1\.2\.0/m', $result['output'] ); + self::assertFileDoesNotExist( $this->work_dir . '/gh-arguments' ); + return; + } + + $arguments = \file( $this->work_dir . '/gh-arguments', \FILE_IGNORE_NEW_LINES ); + self::assertIsArray( $arguments ); + if ( \is_null( $changelog ) ) { + self::assertContains( '--generate-notes', $arguments ); + self::assertNotContains( '--notes-file', $arguments ); + } else { + self::assertContains( '--notes-file', $arguments ); + self::assertSame( $notes, \file_get_contents( $this->work_dir . '/runner-temp/release-notes.md' ) ); + } + } + public function test_required_files_are_found_in_a_large_archive(): void { $stage = $this->work_dir . '/stage/acme-plugin'; \mkdir( "$stage/assets", 0777, true );