From 1b597ac33e42a0b39e19b1c910f3ccef703d22f8 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:14:36 +0100 Subject: [PATCH 01/23] Update packages The locks trailed the latest releases within range. The root Composer lock needs nothing; the release tools' lock gains Composer 2.11's published-time metadata. npm moves eslint (under wp-scripts) to 10.14.0 and minimizer-webpack-plugin to 5.14.0; TypeScript stays on 6, which typescript-eslint caps, and the cooldown's downgrade of @babel/helper-create-regexp-features-plugin to 7.29.7 is kept back at 7.29.10. Workflow Checks' zizmor pin, which Dependabot cannot bump, trailed as well; it moves to 1.31.1, the newest release the pinned zizmor-action supports. Assisted-by: Claude Code:claude-opus-5-5 --- .../workflows/reusable-workflow-checks.yml | 2 +- package-lock.json | 17 ++++--- php/release/composer.lock | 44 ++++++++++++------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.github/workflows/reusable-workflow-checks.yml b/.github/workflows/reusable-workflow-checks.yml index 5064089..1d98b46 100644 --- a/.github/workflows/reusable-workflow-checks.yml +++ b/.github/workflows/reusable-workflow-checks.yml @@ -82,7 +82,7 @@ jobs: env: # Both branches below resolve this release through the action's own version table, so a # repo's findings never differ by which branch it takes. - ZIZMOR_VERSION: '1.30.1' + ZIZMOR_VERSION: '1.31.1' steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/package-lock.json b/package-lock.json index ebc353a..1b62afd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6222,9 +6222,9 @@ } }, "node_modules/@wordpress/scripts/node_modules/eslint": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", - "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", "dev": true, "license": "MIT", "workspaces": [ @@ -6237,7 +6237,7 @@ "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", + "@humanfs/node": "^0.16.8", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", @@ -6258,7 +6258,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "minimatch": "^10.2.6", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -13525,13 +13525,12 @@ } }, "node_modules/minimizer-webpack-plugin": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.13.1.tgz", - "integrity": "sha512-1mhAfvachWYYOm8tpmany5NLuvP+F2wxZWBSX8DC0uFS2wJpM9q3rWgN9wBooAH9d5V12wX0BjyJe4fMjHMarQ==", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.14.0.tgz", + "integrity": "sha512-PnvQXCJxhgH2ZVVO7Lj0sM6q8L1XGxnUvd8wVQIRwi8khMCLq5wg0Ngxe44bcuZL2FSN81RdVE7YHEVzTKsQDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", "jest-worker": "^27.4.5", "schema-utils": "^4.6.0", "terser": "^5.51.0" diff --git a/php/release/composer.lock b/php/release/composer.lock index 22060ef..d09a7c5 100644 --- a/php/release/composer.lock +++ b/php/release/composer.lock @@ -62,7 +62,8 @@ "issues": "https://github.com/EFTEC/BladeOne/issues", "source": "https://github.com/EFTEC/BladeOne/tree/3.52" }, - "time": "2021-04-17T13:49:01+00:00" + "time": "2021-04-17T13:49:01+00:00", + "published-time": "2021-04-17T13:49:42+00:00" }, { "name": "gettext/gettext", @@ -143,7 +144,8 @@ "type": "patreon" } ], - "time": "2024-05-18T10:25:07+00:00" + "time": "2024-05-18T10:25:07+00:00", + "published-time": "2024-05-18T10:26:21+00:00" }, { "name": "gettext/languages", @@ -218,7 +220,8 @@ "type": "github" } ], - "time": "2026-02-23T14:05:50+00:00" + "time": "2026-02-23T14:05:50+00:00", + "published-time": "2026-02-23T14:07:59+00:00" }, { "name": "inmarelibero/gitignore-checker", @@ -267,7 +270,8 @@ "issues": "https://github.com/inmarelibero/gitignore-checker/issues", "source": "https://github.com/inmarelibero/gitignore-checker/tree/1.0.4" }, - "time": "2024-05-03T16:17:41+00:00" + "time": "2024-05-03T16:17:41+00:00", + "published-time": "2024-05-03T16:18:28+00:00" }, { "name": "mck89/peast", @@ -317,7 +321,8 @@ "issues": "https://github.com/mck89/peast/issues", "source": "https://github.com/mck89/peast/tree/v1.18.1" }, - "time": "2026-09-15T06:27:15+00:00" + "time": "2026-09-15T06:27:15+00:00", + "published-time": "2026-09-15T06:27:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -384,7 +389,8 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:11:13+00:00" + "time": "2024-09-25T14:11:13+00:00", + "published-time": "2024-11-27T12:25:26+00:00" }, { "name": "symfony/finder", @@ -447,7 +453,8 @@ "type": "tidelift" } ], - "time": "2024-09-28T13:32:08+00:00" + "time": "2024-09-28T13:32:08+00:00", + "published-time": "2024-10-27T12:57:19+00:00" }, { "name": "symfony/polyfill-php80", @@ -531,7 +538,8 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:50:05+00:00" + "time": "2026-09-25T15:50:05+00:00", + "published-time": "2026-09-30T10:23:35+00:00" }, { "name": "wp-cli/dist-archive-command", @@ -593,7 +601,8 @@ "issues": "https://github.com/wp-cli/dist-archive-command/issues", "source": "https://github.com/wp-cli/dist-archive-command/tree/v3.1.0" }, - "time": "2025-11-11T13:30:45+00:00" + "time": "2025-11-11T13:30:45+00:00", + "published-time": "2025-12-09T15:56:52+00:00" }, { "name": "wp-cli/i18n-command", @@ -663,7 +672,8 @@ "issues": "https://github.com/wp-cli/i18n-command/issues", "source": "https://github.com/wp-cli/i18n-command/tree/v2.7.0" }, - "time": "2026-03-16T17:13:39+00:00" + "time": "2026-03-16T17:13:39+00:00", + "published-time": "2026-03-17T18:03:01+00:00" }, { "name": "wp-cli/mustache", @@ -715,7 +725,8 @@ "support": { "source": "https://github.com/wp-cli/mustache.php/tree/v2.14.99" }, - "time": "2025-05-06T16:15:37+00:00" + "time": "2025-05-06T16:15:37+00:00", + "published-time": "2025-05-06T18:29:58+00:00" }, { "name": "wp-cli/mustangostang-spyc", @@ -766,7 +777,8 @@ "support": { "source": "https://github.com/wp-cli/spyc/tree/0.6.7" }, - "time": "2026-10-06T08:59:48+00:00" + "time": "2026-10-06T08:59:48+00:00", + "published-time": "2026-10-06T09:00:14+00:00" }, { "name": "wp-cli/php-cli-tools", @@ -829,7 +841,8 @@ "issues": "https://github.com/wp-cli/php-cli-tools/issues", "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.9" }, - "time": "2026-03-29T11:12:54+00:00" + "time": "2026-03-29T11:12:54+00:00", + "published-time": "2026-03-29T20:04:18+00:00" }, { "name": "wp-cli/wp-cli", @@ -898,7 +911,8 @@ "issues": "https://github.com/wp-cli/wp-cli/issues", "source": "https://github.com/wp-cli/wp-cli" }, - "time": "2025-05-07T01:16:12+00:00" + "time": "2025-05-07T01:16:12+00:00", + "published-time": "2025-05-07T01:16:59+00:00" } ], "packages-dev": [], @@ -912,5 +926,5 @@ "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } From 97af6ba5f935102d52a697a5a2d5c6a6ba5a4ea5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:27:12 +0100 Subject: [PATCH 02/23] Test the release, audit and workflow-check gaps the audit found Rows for the behavior the reusable workflows lack, each failing on trunk: - release: prerelease tags and their versions, notes and the wp.org refusal; the WooCommerce floor from extra.requires-wc; Composer's configured vendor directory; the forbidden development files and private keys under vendor/; every scoped PHP file in the archive; the changelog section checked in the build and handed to the release; the wp-env override merged, with core cleared per environment, and the site requested on the effective port; - the end-to-end workflow resolving the site's PHP with the PHPUnit workflow's step, and naming a missing @playwright/test; - Composer's cooldown findings read as findings; - Workflow Checks' extra-workflows list for actionlint; - the GHAS probe in reusable CodeQL, which runs analyze on an unclear answer; - the block.json check validating with the ajv-cli node/block-json/ locks. npm audit rows pin today's advisory handling before its rewrite. run_step() reads multi-line `name< $file ) { $pairs[ $key ] = array(); - foreach ( \file( $this->work_dir . '/' . $file, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES ) ?: array() as $line ) { - $pair = \explode( '=', $line, 2 ); - $pairs[ $key ][ $pair[0] ] = $pair[1] ?? ''; + $lines = \file( $this->work_dir . '/' . $file, \FILE_IGNORE_NEW_LINES ) ?: array(); + while ( array() !== $lines ) { + $line = \array_shift( $lines ); + + // GitHub Actions reads `name<write_project( + array( + 'blocks/greeting/block.json' => '{}', + 'blocks/notice/block.json' => '{}', + 'node_modules/acme/block.json' => '{}', + 'vendor/acme/block.json' => '{}', + '.block-json-tools/node/block-json/block.json' => '{}', + ) + ); + + $result = $this->validate(); + + self::assertSame( 0, $result['status'], $result['output'] ); + $validated = \file( $this->work_dir . '/ajv-calls', \FILE_IGNORE_NEW_LINES ) ?: array(); + \sort( $validated ); + self::assertSame( array( './blocks/greeting/block.json', './blocks/notice/block.json' ), $validated ); + self::assertFileDoesNotExist( $this->work_dir . '/npx-calls' ); + } + + public function test_a_project_without_blocks_validates_nothing(): void { + $result = $this->validate(); + + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertStringContainsString( 'nothing to validate', $result['output'] ); + self::assertFileDoesNotExist( $this->work_dir . '/ajv-calls' ); + } + + public function test_the_tools_lock_holds_ajv_cli_and_nothing_else_at_the_top(): void { + $manifest = \json_decode( (string) \file_get_contents( self::TOOLS . '/package.json' ), true, flags: \JSON_THROW_ON_ERROR ); + $lock = \json_decode( (string) \file_get_contents( self::TOOLS . '/package-lock.json' ), true, flags: \JSON_THROW_ON_ERROR ); + self::assertIsArray( $manifest ); + self::assertIsArray( $lock ); + self::assertIsArray( $lock['packages'] ?? null ); + self::assertIsArray( $lock['packages'][''] ?? null ); + self::assertIsArray( $manifest['dependencies'] ?? null ); + + self::assertSame( array( 'ajv-cli' ), \array_keys( $manifest['dependencies'] ) ); + self::assertArrayNotHasKey( 'devDependencies', $manifest ); + self::assertSame( $manifest['dependencies'], $lock['packages']['']['dependencies'] ?? null ); + self::assertArrayHasKey( 'node_modules/ajv-cli', $lock['packages'] ); + } + + /** + * @return array{status: int, output: string, outputs: array, env: array} + */ + protected function validate(): array { + $this->write_executable( 'bin/ajv', "#!/usr/bin/env bash\nwhile [ \$# -gt 0 ]; do\n\tcase \"\$1\" in\n\t\t-d) echo \"\$2\" >> \"\$HOME/ajv-calls\"; shift 2 ;;\n\t\t*) shift ;;\n\tesac\ndone\n" ); + $this->write_executable( 'bin/npx', "#!/usr/bin/env bash\necho \"npx \$*\" >> \"\$HOME/npx-calls\"\nexit 1\n" ); + $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\nwhile [ \$# -gt 0 ]; do\n\tcase \"\$1\" in\n\t\t-o) echo '{}' > \"\$2\"; shift 2 ;;\n\t\t*) shift ;;\n\tesac\ndone\n" ); + + return $this->run_step( + 'reusable-block-json-check.yml', + 'validate', + 'Validate block.json files against the WordPress schema', + array( 'AJV' => $this->work_dir . '/bin/ajv' ) + ); + } +} diff --git a/tests/Unit/CodeqlWorkflowTest.php b/tests/Unit/CodeqlWorkflowTest.php new file mode 100644 index 0000000..07f6fd6 --- /dev/null +++ b/tests/Unit/CodeqlWorkflowTest.php @@ -0,0 +1,51 @@ + + */ + public static function provide_probe_answers(): array { + return array( + 'advanced-security-on' => array( '200', '[]', 'true', '' ), + 'advanced-security-off' => array( '403', '{"message": "Advanced Security must be enabled for this repository to use code scanning."}', 'false', '::notice::' ), + 'another-forbidden-answer' => array( '403', '{"message": "Resource not accessible by integration"}', 'true', '::warning::' ), + 'server-error' => array( '500', '{}', 'true', '::warning::' ), + ); + } + + #[DataProvider( 'provide_probe_answers' )] + public function test_analyze_is_skipped_only_where_advanced_security_is_off( string $code, string $body, string $available, string $annotation ): void { + // Like curl, the stub writes the answer's body to the -o file and prints the status -w asks for. + $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\necho call >> \"\$HOME/curl-calls\"\nwhile [ \$# -gt 0 ]; do\n\tcase \"\$1\" in\n\t\t-o) printf '%s' " . \escapeshellarg( $body ) . " > \"\$2\"; shift 2 ;;\n\t\t*) shift ;;\n\tesac\ndone\nprintf '%s' " . \escapeshellarg( $code ) . "\n" ); + $this->write_executable( 'bin/sleep', "#!/usr/bin/env bash\n" ); + \mkdir( $this->work_dir . '/runner-temp' ); + + $result = $this->run_step( + 'reusable-codeql.yml', + 'detect-ghas', + 'Detect GitHub Advanced Security availability', + array( + 'GH_TOKEN' => 'token', + 'REPO' => 'acme/acme-plugin', + 'RUNNER_TEMP' => $this->work_dir . '/runner-temp', + ) + ); + + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertSame( array( 'ghas-available' => $available ), $result['outputs'] ); + if ( '' !== $annotation ) { + self::assertStringContainsString( $annotation, $result['output'] ); + } + + $calls = \file( $this->work_dir . '/curl-calls', \FILE_IGNORE_NEW_LINES ) ?: array(); + self::assertCount( '::warning::' === $annotation ? 3 : 1, $calls ); + } +} diff --git a/tests/Unit/PhpunitWorkflowTest.php b/tests/Unit/PhpunitWorkflowTest.php index ff93b7b..34a7d76 100644 --- a/tests/Unit/PhpunitWorkflowTest.php +++ b/tests/Unit/PhpunitWorkflowTest.php @@ -5,6 +5,7 @@ use DeepWebSolutions\Config\Tests\Support\RunsWorkflowStepsTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\Yaml\Yaml; final class PhpunitWorkflowTest extends TestCase { use RunsWorkflowStepsTrait; @@ -74,4 +75,42 @@ public function test_the_site_runs_on_php_version_only_where_the_wp_env_config_p self::assertSame( 0, $result['status'], $result['output'] ); self::assertSame( $exported, $result['env'] ); } + + public function test_the_end_to_end_workflow_resolves_the_site_s_php_with_this_workflow_s_step(): void { + $phpunit = self::job( 'reusable-phpunit.yml', 'phpunit' ); + $e2e = self::job( 'reusable-playwright-e2e.yml', 'e2e' ); + + $env = $e2e['env'] ?? array(); + self::assertIsArray( $env ); + self::assertArrayNotHasKey( 'WP_ENV_PHP_VERSION', $env ); + self::assertSame( \array_diff_key( self::step( $phpunit, "Resolve the site's PHP" ), array( 'if' => true ) ), self::step( $e2e, "Resolve the site's PHP" ) ); + } + + /** + * @return array + */ + protected static function job( string $workflow, string $job ): array { + $definition = Yaml::parseFile( __DIR__ . '/../../.github/workflows/' . $workflow ); + self::assertIsArray( $definition ); + self::assertIsArray( $definition['jobs'] ?? null ); + self::assertIsArray( $definition['jobs'][ $job ] ?? null ); + + return $definition['jobs'][ $job ]; + } + + /** + * @param array $job + * + * @return array + */ + protected static function step( array $job, string $name ): array { + self::assertIsArray( $job['steps'] ?? null ); + foreach ( $job['steps'] as $step ) { + if ( \is_array( $step ) && ( $step['name'] ?? null ) === $name ) { + return $step; + } + } + + self::fail( "No step named '$name'." ); + } } diff --git a/tests/Unit/PlaywrightWorkflowTest.php b/tests/Unit/PlaywrightWorkflowTest.php new file mode 100644 index 0000000..095c6ba --- /dev/null +++ b/tests/Unit/PlaywrightWorkflowTest.php @@ -0,0 +1,27 @@ +write_project( array( 'node_modules/@playwright/test/package.json' => '{"name": "@playwright/test", "version": "1.64.1"}' ) ); + + $result = $this->run_step( 'reusable-playwright-e2e.yml', 'e2e', 'Resolve Playwright version', array() ); + + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertSame( array( 'version' => '1.64.1' ), $result['outputs'] ); + } + + public function test_a_project_without_playwright_fails_with_a_named_error(): void { + $result = $this->run_step( 'reusable-playwright-e2e.yml', 'e2e', 'Resolve Playwright version', array() ); + + self::assertSame( 1, $result['status'], $result['output'] ); + self::assertMatchesRegularExpression( '/^::error::@playwright\/test is not installed/m', $result['output'] ); + self::assertSame( array(), $result['outputs'] ); + } +} diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index 09b4150..c769310 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -96,6 +96,39 @@ public function test_versions_must_agree( string $tag_version, string $wp_org, ? } } + /** + * @return array + */ + public static function provide_prerelease_tags(): array { + return array( + 'prerelease-tag-matches-the-header' => array( '1.3.0-beta.1', 0 ), + 'prerelease-tag-differs' => array( '1.3.0-beta.2', 1 ), + ); + } + + #[DataProvider( 'provide_prerelease_tags' )] + public function test_a_prerelease_leaves_the_stable_tag_at_the_last_stable_release( string $tag_version, int $expected_status ): void { + $this->write_project( + array( + 'main.php' => self::plugin_header( array( 'Version' => '1.3.0-beta.1' ) ), + 'readme.txt' => "=== Acme Plugin ===\nStable tag: 1.2.3\n", + ) + ); + + $result = $this->run_build_step( + 'Verify version consistency', + array( + 'TAG_VERSION' => $tag_version, + 'WP_ORG' => 'true', + ) + ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( 0 === $expected_status ) { + self::assertSame( '1.3.0-beta.1', $result['outputs']['version'] ?? null ); + } + } + /** * @return array */ @@ -151,15 +184,50 @@ public function test_pot_must_cover_the_scoped_strings( string $pot, int $expect * @return array, array>, int, string}> */ public static function provide_header_floors(): array { - $framework = array( + $framework = array( 'require' => array( 'php' => '>=8.5' ), 'extra' => array( 'requires-wp' => '7.1', 'requires-plugins' => array( 'woocommerce' ), ), ); + $woocommerce = array( + 'extra' => array( + 'requires-wc' => '11.2', + 'requires-plugins' => array( 'woocommerce' ), + ), + ); return array( + 'woocommerce-header-covers-a-package' => array( + array( + 'Requires PHP' => '8.5', + 'Requires Plugins' => 'woocommerce', + 'WC requires at least' => '11.2', + ), + array( 'acme/commerce' => $woocommerce ), + 0, + '', + ), + 'woocommerce-header-below-a-package' => array( + array( + 'Requires PHP' => '8.5', + 'Requires Plugins' => 'woocommerce', + 'WC requires at least' => '11.1', + ), + array( 'acme/commerce' => $woocommerce ), + 1, + 'acme/commerce', + ), + 'woocommerce-header-missing' => array( + array( + 'Requires PHP' => '8.5', + 'Requires Plugins' => 'woocommerce', + ), + array( 'acme/commerce' => $woocommerce ), + 1, + 'acme/commerce', + ), 'headers-cover-every-package' => array( array( 'Requires PHP' => '8.5', @@ -234,6 +302,7 @@ public static function provide_header_floors(): array { public function test_headers_must_cover_the_shipped_packages( array $headers, array $scoped, int $expected_status, string $named ): void { $files = array( 'main.php' => self::plugin_header( $headers ), + 'composer.json' => '{}', 'vendor/composer/installed.json' => (string) \json_encode( array( 'packages' => array( @@ -265,6 +334,41 @@ public function test_headers_must_cover_the_shipped_packages( array $headers, ar } } + /** + * @return array + */ + public static function provide_vendor_directories(): array { + return array( + 'default-vendor-directory' => array( '{}', 'vendor' ), + 'custom-vendor-directory' => array( '{"config": {"vendor-dir": "lib/deps"}}', 'lib/deps' ), + ); + } + + #[DataProvider( 'provide_vendor_directories' )] + public function test_headers_must_cover_the_packages_in_composer_s_vendor_directory( string $manifest, string $vendor_dir ): void { + $this->write_project( + array( + 'main.php' => self::plugin_header( array( 'Requires PHP' => '8.5' ) ), + 'composer.json' => $manifest, + "$vendor_dir/composer/installed.json" => (string) \json_encode( + array( + 'packages' => array( + array( + 'name' => 'acme/runtime', + 'require' => array( 'php' => '>=8.6' ), + ), + ), + ) + ), + ) + ); + + $result = $this->run_build_step( 'Verify the headers cover the shipped packages', array() ); + + self::assertSame( 1, $result['status'], $result['output'] ); + self::assertMatchesRegularExpression( '/^::error::.*acme\/runtime/m', $result['output'] ); + } + /** * @return array */ @@ -302,15 +406,26 @@ public function test_headers_must_cover_the_project_s_own_floor( string $require */ public static function provide_archive_entries(): array { return array( - 'plugin-files-only' => array( 'src/Plugin.php', 0 ), - '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 ), - 'node-modules' => array( 'node_modules/x', 1 ), - 'secret-env-file' => array( '.env', 1 ), - 'nested-auth-json' => array( 'nested/auth.json', 1 ), - 'bundled-certificate' => array( 'vendor/acme/ca.pem', 0 ), + 'plugin-files-only' => array( 'src/Plugin.php', 0 ), + 'changelog-file' => array( 'CHANGELOG.md', 0 ), + 'tests-ruleset' => array( 'phpcs.tests.dist.xml', 1 ), + 'hidden-tests-ruleset' => array( '.phpcs.tests.xml', 1 ), + 'dist-suffixed-ruleset' => array( 'phpcs.xml.dist', 1 ), + 'profile-phpstan-config' => array( 'phpstan.bootstrap.neon', 1 ), + 'hidden-dist-phpstan-config' => array( '.phpstan.neon.dist', 1 ), + 'dist-named-phpunit' => array( 'phpunit.dist.xml', 1 ), + 'phpunit-result-cache' => array( '.phpunit.result.cache', 1 ), + 'phpunit-cache-directory' => array( '.phpunit.cache/test-results', 1 ), + 'agent-instructions' => array( 'AGENTS.md', 1 ), + 'agent-instructions-link' => array( 'CLAUDE.md', 1 ), + 'changelog-fragment' => array( 'changelog/fix-the-thing', 1 ), + 'node-modules' => array( 'node_modules/x', 1 ), + 'secret-env-file' => array( '.env', 1 ), + 'nested-auth-json' => array( 'nested/auth.json', 1 ), + 'bundled-certificate' => array( 'vendor/acme/ca.pem', 0 ), + 'bundled-scoped-certificate' => array( 'vendor-prefixed/acme/ca.pem', 0 ), + 'private-key-under-vendor' => array( 'vendor/acme/private.key', 1 ), + 'private-key-under-scoped' => array( 'vendor-prefixed/acme/private.key', 1 ), ); } @@ -327,13 +442,16 @@ public function test_archive_must_not_ship_development_files( string $entry, int * @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' ); + $autoloader = 'vendor-prefixed/scoper-autoload.php'; + $client = 'vendor-prefixed/acme/library/src/Client.php'; + $server = 'vendor-prefixed/acme/library/src/Server.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' ), + 'every-scoped-file-shipped' => array( array( 'acme-plugin.php', $autoloader, $client, $server ), true, 0, '' ), + 'autoloader-missing' => array( array( 'acme-plugin.php', $client, $server ), true, 1, 'scoper-autoload.php' ), + 'scoped-file-missing' => array( array( 'acme-plugin.php', $autoloader, $client ), true, 1, $server ), + 'scoped-package-missing' => array( array( 'acme-plugin.php', $autoloader ), true, 1, $client ), + 'scoping-never-ran' => array( array( 'acme-plugin.php', $autoloader, $client, $server ), false, 1, 'did not run' ), ); } @@ -341,10 +459,19 @@ public static function provide_scoped_archives(): array { * @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 { + public function test_the_archive_must_ship_every_scoped_php_file( array $archived, bool $scoped, int $expected_status, string $named ): void { $this->write_archive( $archived ); if ( $scoped ) { - $this->write_project( array( 'vendor-prefixed/acme/library/composer.json' => '{"name": "acme/library"}' ) ); + // A distribution may leave a scoped package's metadata out, but never its PHP. + $this->write_project( + array( + 'vendor-prefixed/scoper-autoload.php' => " '{"name": "acme/library"}', + 'vendor-prefixed/acme/library/README.md' => "# acme/library\n", + 'vendor-prefixed/acme/library/src/Client.php' => " "run_build_step( 'Assert scoped packages bundled', array() ); @@ -414,13 +541,18 @@ public function test_a_release_needs_a_successful_push_run_of_every_required_wor */ public static function provide_release_inputs(): array { return array( - 'version-tag' => array( 'tag', 'v1.2.3', '.', 'true', 'acme-plugin', 0, '1.2.3' ), - 'tag-with-a-leading-zero' => array( 'tag', 'v1.02.3', '.', 'true', 'acme-plugin', 1, 'no leading zeros' ), - 'branch-with-publish' => array( 'branch', 'trunk', '.', 'true', 'acme-plugin', 1, 'is not a tag' ), - 'branch-without-publish' => array( 'branch', 'trunk', 'plugins/acme-plugin', 'false', 'acme-plugin', 0, '' ), - 'dot-dot-path-segment' => array( 'tag', 'v1.2.3', 'plugins/../acme-plugin', 'true', 'acme-plugin', 1, 'must not contain a .. path segment' ), - 'absolute-path' => array( 'tag', 'v1.2.3', '/tmp/acme-plugin', 'true', 'acme-plugin', 1, 'must be relative' ), - 'slug-out-of-pattern' => array( 'tag', 'v1.2.3', '.', 'true', 'Acme_Plugin', 1, 'plugin-slug must match' ), + 'version-tag' => array( 'tag', 'v1.2.3', '.', 'true', 'acme-plugin', 0, '1.2.3' ), + 'tag-with-a-leading-zero' => array( 'tag', 'v1.02.3', '.', 'true', 'acme-plugin', 1, 'no leading zeros' ), + 'prerelease-tag' => array( 'tag', 'v1.3.0-beta.1', '.', 'true', 'acme-plugin', 0, '1.3.0-beta.1' ), + 'prerelease-tag-without-a-number' => array( 'tag', 'v1.3.0-rc', '.', 'true', 'acme-plugin', 0, '1.3.0-rc' ), + 'prerelease-label-outside-the-set' => array( 'tag', 'v1.3.0-pre.1', '.', 'true', 'acme-plugin', 1, 'Releases run from version tags only' ), + 'uppercase-prerelease-label' => array( 'tag', 'v1.3.0-RC.1', '.', 'true', 'acme-plugin', 1, 'Releases run from version tags only' ), + 'prerelease-number-with-a-leading-zero' => array( 'tag', 'v1.3.0-beta.01', '.', 'true', 'acme-plugin', 1, 'no leading zeros' ), + 'branch-with-publish' => array( 'branch', 'trunk', '.', 'true', 'acme-plugin', 1, 'is not a tag' ), + 'branch-without-publish' => array( 'branch', 'trunk', 'plugins/acme-plugin', 'false', 'acme-plugin', 0, '' ), + 'dot-dot-path-segment' => array( 'tag', 'v1.2.3', 'plugins/../acme-plugin', 'true', 'acme-plugin', 1, 'must not contain a .. path segment' ), + 'absolute-path' => array( 'tag', 'v1.2.3', '/tmp/acme-plugin', 'true', 'acme-plugin', 1, 'must be relative' ), + 'slug-out-of-pattern' => array( 'tag', 'v1.2.3', '.', 'true', 'Acme_Plugin', 1, 'plugin-slug must match' ), ); } @@ -446,25 +578,55 @@ public function test_a_release_runs_from_a_version_tag_with_a_safe_slug_and_path } /** - * @return array + * @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, '' ), + '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", '1.2.0', 0, "\n- Fixed a thing.\n" ), + 'bracketed-section' => array( "## [1.2.0] - 2026-10-29\n\n- Fixed a thing.\n", '1.2.0', 0, "\n- Fixed a thing." ), + 'prerelease-section' => array( "## 1.3.0-beta.1 - 2026-11-06\n\n- A beta thing.\n\n## 1.2.0 - 2026-10-29\n\n- Fixed a thing.\n", '1.3.0-beta.1', 0, "\n- A beta thing.\n" ), + 'dots-are-not-wildcards' => array( "## 1a2a0 - 2026-10-29\n\n- Not this version.\n", '1.2.0', 1, '' ), + 'no-section-for-the-version' => array( "## 1.1.0 - 2026-10-01\n\n- An older thing.\n", '1.2.0', 1, '' ), + 'blank-section' => array( "## 1.2.0 - 2026-10-29\n\n \n\t\n## 1.1.0 - 2026-10-01\n\n- An older thing.\n", '1.2.0', 1, '' ), + 'no-changelog' => array( null, '1.2.0', 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( 'bin/gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\n" ); + public function test_the_build_takes_the_release_notes_from_the_version_s_changelog_section( ?string $changelog, string $version, int $expected_status, string $notes ): void { if ( ! \is_null( $changelog ) ) { $this->write_project( array( 'CHANGELOG.md' => $changelog ) ); } + $result = $this->run_build_step( 'Verify the changelog section', array( 'VERSION' => $version ) ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( 1 === $expected_status ) { + self::assertMatchesRegularExpression( '/^::error::.*no section for ' . \preg_quote( $version, '/' ) . '/m', $result['output'] ); + return; + } + + self::assertSame( $notes, $result['outputs']['release-notes'] ?? '' ); + } + + /** + * @return array, list}> + */ + public static function provide_release_notes(): array { + return array( + 'changelog-notes' => array( '1.2.0', "\n- Fixed a thing.", array( '--notes-file' ), array( '--generate-notes', '--prerelease' ) ), + 'generated-notes' => array( '1.2.0', '', array( '--generate-notes' ), array( '--notes-file', '--prerelease' ) ), + 'prerelease' => array( '1.3.0-beta.1', "\n- A beta thing.", array( '--notes-file', '--prerelease' ), array( '--generate-notes' ) ), + ); + } + + /** + * @param list $passed + * @param list $absent + */ + #[DataProvider( 'provide_release_notes' )] + public function test_the_release_publishes_the_build_s_notes( string $version, string $notes, array $passed, array $absent ): void { + $this->write_executable( 'bin/gh', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/gh-arguments\"\n" ); \mkdir( $this->work_dir . '/runner-temp' ); $result = $this->run_step( @@ -473,33 +635,39 @@ public function test_the_release_notes_are_the_version_s_changelog_section( ?str 'Create the GitHub release', array( 'GH_TOKEN' => 'token', - 'CHANGELOG' => 'CHANGELOG.md', - 'VERSION' => '1.2.0', + 'RELEASE_NOTES' => $notes, + 'VERSION' => $version, 'RUNNER_TEMP' => $this->work_dir . '/runner-temp', 'GITHUB_REPOSITORY' => 'acme/acme-plugin', - 'GITHUB_REF_NAME' => 'v1.2.0', + 'GITHUB_REF_NAME' => "v$version", '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; - } - + self::assertSame( 0, $result['status'], $result['output'] ); $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' ) ); + self::assertSame( array( 'release', 'create', "v$version", './dist/acme-plugin.zip' ), \array_slice( $arguments, 0, 4 ) ); + foreach ( $passed as $argument ) { + self::assertContains( $argument, $arguments ); + } + + foreach ( $absent as $argument ) { + self::assertNotContains( $argument, $arguments ); + } + + if ( '' !== $notes ) { + self::assertSame( "$notes\n", \file_get_contents( $this->work_dir . '/runner-temp/release-notes.md' ) ); } } + public function test_the_wp_org_deploy_refuses_a_prerelease_tag(): void { + $result = $this->run_step( 'reusable-release.yml', 'wp-org', 'Re-verify version tag before deploy', array( 'REF_NAME' => 'v1.3.0-beta.1' ) ); + + self::assertSame( 1, $result['status'], $result['output'] ); + self::assertMatchesRegularExpression( '/^::error::Refusing to deploy/m', $result['output'] ); + } + public function test_required_files_are_found_in_a_large_archive(): void { $files = array( 'main.php' ); for ( $i = 0; $i < 2500; $i++ ) { @@ -590,8 +758,60 @@ public function test_wp_env_gets_a_config_flag_only_for_a_named_file( string $co self::assertSame( "$expected_arguments\n", \file_get_contents( $this->work_dir . '/project/wp-env-arguments' ) ); } - public function test_artifact_redirect_defaults_to_the_standard_config(): void { - $this->write_project( array( '.wp-env.json' => (string) \json_encode( array( 'mappings' => array( 'wp-content/plugins/acme-plugin' => '.' ) ) ) ) ); + /** + * @return array|null, array}> + */ + public static function provide_artifact_redirects(): array { + $built = array( 'wp-content/plugins/acme-plugin' => '/built/acme-plugin' ); + $redirect = array( + 'core' => null, + 'mappings' => $built, + ); + + return array( + 'standard-config' => array( + '', + null, + $redirect + array( + 'env' => array( + 'development' => $redirect, + 'tests' => $redirect, + ), + ), + ), + 'named-config-with-an-override' => array( + '.wp-env.tests.json', + array( + 'config' => array( 'WP_DEBUG' => true ), + 'mappings' => array( 'wp-content/plugins/woocommerce' => './woocommerce' ), + 'env' => array( 'tests' => array( 'port' => 8899 ) ), + ), + array( + 'core' => null, + 'config' => array( 'WP_DEBUG' => true ), + 'mappings' => array( 'wp-content/plugins/woocommerce' => './woocommerce' ) + $built, + 'env' => array( + 'development' => $redirect, + 'tests' => array( 'port' => 8899 ) + $redirect, + ), + ), + ), + ); + } + + /** + * @param array|null $override + * @param array $expected + */ + #[DataProvider( 'provide_artifact_redirects' )] + public function test_the_artifact_redirect_joins_the_config_s_override( string $config_file, ?array $override, array $expected ): void { + $config = '' === $config_file ? '.wp-env.json' : $config_file; + $files = array( $config => (string) \json_encode( array( 'mappings' => array( 'wp-content/plugins/acme-plugin' => '.' ) ) ) ); + if ( ! \is_null( $override ) ) { + $files[ \substr( $config, 0, -5 ) . '.override.json' ] = (string) \json_encode( $override ); + } + + $this->write_project( $files ); $result = $this->run_step( 'reusable-release.yml', @@ -599,42 +819,34 @@ public function test_artifact_redirect_defaults_to_the_standard_config(): void { 'Redirect wp-env at built artifact', array( 'PLUGIN_SLUG' => 'acme-plugin', - 'WP_ENV_CONFIG_FILE' => '', + 'WP_ENV_CONFIG_FILE' => $config_file, 'BUILT_PATH' => '/built/acme-plugin', ) ); - $mapping = array( 'wp-content/plugins/acme-plugin' => '/built/acme-plugin' ); self::assertSame( 0, $result['status'], $result['output'] ); - self::assertJsonStringEqualsJsonString( - (string) \json_encode( - array( - 'core' => null, - 'mappings' => $mapping, - 'env' => array( - 'development' => array( 'mappings' => $mapping ), - 'tests' => array( 'mappings' => $mapping ), - ), - ) - ), - (string) \file_get_contents( $this->work_dir . '/project/.wp-env.override.json' ) - ); + self::assertJsonStringEqualsJsonString( (string) \json_encode( $expected ), (string) \file_get_contents( $this->work_dir . '/project/' . \substr( $config, 0, -5 ) . '.override.json' ) ); } /** - * @return array + * @return array */ public static function provide_wp_env_ports(): array { return array( - 'development-port' => array( '{ "port": 8811, "env": { "development": { "port": 8822 } } }', '8822' ), - 'root-port' => array( '{ "port": 8811 }', '8811' ), - 'no-port' => array( '{}', '8888' ), + 'development-port' => array( '{ "port": 8811, "env": { "development": { "port": 8822 } } }', null, '8822' ), + 'root-port' => array( '{ "port": 8811 }', null, '8811' ), + 'no-port' => array( '{}', null, '8888' ), + 'override-development-port' => array( '{ "port": 8811 }', '{ "env": { "development": { "port": 8833 } } }', '8833' ), + 'override-root-port' => array( '{ "port": 8811 }', '{ "port": 8844 }', '8844' ), ); } #[DataProvider( 'provide_wp_env_ports' )] - public function test_the_site_is_requested_on_the_config_s_development_port( string $config, string $port ): void { + public function test_the_site_is_requested_on_the_effective_development_port( string $config, ?string $override, string $port ): void { $this->write_project( array( '.wp-env.json' => $config ) ); + if ( ! \is_null( $override ) ) { + $this->write_project( array( '.wp-env.override.json' => $override ) ); + } $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/curl-arguments\"\n" ); $result = $this->run_step( 'reusable-release.yml', 'test', 'Assert the site serves', array( 'WP_ENV_CONFIG_FILE' => '' ) ); diff --git a/tests/Unit/SupplyChainAuditWorkflowTest.php b/tests/Unit/SupplyChainAuditWorkflowTest.php index a555568..bf90ffc 100644 --- a/tests/Unit/SupplyChainAuditWorkflowTest.php +++ b/tests/Unit/SupplyChainAuditWorkflowTest.php @@ -48,6 +48,56 @@ public function test_failing_mode_fails_on_advisories(): void { self::assertNotSame( 0, $result['status'], $result['output'] ); } + public function test_advisory_mode_reports_a_package_in_its_cooldown_without_failing(): void { + $result = $this->audit( 'cooldown.json', 'false' ); + + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertMatchesRegularExpression( '/^::warning::/m', $result['output'] ); + } + + public function test_failing_mode_fails_on_a_package_in_its_cooldown_as_a_finding(): void { + $result = $this->audit( 'cooldown.json', 'true' ); + + self::assertNotSame( 0, $result['status'], $result['output'] ); + self::assertDoesNotMatchRegularExpression( '/^::error::/m', $result['output'] ); + } + + /** + * @return array + */ + public static function provide_npm_reports(): array { + $findings = '{"metadata": {"vulnerabilities": {"info": 0, "low": 1, "moderate": 0, "high": 0, "critical": 0, "total": 1}}}'; + + return array( + 'clean-audit' => array( '{"metadata": {"vulnerabilities": {"total": 0}}}', 0, 'false', 0, '' ), + 'findings-in-advisory-mode' => array( $findings, 1, 'false', 0, '::warning::npm audit reported findings' ), + 'findings-in-failing-mode' => array( $findings, 1, 'true', 1, '' ), + 'error-report-in-advisory-mode' => array( '{"error": {"code": "ENOLOCK"}}', 1, 'false', 1, '::error::npm audit failed before producing a valid findings report.' ), + 'unreadable-report' => array( 'not json', 1, 'false', 1, '::error::npm audit failed before producing a valid findings report.' ), + 'no-findings-but-a-failure' => array( '{"metadata": {"vulnerabilities": {"total": 0}}}', 1, 'false', 1, '::error::npm audit failed before producing a valid findings report.' ), + ); + } + + #[DataProvider( 'provide_npm_reports' )] + public function test_npm_audit_softens_only_a_report_that_holds_findings( string $report, int $npm_status, string $fail_on_findings, int $expected_status, string $annotation ): void { + $this->write_executable( 'bin/npm', "#!/usr/bin/env bash\nprintf '%s\\n' " . \escapeshellarg( $report ) . "\nexit $npm_status\n" ); + + $result = $this->run_step( + 'reusable-supply-chain-audit.yml', + 'npm-audit', + 'npm audit', + array( + 'AUDIT_FLAGS' => '--omit=dev --audit-level=high', + 'FAIL_ON_FINDINGS' => $fail_on_findings, + ) + ); + + self::assertSame( $expected_status, $result['status'], $result['output'] ); + if ( '' !== $annotation ) { + self::assertStringContainsString( $annotation, $result['output'] ); + } + } + /** * @return array{status: int, output: string, outputs: array, env: array} */ diff --git a/tests/Unit/WorkflowChecksWorkflowTest.php b/tests/Unit/WorkflowChecksWorkflowTest.php new file mode 100644 index 0000000..c7ab609 --- /dev/null +++ b/tests/Unit/WorkflowChecksWorkflowTest.php @@ -0,0 +1,59 @@ + + */ + public static function provide_extra_workflows(): array { + return array( + 'none' => array( '[]', '' ), + 'one-glob' => array( '["plugins/*/.github/workflows/*.yml"]', '.github/workflows/*.yml,.github/workflows/*.yaml,plugins/*/.github/workflows/*.yml' ), + 'two-globs' => array( '["plugins/*/.github/workflows/*.yml", "tools/ci/*.yaml"]', '.github/workflows/*.yml,.github/workflows/*.yaml,plugins/*/.github/workflows/*.yml,tools/ci/*.yaml' ), + ); + } + + #[DataProvider( 'provide_extra_workflows' )] + public function test_actionlint_lints_the_extra_workflows_beside_the_repository_s_own( string $extra_workflows, string $files ): void { + $result = $this->resolve( $extra_workflows ); + + self::assertSame( 0, $result['status'], $result['output'] ); + self::assertSame( array( 'files' => $files ), $result['outputs'] ); + } + + /** + * @return array + */ + public static function provide_malformed_extra_workflows(): array { + return array( + 'not-json' => array( 'plugins/*/.github/workflows/*.yml' ), + 'a-string' => array( '"plugins/*/.github/workflows/*.yml"' ), + 'a-number-entry' => array( '[1]' ), + 'an-empty-entry' => array( '[""]' ), + 'a-comma-in-glob' => array( '["plugins/a/x.yml,plugins/b/x.yml"]' ), + ); + } + + #[DataProvider( 'provide_malformed_extra_workflows' )] + public function test_a_malformed_list_fails_the_job_and_names_the_input( string $extra_workflows ): void { + $result = $this->resolve( $extra_workflows ); + + self::assertNotSame( 0, $result['status'], $result['output'] ); + self::assertMatchesRegularExpression( '/^::error::extra-workflows /m', $result['output'] ); + self::assertSame( array(), $result['outputs'] ); + } + + /** + * @return array{status: int, output: string, outputs: array, env: array} + */ + protected function resolve( string $extra_workflows ): array { + return $this->run_step( 'reusable-workflow-checks.yml', 'actionlint', 'Resolve the actionlint files', array( 'EXTRA_WORKFLOWS' => $extra_workflows ) ); + } +} diff --git a/tests/fixtures/composer-audit/cooldown.json b/tests/fixtures/composer-audit/cooldown.json new file mode 100644 index 0000000..84ed9a4 --- /dev/null +++ b/tests/fixtures/composer-audit/cooldown.json @@ -0,0 +1,13 @@ +{ + "advisories": [], + "abandoned": [], + "filter": [], + "cooldown": { + "acme/fresh": { + "prettyVersion": "1.4.0", + "releaseDate": "2026-11-04T13:21:14+00:00", + "availableIn": "5 days 2 hours", + "source": "published-time" + } + } +} From f624ee7af364d746d140ce6839f741d2b7411d67 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:28:17 +0100 Subject: [PATCH 03/23] Release prerelease tags as GitHub prereleases The bootstrap updater has a prerelease channel that scans a plugin's newest releases for dev, alpha, beta and rc versions, but the release workflow refused every tag except vMAJOR.MINOR.PATCH, so nothing could produce one. A tag may add -dev, -alpha, -beta or -rc and an optional .N. Its version must still equal the main file's Version header, but not readme.txt's Stable tag, which keeps naming the last stable release. The release is created with --prerelease, which GitHub's latest release skips, and the wp.org deploy never runs for it. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 14 ++++++++++---- docs/scripts-contract.md | 2 +- docs/workflows.md | 8 +++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 48420a1..6bc71a2 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -115,8 +115,8 @@ jobs: tag_version='' if [ "$REF_TYPE" = "tag" ]; then - if ! printf '%s' "$REF_NAME" | grep -Eq '^v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}$'; then - echo "::error::Releases run from version tags only (vMAJOR.MINOR.PATCH, no leading zeros) — got '$REF_NAME'." + if ! printf '%s' "$REF_NAME" | grep -Eq '^v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}(-(dev|alpha|beta|rc)(\.(0|[1-9][0-9]*))?)?$'; then + echo "::error::Releases run from version tags only (vMAJOR.MINOR.PATCH, or a prerelease such as vMAJOR.MINOR.PATCH-beta.N with a dev, alpha, beta or rc label, no leading zeros) — got '$REF_NAME'." exit 1 fi tag_version="${REF_NAME#v}" @@ -191,9 +191,10 @@ jobs: echo "::error::A wp.org release needs readme.txt, whose Stable tag must equal the release version." exit 1 fi + # A prerelease leaves the Stable tag at the last stable release, which wp.org keeps offering. if [ -z "$header_version" ] \ || { [ -n "$TAG_VERSION" ] && [ "$TAG_VERSION" != "$header_version" ]; } \ - || { [ -f readme.txt ] && [ "$stable_tag" != "$header_version" ]; }; then + || { [ -f readme.txt ] && [[ "$header_version" != *-* ]] && [ "$stable_tag" != "$header_version" ]; }; then echo "::error::Release versions disagree — tag: '${TAG_VERSION:-none}', $ENTRY_FILE Version header: '$header_version', readme.txt Stable tag: '${stable_tag:-none}'." exit 1 fi @@ -758,6 +759,10 @@ jobs: VERSION: ${{ needs.build.outputs.version }} run: | args=(--repo "$GITHUB_REPOSITORY" --verify-tag) + # GitHub's latest release, which the stable update channel reads, skips prereleases. + if [[ "$VERSION" == *-* ]]; then + args+=(--prerelease) + fi if [ -f "$CHANGELOG" ]; then # index() matches the heading as text; a regex would read the version's dots as wildcards. awk -v v="$VERSION" ' @@ -778,7 +783,8 @@ jobs: wp-org: name: Deploy to wp.org needs: [build, test, provenance, release] - if: ${{ inputs.publish && inputs.wp-org }} + # wp.org offers sites only its Stable tag, so a prerelease ships through GitHub alone. + if: ${{ inputs.publish && inputs.wp-org && !contains(needs.build.outputs.version, '-') }} runs-on: ubuntu-latest timeout-minutes: 30 environment: wp-org-release diff --git a/docs/scripts-contract.md b/docs/scripts-contract.md index 6b1147e..df7a91b 100644 --- a/docs/scripts-contract.md +++ b/docs/scripts-contract.md @@ -102,7 +102,7 @@ A project with a lower minimum WordPress version sets `WPCompat.requiresAtLeast` | File | Requirement | | --- | --- | | The main plugin file | The one PHP file at the project root with a `Plugin Name` header in its first 8 KB, as WordPress finds it. Its `Version` header equals the tag, its `Text Domain` equals `plugin-slug`, and its `Requires PHP`, `Requires at least` and `Requires Plugins` cover what the shipped packages need. | -| `readme.txt` | Required with `wp-org: true`. When present, its `Stable tag` equals the release version. | +| `readme.txt` | Required with `wp-org: true`. When present, its `Stable tag` equals the release version, or, for a prerelease, the last stable release. | | `CHANGELOG.md` | Optional. When present, it has a section headed by the version, such as `## 1.2.3 - 2026-10-06` or `## [1.2.3] - 2026-10-06`, which becomes the release notes; a missing section fails the release. | | `.distignore` | Required; `wp dist-archive` builds the zip from it. Copy this package's baseline and extend it. | | `package.json` and `package-lock.json` | Declare a Node version and `@wordpress/env`. | diff --git a/docs/workflows.md b/docs/workflows.md index 0eaa057..8c655e1 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -218,7 +218,7 @@ The workflow runs five jobs: - `test` (`contents: read`, no secrets) mounts the extracted zip in the project's own wp-env, activates it with `wp plugin activate`, requests the home page, and runs `npm run test:e2e` when the project defines it, after installing Chromium when `@playwright/test` is installed. - `provenance` (`actions: read`) requires a successful push run of every file in `required-workflows` on the released commit, so a release reuses the suites proven there; tag a commit once they pass. - `release` (`contents: write`) verifies the zip's digest and creates the GitHub release with the zip attached. Its notes are the version's section of `CHANGELOG.md` when that file exists, and the job fails when that section is missing; without the file, the notes are GitHub's generated notes. -- `wp-org` runs only with `wp-org: true`, after `release`. It re-checks that the ref is a version tag, then deploys the same zip and the `.wordpress-org` assets to wp.org SVN from the `wp-org-release` environment, the only job that holds the SVN credentials. +- `wp-org` runs only with `wp-org: true`, after `release`, and never for a prerelease. It re-checks that the ref is a stable version tag, then deploys the same zip and the `.wordpress-org` assets to wp.org SVN from the `wp-org-release` environment, the only job that holds the SVN credentials. The `release` and `wp-org` jobs install no Composer or npm dependencies and run no tests, and `wp-org` checks nothing out; the deploy action installs Subversion when the runner lacks it. Pinning the workflow by commit SHA keeps a changed privileged job from reaching a consumer without a reviewed pin bump. @@ -226,10 +226,10 @@ The `release` and `wp-org` jobs install no Composer or npm dependencies and run The `build` job fails when: -- the tag is not `v..` (no leading zeros), or `publish` is on without a tag; +- the tag is not `v..`, or a prerelease that adds `-dev`, `-alpha`, `-beta` or `-rc` and an optional `.`, such as `v1.3.0-beta.1` (no leading zeros), or `publish` is on without a tag; - the plugin does not require `ahegyes/wordpress-configs` under `require-dev`, so its development install holds no `php/release/composer.lock` to install WP-CLI from; - the project root holds no PHP file with a `Plugin Name:` header in its first 8 KB, or more than one; -- the main plugin 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 main plugin file's `Version:` header differs from the tag, or, for a stable version, from the `Stable tag:` of `readme.txt` when that file exists; a wp.org release requires `readme.txt`; - the main plugin 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 project's own `composer.json`, the production packages in `vendor/` and the scoped packages in `vendor-prefixed/`; @@ -239,6 +239,8 @@ The `build` job fails when: A strict `>` lower bound in a package's PHP constraint is read as inclusive. The version is taken from the tag and verified against the files, never stamped in. +A prerelease tag publishes a GitHub prerelease, which GitHub's latest release skips, so only sites on a prerelease update channel are offered it. It never deploys to wp.org, which offers sites only the `Stable tag:` of `readme.txt`, so that header keeps naming the last stable release. + The artifact test needs the wp-env config to map `wp-content/plugins/` to the plugin source: it remaps exactly that `mappings` key at the built zip, so co-mounted plugins, the port and lifecycle scripts survive the per-key merge, and pins the environment to the latest stable WordPress core. `plugin-check` runs the GitHub profile, which skips `plugin_updater` and `plugin_readme` as `reusable-plugin-check.yml` does, unless `wp-org` is on, and `exclude-checks` skips further checks as that workflow's input does; a malformed list fails the build before anything installs. From 1401692738ce38bd0fbe17c434e095a68bb07ba6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:29:39 +0100 Subject: [PATCH 04/23] Check the changelog section in the release build The release job alone looked up the version's CHANGELOG.md section, so a dry run passed what the tagged run then failed, and a section holding only blank lines published an empty description. The build extracts the section beside the version check, fails when it is missing or blank, and hands it to the release job as the multi-line release-notes output, so the tagged run publishes exactly the notes the dry run checked. The release job then reads nothing from the tree and drops its checkout. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 45 +++++++++++++++++--------- docs/scripts-contract.md | 2 +- docs/workflows.md | 5 +-- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 6bc71a2..be35720 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -75,6 +75,7 @@ jobs: WP_ORG: ${{ inputs.wp-org }} outputs: digest: ${{ steps.archive-digest.outputs.digest }} + release-notes: ${{ steps.changelog.outputs.release-notes }} requires-scoped: ${{ steps.scoping.outputs.requires-scoped }} version: ${{ steps.versions.outputs.version }} steps: @@ -200,6 +201,30 @@ jobs: fi echo "version=$header_version" >> "$GITHUB_OUTPUT" + # The notes reach the release job as this output, so a dry run checks the notes a tagged run publishes. + - name: Verify the changelog section + id: changelog + working-directory: ${{ inputs.project-path }} + env: + VERSION: ${{ steps.versions.outputs.version }} + run: | + if [ ! -f CHANGELOG.md ]; then + exit 0 + fi + notes=$(mktemp) + # index() matches the heading as text; a regex would read the version's dots as wildcards. + awk -v v="$VERSION" ' + $0 == "## " v || index($0, "## " v " ") == 1 || index($0, "## [" v "]") == 1 { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' CHANGELOG.md > "$notes" + if ! grep -q '[^[:space:]]' "$notes"; then + echo "::error::CHANGELOG.md has no section for $VERSION, or only a blank one, so the release would describe nothing." + exit 1 + fi + delimiter="notes-$(openssl rand -hex 16)" + { echo "release-notes<<$delimiter"; cat "$notes"; echo "$delimiter"; } >> "$GITHUB_OUTPUT" + - name: Verify the text domain working-directory: ${{ inputs.project-path }} run: | @@ -732,10 +757,6 @@ jobs: env: PLUGIN_SLUG: ${{ inputs.plugin-slug }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Download built artifact uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -755,7 +776,7 @@ jobs: - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} - CHANGELOG: ${{ inputs.project-path }}/CHANGELOG.md + RELEASE_NOTES: ${{ needs.build.outputs.release-notes }} VERSION: ${{ needs.build.outputs.version }} run: | args=(--repo "$GITHUB_REPOSITORY" --verify-tag) @@ -763,17 +784,9 @@ jobs: if [[ "$VERSION" == *-* ]]; then args+=(--prerelease) fi - if [ -f "$CHANGELOG" ]; then - # index() matches the heading as text; a regex would read the version's dots as wildcards. - awk -v v="$VERSION" ' - $0 == "## " v || index($0, "## " v " ") == 1 || index($0, "## [" v "]") == 1 { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' "$CHANGELOG" > "$RUNNER_TEMP/release-notes.md" - if [ ! -s "$RUNNER_TEMP/release-notes.md" ]; then - echo "::error::$CHANGELOG has no section for $VERSION, so the release would describe nothing." - exit 1 - fi + # The build fails on a CHANGELOG.md without the version's section, so empty notes mean the project keeps none. + if [ -n "$RELEASE_NOTES" ]; then + printf '%s\n' "$RELEASE_NOTES" > "$RUNNER_TEMP/release-notes.md" args+=(--notes-file "$RUNNER_TEMP/release-notes.md") else args+=(--generate-notes) diff --git a/docs/scripts-contract.md b/docs/scripts-contract.md index df7a91b..c63413e 100644 --- a/docs/scripts-contract.md +++ b/docs/scripts-contract.md @@ -103,7 +103,7 @@ A project with a lower minimum WordPress version sets `WPCompat.requiresAtLeast` | --- | --- | | The main plugin file | The one PHP file at the project root with a `Plugin Name` header in its first 8 KB, as WordPress finds it. Its `Version` header equals the tag, its `Text Domain` equals `plugin-slug`, and its `Requires PHP`, `Requires at least` and `Requires Plugins` cover what the shipped packages need. | | `readme.txt` | Required with `wp-org: true`. When present, its `Stable tag` equals the release version, or, for a prerelease, the last stable release. | -| `CHANGELOG.md` | Optional. When present, it has a section headed by the version, such as `## 1.2.3 - 2026-10-06` or `## [1.2.3] - 2026-10-06`, which becomes the release notes; a missing section fails the release. | +| `CHANGELOG.md` | Optional. When present, it has a section headed by the version, such as `## 1.2.3 - 2026-10-06` or `## [1.2.3] - 2026-10-06`, which becomes the release notes; a missing or blank section fails the build, dry runs included. | | `.distignore` | Required; `wp dist-archive` builds the zip from it. Copy this package's baseline and extend it. | | `package.json` and `package-lock.json` | Declare a Node version and `@wordpress/env`. | | `.wp-env.json`, or the file `wp-env-config-file` names | Maps `wp-content/plugins/` to the plugin source. | diff --git a/docs/workflows.md b/docs/workflows.md index 8c655e1..2b487f2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -217,10 +217,10 @@ The workflow runs five jobs: - `build` (`contents: read`, no secrets) finds the main plugin file as WordPress does, checks the release metadata, installs Composer and npm dependencies without restoring a dependency cache, installs WP-CLI and its `i18n` and `dist-archive` commands from `php/release/composer.lock` in the plugin's copy of this package, regenerates the POT, prunes to production dependencies, builds the zip, checks its contents, runs Plugin Check, and outputs the version and the zip's SHA-256. - `test` (`contents: read`, no secrets) mounts the extracted zip in the project's own wp-env, activates it with `wp plugin activate`, requests the home page, and runs `npm run test:e2e` when the project defines it, after installing Chromium when `@playwright/test` is installed. - `provenance` (`actions: read`) requires a successful push run of every file in `required-workflows` on the released commit, so a release reuses the suites proven there; tag a commit once they pass. -- `release` (`contents: write`) verifies the zip's digest and creates the GitHub release with the zip attached. Its notes are the version's section of `CHANGELOG.md` when that file exists, and the job fails when that section is missing; without the file, the notes are GitHub's generated notes. +- `release` (`contents: write`) verifies the zip's digest and creates the GitHub release with the zip attached. Its notes are the version's section of `CHANGELOG.md`, which `build` extracts and checks, so a dry run checks the notes the tagged run publishes; without the file, the notes are GitHub's generated notes. - `wp-org` runs only with `wp-org: true`, after `release`, and never for a prerelease. It re-checks that the ref is a stable version tag, then deploys the same zip and the `.wordpress-org` assets to wp.org SVN from the `wp-org-release` environment, the only job that holds the SVN credentials. -The `release` and `wp-org` jobs install no Composer or npm dependencies and run no tests, and `wp-org` checks nothing out; the deploy action installs Subversion when the runner lacks it. Pinning the workflow by commit SHA keeps a changed privileged job from reaching a consumer without a reviewed pin bump. +The `release` and `wp-org` jobs install no Composer or npm dependencies, run no tests and check nothing out; the deploy action installs Subversion when the runner lacks it. Pinning the workflow by commit SHA keeps a changed privileged job from reaching a consumer without a reviewed pin bump. `publish: false` skips `release` and `wp-org` and runs `build` and `test` on any branch or version tag, which exercises the release path without a tag. A dry run started with `workflow_dispatch`, such as one on trunk after CI passes, also runs `provenance`; a dry run on a pull request or a push skips it, because a pull request's merge commit has no push runs and a push would race the runs it looks up. Releases of one plugin run one at a time, while each ref's dry runs queue apart from them, because a concurrency group holds one pending run and cancels an older one, so a dry run in the releases' group would cancel a pending release. @@ -230,6 +230,7 @@ The `build` job fails when: - the plugin does not require `ahegyes/wordpress-configs` under `require-dev`, so its development install holds no `php/release/composer.lock` to install WP-CLI from; - the project root holds no PHP file with a `Plugin Name:` header in its first 8 KB, or more than one; - the main plugin file's `Version:` header differs from the tag, or, for a stable version, from the `Stable tag:` of `readme.txt` when that file exists; a wp.org release requires `readme.txt`; +- `CHANGELOG.md` exists without a section for the version, a `## ` or `## []` heading, or that section is blank; - the main plugin 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 project's own `composer.json`, the production packages in `vendor/` and the scoped packages in `vendor-prefixed/`; From 3fca6f7c98bf25ba502e3762d37a7f3fd363abb0 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:30:16 +0100 Subject: [PATCH 05/23] Hold the release headers to WooCommerce and vendor-dir floors The release gate checked Requires PHP and Requires at least against the shipped packages but no WooCommerce floor, so a plugin could declare a WC requires at least below what a bundled package needs. It also read production packages only from vendor/composer/installed.json, so a project with a custom Composer vendor-dir skipped the PHP-floor check for every production package. A shipped package's extra.requires-wc now sets a WC requires at least floor, as extra.requires-wp sets Requires at least, and the installed packages come from the vendor directory composer config reports. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 13 +++++++++---- docs/scripts-contract.md | 5 +++-- docs/workflows.md | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index be35720..82efcb6 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -321,7 +321,8 @@ jobs: - name: Verify the headers cover the shipped packages working-directory: ${{ inputs.project-path }} run: | - php <<'PHP' + vendor_dir=$(composer config vendor-dir) + VENDOR_DIR="$vendor_dir" php <<'PHP' [], 'Requires at least' => []]; + $floors = ['Requires PHP' => [], 'Requires at least' => [], 'WC requires at least' => []]; $plugins = []; foreach ($packages as $package) { $name = $package['name'] ?? '(unnamed)'; @@ -386,6 +388,9 @@ jobs: if (is_string($package['extra']['requires-wp'] ?? null)) { $floors['Requires at least'][$name] = $normalize($package['extra']['requires-wp']); } + if (is_string($package['extra']['requires-wc'] ?? null)) { + $floors['WC requires at least'][$name] = $normalize($package['extra']['requires-wc']); + } foreach ((array) ($package['extra']['requires-plugins'] ?? []) as $slug) { $plugins[(string) $slug][] = $name; } diff --git a/docs/scripts-contract.md b/docs/scripts-contract.md index c63413e..7296e88 100644 --- a/docs/scripts-contract.md +++ b/docs/scripts-contract.md @@ -101,7 +101,7 @@ A project with a lower minimum WordPress version sets `WPCompat.requiresAtLeast` | File | Requirement | | --- | --- | -| The main plugin file | The one PHP file at the project root with a `Plugin Name` header in its first 8 KB, as WordPress finds it. Its `Version` header equals the tag, its `Text Domain` equals `plugin-slug`, and its `Requires PHP`, `Requires at least` and `Requires Plugins` cover what the shipped packages need. | +| The main plugin file | The one PHP file at the project root with a `Plugin Name` header in its first 8 KB, as WordPress finds it. Its `Version` header equals the tag, its `Text Domain` equals `plugin-slug`, and its `Requires PHP`, `Requires at least`, `WC requires at least` and `Requires Plugins` cover what the shipped packages need. | | `readme.txt` | Required with `wp-org: true`. When present, its `Stable tag` equals the release version, or, for a prerelease, the last stable release. | | `CHANGELOG.md` | Optional. When present, it has a section headed by the version, such as `## 1.2.3 - 2026-10-06` or `## [1.2.3] - 2026-10-06`, which becomes the release notes; a missing or blank section fails the build, dry runs included. | | `.distignore` | Required; `wp dist-archive` builds the zip from it. Copy this package's baseline and extend it. | @@ -111,12 +111,13 @@ A project with a lower minimum WordPress version sets `WPCompat.requiresAtLeast` | `.wordpress-org/` | Optional wp.org assets, deployed with `wp-org: true`. | | `.github/workflows/quality.yml` and `.github/workflows/tests.yml` | Each has a successful push run on the released commit, unless `required-workflows` names other files. | -A package the plugin ships states the WordPress version and the plugins it needs in its own `composer.json`, and the release checks the plugin's headers against them: +A package the plugin ships states the WordPress version, the WooCommerce version and the plugins it needs in its own `composer.json`, and the release checks the plugin's headers against them: ```json { "extra": { "requires-wp": "7.1", + "requires-wc": "11.2", "requires-plugins": ["woocommerce"] } } diff --git a/docs/workflows.md b/docs/workflows.md index 2b487f2..338b452 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -233,8 +233,8 @@ The `build` job fails when: - `CHANGELOG.md` exists without a section for the version, a `## ` or `## []` heading, or that section is blank; - the main plugin 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 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`; +- `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 Composer's vendor directory and the scoped packages in `vendor-prefixed/`; +- `Requires at least` is below the `extra.requires-wp` version a shipped package declares, `WC requires at least` is below its `extra.requires-wc` version, or `Requires Plugins` lacks a slug listed in its `extra.requires-plugins`; - the archive misses the main plugin 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`. From 285d5dbf5efe5d3ff2f7903e78a323681ad82e1c Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:34:16 +0100 Subject: [PATCH 06/23] Tighten the release archive's shipped and forbidden files The completeness check passed a scoped package as long as any one of its files reached the zip, so a .distignore pattern that dropped its code while its composer.json or README shipped went unnoticed until the plugin fataled. Every PHP file under vendor-prefixed/ must now ship, scoper-autoload.php included, while a package's metadata may stay out; the template, Linked Orders, Internal Comments and Locked Payment Methods archives pass it. The forbidden-entry check let profile PHPStan configs, PHPUnit caches, AGENTS.md, CLAUDE.md and changelog/ fragments through, and exempted .key files under vendor/ with the certificates. Those files now fail the build, and only .pem certificates under vendor/ and vendor-prefixed/ stay exempt. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 38 ++++++++++---------------- docs/workflows.md | 4 +-- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 82efcb6..7dc420a 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -440,27 +440,18 @@ jobs: if: ${{ steps.scoping.outputs.requires-scoped == 'true' }} working-directory: ${{ inputs.project-path }} run: | - entries=$(unzip -Z1 "$PLUGIN_SLUG.zip") - status=0 - - if ! grep -qxF -- "$PLUGIN_SLUG/vendor-prefixed/scoper-autoload.php" <<< "$entries"; then - echo "::error::Archive is missing $PLUGIN_SLUG/vendor-prefixed/scoper-autoload.php." - status=1 - fi - - scoped_package_roots=$(find vendor-prefixed -mindepth 3 -maxdepth 3 -type f -name composer.json -exec dirname {} \; 2>/dev/null | LC_ALL=C sort -u) - if [ -z "$scoped_package_roots" ]; then + if [ -z "$(find vendor-prefixed -mindepth 3 -maxdepth 3 -type f -name composer.json 2>/dev/null)" ]; then echo "::error::No scoped package under vendor-prefixed/// — the scoping pipeline did not run." - status=1 + exit 1 + fi + # Every scoped PHP file ships, scoper-autoload.php included, while a distribution may leave a package's metadata out. + missing=$(LC_ALL=C comm -23 <(find vendor-prefixed -type f -name '*.php' | sed "s|^|$PLUGIN_SLUG/|" | LC_ALL=C sort) <(unzip -Z1 "$PLUGIN_SLUG.zip" | LC_ALL=C sort)) + if [ -n "$missing" ]; then + while IFS= read -r file; do + echo "::error::Archive is missing $file." + done <<< "$missing" + exit 1 fi - while IFS= read -r pkg; do - [ -n "$pkg" ] || continue - if ! grep -F -- "$PLUGIN_SLUG/$pkg/" <<< "$entries" | grep -q '[^/]$'; then - echo "::error::Archive is missing scoped package $pkg/." - status=1 - fi - done <<< "$scoped_package_roots" - exit "$status" - name: Assert forbidden entries absent working-directory: ${{ inputs.project-path }} @@ -477,7 +468,7 @@ jobs: echo "::error::Archive must not contain $entry." status=1 ;; - "$PLUGIN_SLUG/.editorconfig"|"$PLUGIN_SLUG/.gitattributes"|"$PLUGIN_SLUG/.gitignore"|"$PLUGIN_SLUG/.distignore") + "$PLUGIN_SLUG/.editorconfig"|"$PLUGIN_SLUG/.gitattributes"|"$PLUGIN_SLUG/.gitignore"|"$PLUGIN_SLUG/.distignore"|"$PLUGIN_SLUG/AGENTS.md"|"$PLUGIN_SLUG/CLAUDE.md"|"$PLUGIN_SLUG/changelog/"*) echo "::error::Archive must not contain $entry." status=1 ;; @@ -485,11 +476,11 @@ jobs: echo "::error::Archive must not contain $entry." status=1 ;; - "$PLUGIN_SLUG/phpcs"*.xml*|"$PLUGIN_SLUG/.phpcs"*.xml*|"$PLUGIN_SLUG/.phpstan.neon"|"$PLUGIN_SLUG/phpstan.dist.neon") + "$PLUGIN_SLUG/phpcs"*.xml*|"$PLUGIN_SLUG/.phpcs"*.xml*|"$PLUGIN_SLUG/phpstan"*.neon*|"$PLUGIN_SLUG/.phpstan"*.neon*) echo "::error::Archive must not contain $entry." status=1 ;; - "$PLUGIN_SLUG/phpunit"*.xml*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."*) + "$PLUGIN_SLUG/phpunit"*.xml*|"$PLUGIN_SLUG/.phpunit.result.cache"|"$PLUGIN_SLUG/.phpunit.cache/"*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."*) echo "::error::Archive must not contain $entry." status=1 ;; @@ -502,8 +493,7 @@ jobs: status=1 ;; # 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) + "$PLUGIN_SLUG/vendor/"*.pem|"$PLUGIN_SLUG/"*"/vendor/"*.pem|"$PLUGIN_SLUG/vendor-prefixed/"*.pem) ;; "$PLUGIN_SLUG/.env"*|"$PLUGIN_SLUG/"*"/.env"* \ |"$PLUGIN_SLUG/.npmrc"|"$PLUGIN_SLUG/"*"/.npmrc" \ diff --git a/docs/workflows.md b/docs/workflows.md index 338b452..0a76eb6 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -235,8 +235,8 @@ The `build` job fails when: - `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 project's own `composer.json`, the production packages in Composer's vendor directory and the scoped packages in `vendor-prefixed/`; - `Requires at least` is below the `extra.requires-wp` version a shipped package declares, `WC requires at least` is below its `extra.requires-wc` version, or `Requires Plugins` lacks a slug listed in its `extra.requires-plugins`; -- the archive misses the main plugin 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`. +- the archive misses the main plugin file, `readme.txt` for a wp.org release, or, when `composer.json` declares `extra.scoping-prefix`, any PHP file under `vendor-prefixed/`, `scoper-autoload.php` included, while a scoped package's metadata, such as its `composer.json`, may stay out; +- the archive holds a development or secret file, such as a `phpcs*.xml*` ruleset, a `phpstan*.neon*` profile, `AGENTS.md`, a `changelog/` fragment, `composer.json`, `.env` or a `.key` file, with only a `.pem` certificate allowed under a `vendor/` or `vendor-prefixed/` directory. A strict `>` lower bound in a package's PHP constraint is read as inclusive. The version is taken from the tag and verified against the files, never stamped in. From 385f03a5b463553bc05cd682f5db88d758e6632c Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:34:51 +0100 Subject: [PATCH 07/23] Merge the artifact test's redirect into the project's wp-env override The artifact test wrote the config's override file from scratch, so any override the project kept, such as its own constants or a co-mounted plugin, was dropped, and it cleared core only at the root, so a core pinned for the development or tests environment still ran the E2E against that pin. The site check also read the port from the config alone. The redirect and core: null now merge into the existing override at the root and in both environments, and the site is requested on the development port the config and its override set. docs/workflows.md also says that a newer tag cancels an older one's pending release, which is re-run from its tag's run. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 16 +++++++++++----- docs/workflows.md | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 7dc420a..fb07853 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -639,9 +639,11 @@ jobs: echo "::error::Release contract violation — $config must exist and map \"$key\" to the plugin source; found mappings: $(jq -c '.mappings // {}' "$config" 2> /dev/null || echo 'none')." exit 1 fi - # wp-env layers env-scoped mappings over root, so cover root plus development and tests — otherwise a consumer's env.tests mapping shadows the built artifact and the E2E runs against source. + # wp-env layers env-scoped settings over root, so the redirect and the latest core cover root plus development and tests, or an env-scoped mapping or core pin would run the E2E against source. override="${config%.json}.override.json" - jq -n --arg key "$key" --arg built "$BUILT_PATH" '{core: null, mappings: {($key): $built}, env: {development: {mappings: {($key): $built}}, tests: {mappings: {($key): $built}}}}' > "$override" + [ -f "$override" ] || echo '{}' > "$override" + jq --arg key "$key" --arg built "$BUILT_PATH" '. * ({core: null, mappings: {($key): $built}} | . + {env: {development: ., tests: .}})' "$override" > "$override.tmp" + mv "$override.tmp" "$override" echo "Generated $override:" cat "$override" @@ -685,9 +687,13 @@ jobs: - name: Assert the site serves working-directory: ${{ inputs.project-path }} run: | - # A boot-path fatal that survives activation surfaces on the next request. The consumer's - # config governs the development port — the override file never touches it. - port=$(jq -r '.env.development.port // .port // 8888' "${WP_ENV_CONFIG_FILE:-.wp-env.json}") + # A boot-path fatal that survives activation surfaces on the next request. + config="${WP_ENV_CONFIG_FILE:-.wp-env.json}" + files=("$config") + if [ -f "${config%.json}.override.json" ]; then + files+=("${config%.json}.override.json") + fi + port=$(jq -s 'reduce .[] as $file ({}; . * $file) | .env.development.port // .port // 8888' "${files[@]}") curl --fail --silent --show-error --output /dev/null "http://localhost:${port}/" - name: Run E2E tests against built artifact diff --git a/docs/workflows.md b/docs/workflows.md index 0a76eb6..a608edf 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -222,7 +222,7 @@ The workflow runs five jobs: The `release` and `wp-org` jobs install no Composer or npm dependencies, run no tests and check nothing out; the deploy action installs Subversion when the runner lacks it. Pinning the workflow by commit SHA keeps a changed privileged job from reaching a consumer without a reviewed pin bump. -`publish: false` skips `release` and `wp-org` and runs `build` and `test` on any branch or version tag, which exercises the release path without a tag. A dry run started with `workflow_dispatch`, such as one on trunk after CI passes, also runs `provenance`; a dry run on a pull request or a push skips it, because a pull request's merge commit has no push runs and a push would race the runs it looks up. Releases of one plugin run one at a time, while each ref's dry runs queue apart from them, because a concurrency group holds one pending run and cancels an older one, so a dry run in the releases' group would cancel a pending release. +`publish: false` skips `release` and `wp-org` and runs `build` and `test` on any branch or version tag, which exercises the release path without a tag. A dry run started with `workflow_dispatch`, such as one on trunk after CI passes, also runs `provenance`; a dry run on a pull request or a push skips it, because a pull request's merge commit has no push runs and a push would race the runs it looks up. Releases of one plugin run one at a time, while each ref's dry runs queue apart from them, because a concurrency group holds one pending run and cancels an older one, so a dry run in the releases' group would cancel a pending release. A tag pushed while an older one's release is still pending cancels that release, which is then re-run from its tag's run. The `build` job fails when: @@ -242,7 +242,7 @@ A strict `>` lower bound in a package's PHP constraint is read as inclusive. The A prerelease tag publishes a GitHub prerelease, which GitHub's latest release skips, so only sites on a prerelease update channel are offered it. It never deploys to wp.org, which offers sites only the `Stable tag:` of `readme.txt`, so that header keeps naming the last stable release. -The artifact test needs the wp-env config to map `wp-content/plugins/` to the plugin source: it remaps exactly that `mappings` key at the built zip, so co-mounted plugins, the port and lifecycle scripts survive the per-key merge, and pins the environment to the latest stable WordPress core. +The artifact test needs the wp-env config to map `wp-content/plugins/` to the plugin source: it remaps exactly that `mappings` key at the built zip and pins the latest stable WordPress core, at the root and in the development and tests environments, through the config's override file, merged into any override the project keeps, so co-mounted plugins, the port and lifecycle scripts survive. It requests the site on the development port the config and its override set. `plugin-check` runs the GitHub profile, which skips `plugin_updater` and `plugin_readme` as `reusable-plugin-check.yml` does, unless `wp-org` is on, and `exclude-checks` skips further checks as that workflow's input does; a malformed list fails the build before anything installs. From ff19ff52d9fc1c6008b9c27df812be433f6d3334 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:35:17 +0100 Subject: [PATCH 08/23] Attest the release zip's build provenance A release attached its zip to the GitHub release with nothing that ties it to the workflow run that built it, so a downloaded copy could not be checked against its build. The release job attests the verified zip with actions/attest-build-provenance before it creates the release, which gh attestation verify checks. GitHub offers attestations in private and internal repositories only on Enterprise Cloud, so the step runs only in a public repository and a private one still releases. Callers grant id-token: write and attestations: write as well. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 11 ++++++++++- docs/workflows.md | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index fb07853..6463587 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -1,5 +1,5 @@ name: Release -# Callers grant `contents: write` and `actions: read`; a caller must grant every permission the called jobs declare, or the call fails at startup. +# Callers grant `contents: write`, `actions: read`, `id-token: write` and `attestations: write`; a caller must grant every permission the called jobs declare, or the call fails at startup. on: workflow_call: @@ -754,7 +754,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: + attestations: write contents: write + id-token: write env: PLUGIN_SLUG: ${{ inputs.plugin-slug }} steps: @@ -774,6 +776,13 @@ jobs: exit 1 fi + # Attestations need GitHub Enterprise Cloud in a private or internal repository, where the step would fail the release. + - name: Attest the archive's provenance + if: ${{ github.event.repository.visibility == 'public' }} + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ./dist/${{ inputs.plugin-slug }}.zip + - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/docs/workflows.md b/docs/workflows.md index a608edf..1225662 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -217,7 +217,7 @@ The workflow runs five jobs: - `build` (`contents: read`, no secrets) finds the main plugin file as WordPress does, checks the release metadata, installs Composer and npm dependencies without restoring a dependency cache, installs WP-CLI and its `i18n` and `dist-archive` commands from `php/release/composer.lock` in the plugin's copy of this package, regenerates the POT, prunes to production dependencies, builds the zip, checks its contents, runs Plugin Check, and outputs the version and the zip's SHA-256. - `test` (`contents: read`, no secrets) mounts the extracted zip in the project's own wp-env, activates it with `wp plugin activate`, requests the home page, and runs `npm run test:e2e` when the project defines it, after installing Chromium when `@playwright/test` is installed. - `provenance` (`actions: read`) requires a successful push run of every file in `required-workflows` on the released commit, so a release reuses the suites proven there; tag a commit once they pass. -- `release` (`contents: write`) verifies the zip's digest and creates the GitHub release with the zip attached. Its notes are the version's section of `CHANGELOG.md`, which `build` extracts and checks, so a dry run checks the notes the tagged run publishes; without the file, the notes are GitHub's generated notes. +- `release` (`attestations: write`, `contents: write`, `id-token: write`) verifies the zip's digest, attests its build provenance in a public repository, and creates the GitHub release with the zip attached. Its notes are the version's section of `CHANGELOG.md`, which `build` extracts and checks, so a dry run checks the notes the tagged run publishes; without the file, the notes are GitHub's generated notes. - `wp-org` runs only with `wp-org: true`, after `release`, and never for a prerelease. It re-checks that the ref is a stable version tag, then deploys the same zip and the `.wordpress-org` assets to wp.org SVN from the `wp-org-release` environment, the only job that holds the SVN credentials. The `release` and `wp-org` jobs install no Composer or npm dependencies, run no tests and check nothing out; the deploy action installs Subversion when the runner lacks it. Pinning the workflow by commit SHA keeps a changed privileged job from reaching a consumer without a reviewed pin bump. @@ -248,7 +248,9 @@ The artifact test needs the wp-env config to map `wp-content/plugins/ --repo /` checks a public release's zip against its attestation. + +The caller grants `actions: read`, `attestations: write`, `contents: write` and `id-token: write`. [The scripts contract](scripts-contract.md#release) lists the files and scripts the workflow expects. ```yaml name: Release @@ -265,7 +267,9 @@ jobs: release: permissions: actions: read + attestations: write contents: write + id-token: write uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@ with: plugin-slug: your-plugin-slug From b35c0b297eda94316d021faf6753cb20b16aaab1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:35:46 +0100 Subject: [PATCH 09/23] Keep a pinned site's PHP in the end-to-end workflow The Playwright workflow exported php-version as WP_ENV_PHP_VERSION for the whole job, which wp-env applies over every phpVersion, so a suite aimed at a site pinned to an older PHP ran on php-version instead, while the PHPUnit workflow keeps the pin. The end-to-end workflow drops the job-level variable and runs the PHPUnit workflow's "Resolve the site's PHP" step verbatim, which a test holds identical. A project without @playwright/test now fails with a named error instead of an empty version, and the browser install runs npx with --no-install, as the release does. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-playwright-e2e.yml | 26 +++++++++++++++---- docs/workflows.md | 6 +++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reusable-playwright-e2e.yml b/.github/workflows/reusable-playwright-e2e.yml index e0c388d..7dd386d 100644 --- a/.github/workflows/reusable-playwright-e2e.yml +++ b/.github/workflows/reusable-playwright-e2e.yml @@ -13,7 +13,7 @@ on: type: string required: true php-version: - description: 'PHP version for Composer, and for wp-env through WP_ENV_PHP_VERSION, which wins over the phpVersion of its config files.' + description: 'PHP version for Composer and any suite outside wp-env, and for the site in wp-env when its config and override file pin no phpVersion.' type: string required: false default: '8.5' @@ -58,9 +58,8 @@ jobs: permissions: contents: read env: - # Job-level wp-env variables give a consumer script that starts wp-env again the same WordPress and PHP. + # A job-level WP_ENV_CORE gives a consumer script that starts wp-env again the same WordPress. WP_ENV_CORE: ${{ inputs.wp-env-core != '' && inputs.wp-env-core || (inputs.wp-version != '' && format('WordPress/WordPress#tags/{0}', inputs.wp-version)) || '' }} - WP_ENV_PHP_VERSION: ${{ inputs.php-version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -114,7 +113,9 @@ jobs: - name: Resolve Playwright version id: playwright working-directory: ${{ inputs.project-path }} - run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + run: | + version=$(node -p "require('@playwright/test/package.json').version") || { echo "::error::@playwright/test is not installed in this project — add it to devDependencies."; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -124,7 +125,22 @@ jobs: - name: Install Playwright browsers working-directory: ${{ inputs.project-path }} - run: npx playwright install --with-deps chromium + run: npx --no-install playwright install --with-deps chromium + + # wp-env applies WP_ENV_PHP_VERSION over a config's phpVersion, so a pinned site keeps its PHP while Composer and the suite run on php-version. + - name: Resolve the site's PHP + working-directory: ${{ inputs.project-path }} + env: + PHP_VERSION: ${{ inputs.php-version }} + WP_ENV_CONFIG_FILE: ${{ inputs.wp-env-config-file }} + run: | + config="${WP_ENV_CONFIG_FILE:-.wp-env.json}" + for file in "$config" "${config%.json}.override.json"; do + if [ -f "$file" ] && jq -e '[.phpVersion, (.env // {} | .[] | .phpVersion?)] | map(select(. != null)) | length > 0' "$file" > /dev/null; then + exit 0 + fi + done + echo "WP_ENV_PHP_VERSION=$PHP_VERSION" >> "$GITHUB_ENV" - name: Start wp-env working-directory: ${{ inputs.project-path }} diff --git a/docs/workflows.md b/docs/workflows.md index 1225662..59b4ed1 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -15,7 +15,7 @@ A reusable workflow cannot elevate above the caller's token, so a caller grants ## Shared behavior - **Node.** `reusable-scripts-styles-lint.yml`, `reusable-phpunit.yml` (when it starts wp-env), `reusable-playwright-e2e.yml` and `reusable-release.yml` install the Node version the project's `package.json` declares, in `engines.node` or in the `volta` or `devEngines.runtime` fields that `actions/setup-node` reads, and fail when it declares none. `reusable-supply-chain-audit.yml` and `reusable-block-json-check.yml` run no project npm code and use a fixed Node. -- **wp-env.** The PHPUnit and Playwright workflows and the release's artifact test run the project's own `@wordpress/env`, the version its `package-lock.json` pins, so the project declares it in `devDependencies`. They export `php-version` as `WP_ENV_PHP_VERSION`, which wp-env applies over the `phpVersion` of its config files; the PHPUnit workflow exports it only when the config in use and its override file pin no `phpVersion`. Plugin Check, in `reusable-plugin-check.yml` and in the release build, starts its own environment through `WordPress/plugin-check-action`, without the project's wp-env config. +- **wp-env.** The PHPUnit and Playwright workflows and the release's artifact test run the project's own `@wordpress/env`, the version its `package-lock.json` pins, so the project declares it in `devDependencies`. They export `php-version` as `WP_ENV_PHP_VERSION`, which wp-env applies over the `phpVersion` of its config files; the PHPUnit and Playwright workflows export it only when the config in use and its override file pin no `phpVersion`, with one step both workflows share verbatim. Plugin Check, in `reusable-plugin-check.yml` and in the release build, starts its own environment through `WordPress/plugin-check-action`, without the project's wp-env config. - **Composer.** Installs pass `--ignore-platform-req=php+`, which ignores only the PHP upper bound, so a PHP newer than a package declares still installs while extension requirements and floors stay checked. - **Matrix inputs.** A JSON-array input that fans out into a matrix (`scripts`, `php-versions`, `languages`) must be non-empty: an empty array would expand to zero jobs and report success. @@ -149,7 +149,7 @@ Installs Composer and npm dependencies, builds assets, installs Chromium, starts | --- | --- | --- | --- | --- | | `project-path` | `string` | No | `'.'` | Path to the project, relative to the repository root. | | `artifact-slug` | `string` | Yes | — | Label for the uploaded failure-report artifact. | -| `php-version` | `string` | No | `'8.5'` | PHP for Composer and for wp-env through `WP_ENV_PHP_VERSION`. | +| `php-version` | `string` | No | `'8.5'` | PHP for Composer, for any suite outside wp-env, and for the site when the wp-env config pins no `phpVersion`. | | `wp-version` | `string` | No | `''` | WordPress release tag. Empty defers to the wp-env config's `core`. | | `wp-env-core` | `string` | No | `''` | Full `WP_ENV_CORE` value. Overrides `wp-version`. | | `composer-options` | `string` | No | `'--prefer-dist --ignore-platform-req=php+'` | Options for the dependency install, which includes development packages so a scoping pipeline runs. | @@ -157,6 +157,8 @@ Installs Composer and npm dependencies, builds assets, installs Chromium, starts | `wp-env-config-file` | `string` | No | `''` | wp-env config file relative to `project-path`. Empty uses `.wp-env.json`. | | `playwright-script` | `string` | No | `'test:e2e'` | npm script that runs the Playwright suite. | +A wp-env config that pins `phpVersion`, or whose `.override.json` pins it, keeps its PHP, as in the PHPUnit workflow. The project declares `@playwright/test`, or the job fails naming it. + On failure, the `playwright-report-` artifact holds `playwright-report/`, `artifacts/` and `tests/.cache/artifacts/`, the output location the shared Playwright base sets. The caller grants `contents: read`. ```yaml From 18525da7cbe537f4d1b07b903251ab5f1a7ccbca Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:36:17 +0100 Subject: [PATCH 10/23] Count cooldown findings and default npm audits to production Composer 2.11 fails an audit on a package still in policy.cooldown's window when that policy says fail, and reports it under a cooldown key the classifier never read, so such a run was read as a broken report and failed even with fail-on-findings off. Cooldown entries now count as findings beside advisories and abandoned packages, and docs/workflows.md says what the classifier does: any filter-list entry fails the audit. npm-audit-flags defaulted to the full dependency graph, which every caller overrode with --omit=dev --audit-level=high, the gate this project runs. That is now the default, and configs' own caller drops its override. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/quality.yml | 3 --- .github/workflows/reusable-supply-chain-audit.yml | 8 ++++---- docs/workflows.md | 6 ++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c646c74..54c6dc9 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -90,12 +90,9 @@ jobs: supply-chain: name: Supply-chain audit - # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. permissions: contents: read uses: ./.github/workflows/reusable-supply-chain-audit.yml - with: - npm-audit-flags: '--omit=dev --audit-level=high' block-json: name: block.json schema check diff --git a/.github/workflows/reusable-supply-chain-audit.yml b/.github/workflows/reusable-supply-chain-audit.yml index 4a31d52..7a89b3a 100644 --- a/.github/workflows/reusable-supply-chain-audit.yml +++ b/.github/workflows/reusable-supply-chain-audit.yml @@ -14,7 +14,7 @@ on: required: false default: true npm-audit: - description: 'Run `npm audit` against package-lock.json, so the lockfile is required. Set false for repos without package.json. Audits the full dependency graph (dev/build deps included) by default. At least one of composer-audit / npm-audit must stay enabled.' + description: 'Run `npm audit` against package-lock.json, so the lockfile is required. Set false for repos without package.json. At least one of composer-audit / npm-audit must stay enabled.' type: boolean required: false default: true @@ -24,10 +24,10 @@ on: required: false default: '--abandoned=report' npm-audit-flags: - description: 'Extra flags passed to `npm audit`. Defaults to --audit-level=high — audits the full graph (dev/build deps included) and fails only on high/critical advisories; low/moderate are surfaced without failing. Pass --omit=dev to audit production deps only.' + description: 'Extra flags passed to `npm audit`. The default audits production dependencies only and fails on high and critical advisories, surfacing low and moderate ones without failing; drop --omit=dev to audit development dependencies too.' type: string required: false - default: '--audit-level=high' + default: '--omit=dev --audit-level=high' fail-on-findings: description: 'Fail the job when an audit reports findings. Set false to run the audit advisory-only — findings are surfaced in the logs without blocking, except a Composer filter-list match such as the malware list, which always fails.' type: boolean @@ -98,7 +98,7 @@ jobs: } if ($filtered !== []) { echo "filter:", implode(", ", $filtered); - } elseif (count((array) $data["advisories"]) + count((array) ($data["abandoned"] ?? [])) > 0) { + } elseif (count((array) $data["advisories"]) + count((array) ($data["abandoned"] ?? [])) + count((array) ($data["cooldown"] ?? [])) > 0) { echo "findings"; } ') || findings="invalid" diff --git a/docs/workflows.md b/docs/workflows.md index 59b4ed1..20740b7 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -310,10 +310,10 @@ Audits the committed `composer.lock` and `package-lock.json` in two parallel job | `composer-audit` | `boolean` | No | `true` | Whether to run `composer audit --locked`. | | `npm-audit` | `boolean` | No | `true` | Whether to run `npm audit --package-lock-only`. | | `composer-audit-flags` | `string` | No | `'--abandoned=report'` | Extra `composer audit` flags; the default fails on advisories and reports abandoned packages. | -| `npm-audit-flags` | `string` | No | `'--audit-level=high'` | Extra `npm audit` flags; the default fails on high and critical advisories. | +| `npm-audit-flags` | `string` | No | `'--omit=dev --audit-level=high'` | Extra `npm audit` flags; the default audits production dependencies and fails on high and critical advisories. | | `fail-on-findings` | `boolean` | No | `true` | Whether findings fail the job. | -At least one of `composer-audit` and `npm-audit` stays on. Both audit the full graph, development dependencies included, unless the flags say `--no-dev` or `--omit=dev`. `fail-on-findings: false` turns advisories and abandoned packages into warnings, but a package on a Composer filter list whose policy fails the audit, such as the malware list, still fails it, and the error names the package and its lists. +At least one of `composer-audit` and `npm-audit` stays on. The Composer audit covers the full graph, development dependencies included, unless its flags say `--no-dev`; the npm audit covers production dependencies unless its flags drop `--omit=dev`. A package still in the cooldown period of Composer's `policy.cooldown`, when that policy fails the audit, counts as a finding. `fail-on-findings: false` turns advisories, abandoned packages and cooldown findings into warnings, but any package on a Composer filter list, such as the malware list, still fails the audit, and the error names the package and its lists. The caller grants `contents: read`. @@ -323,8 +323,6 @@ jobs: permissions: contents: read uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@ - with: - npm-audit-flags: '--omit=dev --audit-level=high' ``` ## Workflow checks — `reusable-workflow-checks.yml` From 093da39bf0829e6d1de669ee5009911b16574245 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:36:53 +0100 Subject: [PATCH 11/23] Lint extra workflow files and pin actionlint in Workflow Checks A monorepo whose packages ship their own workflows under plugins/*/.github/workflows had no way to lint them: actionlint reads only the root's .github/workflows. extra-workflows, an optional JSON array of globs, now joins those files to actionlint's list. zizmor needs nothing, because its default input already audits every workflow in the repository and skips gitignored trees. actionlint ran whatever release the action fetched, beside a pinned zizmor; it is now pinned to 1.7.12, and CONTRIBUTING names both pins as manual bumps, since Dependabot sees neither. configs' own Workflow Checks also runs when zizmor.yml or dependabot.yml changes. Assisted-by: Claude Code:claude-opus-5-5 --- .../workflows/reusable-workflow-checks.yml | 21 +++++++++++++++++++ .github/workflows/workflow-checks.yml | 4 ++-- CONTRIBUTING.md | 2 ++ docs/workflows.md | 6 +++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-workflow-checks.yml b/.github/workflows/reusable-workflow-checks.yml index 1d98b46..000ca4a 100644 --- a/.github/workflows/reusable-workflow-checks.yml +++ b/.github/workflows/reusable-workflow-checks.yml @@ -2,6 +2,12 @@ name: Workflow Checks on: workflow_call: + inputs: + extra-workflows: + description: 'JSON array of glob patterns naming workflow files outside .github/workflows that actionlint lints beside it, such as ["plugins/*/.github/workflows/*.yml"]. zizmor audits every workflow in the repository already.' + type: string + required: false + default: '[]' permissions: {} @@ -18,8 +24,23 @@ jobs: with: persist-credentials: false + # actionlint reads only the root's .github/workflows unless it is given files, and then reads only those. + - name: Resolve the actionlint files + id: files + env: + EXTRA_WORKFLOWS: ${{ inputs.extra-workflows }} + run: | + if ! files=$(jq -r 'if type == "array" and all(.[]; type == "string" and . != "" and (contains(",") | not)) then (if length > 0 then [".github/workflows/*.yml", ".github/workflows/*.yaml"] + . | join(",") else "" end) else error end' <<< "$EXTRA_WORKFLOWS" 2> /dev/null); then + echo "::error::extra-workflows must be a JSON array of glob patterns without commas — got '$EXTRA_WORKFLOWS'." + exit 1 + fi + echo "files=$files" >> "$GITHUB_OUTPUT" + - name: Run actionlint uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: '1.7.12' + files: ${{ steps.files.outputs.files }} detect-ghas: name: Detect GHAS availability diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index 5f422c1..6e20368 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -3,9 +3,9 @@ name: Workflow Checks on: push: branches: [trunk] - paths: ['.github/workflows/**'] + paths: ['.github/**'] pull_request: - paths: ['.github/workflows/**'] + paths: ['.github/**'] schedule: # A weekly run off the hour keeps zizmor's online audits current between workflow changes. - cron: '17 6 * * 1' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05e37b9..fee6509 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,8 @@ Shipped `workflow_call` input names, types and semantics are frozen. An input ma Give every input a `description:` in the workflow and a row in [docs/workflows.md](docs/workflows.md). Consumers review, never automerge, a pin bump that changes the release workflow's `release` or `wp-org` jobs. +Dependabot cannot see the actionlint `version` and the `ZIZMOR_VERSION` that `reusable-workflow-checks.yml` pins, so bump them by hand to the newest release each pinned action supports. + ## Filing changes - Keep one concern per pull request. diff --git a/docs/workflows.md b/docs/workflows.md index 20740b7..de6c928 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -327,7 +327,11 @@ jobs: ## Workflow checks — `reusable-workflow-checks.yml` -Runs actionlint over the workflow files and zizmor over the repository. With GitHub Advanced Security, zizmor uploads SARIF to the Security tab and a finding-count gate fails the job; without it, zizmor runs in native mode and fails the job itself. Both modes fail on any finding. The workflow takes no inputs. +Runs a pinned actionlint over the workflow files in `.github/workflows/` and zizmor over the repository, whose every workflow, at any depth, it audits. With GitHub Advanced Security, zizmor uploads SARIF to the Security tab and a finding-count gate fails the job; without it, zizmor runs in native mode and fails the job itself. Both modes fail on any finding. + +| Input | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `extra-workflows` | `string` | No | `'[]'` | JSON array of glob patterns naming workflow files outside `.github/workflows/` that actionlint lints beside it, such as `'["plugins/*/.github/workflows/*.yml"]'`. A pattern holds no comma. | The caller grants `actions: read`, `contents: read` and `security-events: write`. From 8fd80168685f2957c4c8441254c8ee5d26fcb3d1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:37:22 +0100 Subject: [PATCH 12/23] Audit the release tools' lock in configs' Quality Every consumer's release installs WP-CLI and its commands from php/release/composer.lock, but the supply-chain audit read only the root's locks, so an advisory in that tree never failed configs' CI. Quality now audits it as well, with the npm audit off, since it holds no npm manifest. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/quality.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 54c6dc9..e426e9d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -94,6 +94,15 @@ jobs: contents: read uses: ./.github/workflows/reusable-supply-chain-audit.yml + supply-chain-release-tools: + name: Supply-chain audit (release tools) + permissions: + contents: read + uses: ./.github/workflows/reusable-supply-chain-audit.yml + with: + project-path: php/release + npm-audit: false + block-json: name: block.json schema check # Dogfoods reusable-block-json-check against a throwaway fixture; this tooling repo ships no real blocks. From b832545dcf6085fab19eb7a4430b10cb33b606a4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:38:47 +0100 Subject: [PATCH 13/23] Validate block.json files with a locked ajv-cli The block.json check ran `npx --yes ajv-cli@5.0.0`, which fixes only the top-level version, so every consumer's CI resolved ajv-cli's dependency tree fresh from the registry, unlocked, uncooled and unseen by Dependabot. node/block-json/ now holds the manifest and lock of that ajv-cli, as php/release/ does for the release's WP-CLI commands. The job checks it out from the workflow's own commit through job.workflow_repository and job.workflow_sha, installs it with npm ci and calls its ajv. Dependabot keeps the lock current, Quality audits it, and the npm package leaves the directory out. ajv-cli's last release requires fast-json-patch 2, whose prototype-pollution advisory is high, so an override holds it at 3.1.1, under which ajv validates the fixture and rejects an invalid block as before. actionlint 1.7.12 does not know the two job.workflow_* properties yet, so .github/actionlint.yaml ignores those two messages for that file. Assisted-by: Claude Code:claude-opus-5-5 --- .github/actionlint.yaml | 5 + .github/dependabot.yml | 15 + .github/workflows/quality.yml | 9 + .../workflows/reusable-block-json-check.yml | 20 +- AGENTS.md | 1 + docs/workflows.md | 2 +- node/block-json/package-lock.json | 272 ++++++++++++++++++ node/block-json/package.json | 12 + package.json | 1 + 9 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 node/block-json/package-lock.json create mode 100644 node/block-json/package.json diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..aff04c3 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# actionlint does not know the job context's workflow_repository and workflow_sha yet, which GitHub documents for a reusable workflow's own commit. +paths: + .github/workflows/reusable-block-json-check.yml: + ignore: + - 'property "workflow_(repository|sha)" is not defined' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da6d3d7..eb1005b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -45,6 +45,21 @@ updates: patterns: - '*' + - package-ecosystem: npm + directory: /node/block-json + schedule: + interval: weekly + day: monday + time: '06:00' + timezone: UTC + open-pull-requests-limit: 5 + cooldown: + default-days: 7 + groups: + block-json-tools: + patterns: + - '*' + - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e426e9d..8fc5742 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -103,6 +103,15 @@ jobs: project-path: php/release npm-audit: false + supply-chain-block-json-tools: + name: Supply-chain audit (block.json tools) + permissions: + contents: read + uses: ./.github/workflows/reusable-supply-chain-audit.yml + with: + project-path: node/block-json + composer-audit: false + block-json: name: block.json schema check # Dogfoods reusable-block-json-check against a throwaway fixture; this tooling repo ships no real blocks. diff --git a/.github/workflows/reusable-block-json-check.yml b/.github/workflows/reusable-block-json-check.yml index 551b431..4741351 100644 --- a/.github/workflows/reusable-block-json-check.yml +++ b/.github/workflows/reusable-block-json-check.yml @@ -23,16 +23,32 @@ jobs: with: persist-credentials: false + # The validator installs from this workflow's own commit of configs, whose lock Dependabot keeps current. + - name: Check out the block.json tools + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .block-json-tools + sparse-checkout: node/block-json + persist-credentials: false + - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '26' + - name: Install ajv-cli + working-directory: .block-json-tools/node/block-json + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Validate block.json files against the WordPress schema working-directory: ${{ inputs.project-path }} + env: + AJV: ${{ github.workspace }}/.block-json-tools/node/block-json/node_modules/.bin/ajv run: | # Validate one file per -d invocation; a single joined string breaks multi-block plugins. - mapfile -d '' files < <(find . -path ./node_modules -prune -o -path ./vendor -prune -o -name 'block.json' -print0) + mapfile -d '' files < <(find . -path ./node_modules -prune -o -path ./vendor -prune -o -path ./.block-json-tools -prune -o -name 'block.json' -print0) if [ "${#files[@]}" -eq 0 ]; then echo "No block.json files found — nothing to validate." exit 0 @@ -43,7 +59,7 @@ jobs: curl -fsSL https://schemas.wp.org/trunk/block.json -o "$schema" for file in "${files[@]}"; do echo "Validating $file" - npx --yes ajv-cli@5.0.0 validate \ + "$AJV" validate \ -s "$schema" \ -d "$file" \ --strict=false diff --git a/AGENTS.md b/AGENTS.md index 9be8963..3f7fc3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ wordpress-configs/ │ ├── phpstan/ # GlobalTagVisitor, which the PHPStan profile registers │ └── release/ # the manifest and lock of the WP-CLI commands reusable-release.yml installs, which Dependabot keeps current ├── node/ # tsconfig, ESLint, Stylelint, PostCSS and Playwright baselines +│ └── block-json/ # the manifest and lock of the ajv-cli reusable-block-json-check.yml installs, which the npm package leaves out ├── docs/ # workflows.md (input reference) and scripts-contract.md (scripts, files, floors) ├── tests/ │ ├── Unit/ # PHPUnit tests that run the real tools diff --git a/docs/workflows.md b/docs/workflows.md index de6c928..ae61a7e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -21,7 +21,7 @@ A reusable workflow cannot elevate above the caller's token, so a caller grants ## block.json schema check — `reusable-block-json-check.yml` -Finds every `block.json` under the project path, skipping the `node_modules/` and `vendor/` directories at its top level, and validates each against the trunk schema from `schemas.wp.org`. It succeeds with a message when it finds none. +Finds every `block.json` under the project path, skipping the `node_modules/` and `vendor/` directories at its top level, and validates each against the trunk schema from `schemas.wp.org`. It succeeds with a message when it finds none. The validator is the ajv-cli that `node/block-json/package-lock.json` locks, checked out from this workflow's own commit of configs, whose `overrides` keep its `fast-json-patch` at the release that fixes its prototype-pollution advisory. | Input | Type | Required | Default | Description | | --- | --- | --- | --- | --- | diff --git a/node/block-json/package-lock.json b/node/block-json/package-lock.json new file mode 100644 index 0000000..e95a4fe --- /dev/null +++ b/node/block-json/package-lock.json @@ -0,0 +1,272 @@ +{ + "name": "@ahegyes/wordpress-configs-block-json-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ahegyes/wordpress-configs-block-json-tools", + "license": "MIT", + "dependencies": { + "ajv-cli": "^5.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-cli": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz", + "integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0", + "fast-json-patch": "^2.0.0", + "glob": "^7.1.0", + "js-yaml": "^3.14.0", + "json-schema-migrate": "^2.0.0", + "json5": "^2.1.3", + "minimist": "^1.2.0" + }, + "bin": { + "ajv": "dist/index.js" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-migrate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz", + "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/node/block-json/package.json b/node/block-json/package.json new file mode 100644 index 0000000..cb4cc0b --- /dev/null +++ b/node/block-json/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ahegyes/wordpress-configs-block-json-tools", + "description": "The ajv-cli that reusable-block-json-check.yml validates block.json files with.", + "license": "MIT", + "private": true, + "dependencies": { + "ajv-cli": "^5.0.0" + }, + "overrides": { + "fast-json-patch": "^3.1.1" + } +} diff --git a/package.json b/package.json index e66a88f..41e17d9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "files": [ "node/", + "!node/block-json/", "LICENSE" ], "exports": { From 90e8d0048988dabb9512ef44b349e01c2267cb1f Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:39:21 +0100 Subject: [PATCH 14/23] Skip CodeQL analysis where Advanced Security is off On a private repository without GitHub Advanced Security, CodeQL's analyze step fails on its SARIF upload, so reusable CodeQL failed every run there, while Workflow Checks already probes for it. CodeQL now runs a8csp-configs' probe first and skips analyze when the code-scanning API answers that Advanced Security is off. An answer that stays unclear after three tries counts as available, unlike Workflow Checks' probe, because a skipped analyze would pass as a green check while zizmor still runs and fails on findings there. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-codeql.yml | 52 ++++++++++++++++++++++++++- docs/workflows.md | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-codeql.yml b/.github/workflows/reusable-codeql.yml index 8279319..d2f5321 100644 --- a/.github/workflows/reusable-codeql.yml +++ b/.github/workflows/reusable-codeql.yml @@ -29,9 +29,59 @@ jobs: exit 1 fi + detect-ghas: + name: Detect GHAS availability + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + security-events: read + outputs: + ghas-available: ${{ steps.detect.outputs.ghas-available }} + steps: + - name: Detect GitHub Advanced Security availability + id: detect + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + # The code-scanning alerts endpoint answers 403 naming Advanced Security exactly where analyze would fail, and an unclear answer is retried and then read as available, because a skipped analyze passes as a green check. + probe_body="${RUNNER_TEMP}/ghas-probe.json" + max_attempts=3 + retry_sleep_seconds=5 + attempt=1 + while :; do + http_code=$(curl -s --max-time 15 -o "$probe_body" -w '%{http_code}' \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${REPO}/code-scanning/alerts?per_page=1") || http_code="curl-error" + + if [ "$http_code" = "200" ]; then + echo "ghas-available=true" >> "$GITHUB_OUTPUT" + break + fi + + if [ "$http_code" = "403" ] && grep -q 'Advanced Security must be enabled' "$probe_body" 2>/dev/null; then + echo "::notice::GitHub Advanced Security is unavailable on this repository — CodeQL analyze is skipped." + echo "ghas-available=false" >> "$GITHUB_OUTPUT" + break + fi + + if [ "$attempt" -lt "$max_attempts" ]; then + attempt=$((attempt + 1)) + sleep "$retry_sleep_seconds" + continue + fi + + echo "::warning::GHAS availability probe was ambiguous after $max_attempts attempts (last status '$http_code') — assuming GHAS is available so analyze runs and a genuine absence fails visibly." + echo "ghas-available=true" >> "$GITHUB_OUTPUT" + break + done + analyze: name: Analyze (${{ matrix.language }}) - needs: validate-inputs + needs: [validate-inputs, detect-ghas] + if: needs.detect-ghas.outputs.ghas-available == 'true' runs-on: ubuntu-latest timeout-minutes: 30 permissions: diff --git a/docs/workflows.md b/docs/workflows.md index ae61a7e..ce2798d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -39,7 +39,7 @@ jobs: ## CodeQL — `reusable-codeql.yml` -Runs CodeQL initialization and analysis as one matrix job per language. CodeQL has no PHP analyzer; PHPStan covers PHP. +Runs CodeQL initialization and analysis as one matrix job per language. CodeQL has no PHP analyzer; PHPStan covers PHP. A probe of the code-scanning API first skips the analysis where GitHub Advanced Security is off, as on a private repository without it, and runs it when the answer stays unclear after three tries, so a repository that lacks it fails visibly rather than passing a skipped check. | Input | Type | Required | Default | Description | | --- | --- | --- | --- | --- | From 284e4e68f1ea99fda6dfff62394b1f5f8e4c033a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:39:36 +0100 Subject: [PATCH 15/23] Check PHP syntax on 8.6 by default The syntax check defaulted to PHP 8.5 alone, so a construct the next PHP deprecates or removes surfaced only when a project raised its PHP. The default now also runs 8.6, which setup-php installs as a nightly build, as a8csp-configs does. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-php-syntax-check.yml | 2 +- docs/workflows.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-php-syntax-check.yml b/.github/workflows/reusable-php-syntax-check.yml index f6e08d0..a2afc5f 100644 --- a/.github/workflows/reusable-php-syntax-check.yml +++ b/.github/workflows/reusable-php-syntax-check.yml @@ -12,7 +12,7 @@ on: description: 'Non-empty JSON array of PHP versions to check (e.g. [\"8.5\",\"8.6\"])' type: string required: false - default: '["8.5"]' + default: '["8.5", "8.6"]' paths: description: 'Non-empty JSON array of files and directories to check, relative to project-path. Directories are searched for *.php files, skipping any vendor/ and node_modules/ inside them.' type: string diff --git a/docs/workflows.md b/docs/workflows.md index ce2798d..c2b5a70 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -89,7 +89,7 @@ Runs `php -l` over the PHP files under `paths`, once per PHP version, without in | Input | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `project-path` | `string` | No | `'.'` | Path to the project, relative to the repository root. | -| `php-versions` | `string` | No | `'["8.5"]'` | Non-empty JSON array of PHP versions, such as `'["8.5", "8.6"]'`. | +| `php-versions` | `string` | No | `'["8.5", "8.6"]'` | Non-empty JSON array of PHP versions; the default adds the next PHP's nightly build. | | `paths` | `string` | No | `'["."]'` | Non-empty JSON array of files and directories to check, relative to `project-path`. | | `artifact` | `string` | No | `''` | Name of an artifact uploaded earlier in the calling workflow to check instead of the repository; `project-path` and `paths` are then relative to its contents. | From e4baaa6c4da709c91d5219c874c6e68c5903a6e1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:40:05 +0100 Subject: [PATCH 16/23] Declare the php-scoper range and the stubs the pipeline reads The scoping pipeline relies on php-scoper 0.18's behavior but declared the range only as its own development requirement, which a consumer's install never sees, so a consumer could scope with another minor. A conflict now holds php-scoper to 0.18, and the README says so. The pipeline also reads jetbrains/phpstorm-stubs directly, which php-scoper 0.18 installs, without naming it; suggest names it beside php-scoper. The composer/* plugin allowance, which matched no installed plugin, goes. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- composer.json | 7 +++++-- composer.lock | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f52ee35..6d2acbb 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The package installs `phpstan-deprecation-rules`, `phpstan-strict-rules`, `szepe `php/php-scoper/scoper-base.inc.php` and `php/composer/ScopePhpDependencies.php` prefix the packages a WordPress plugin bundles with the plugin's own namespace, so plugins that bundle different versions of the same library run side by side. -Require php-scoper in the plugin itself, because Composer does not install this package's development requirements: +Require php-scoper in the plugin itself, because Composer does not install this package's development requirements. This package conflicts with any php-scoper outside 0.18, whose behavior the pipeline relies on: ```json { diff --git a/composer.json b/composer.json index 021cf85..1677dbf 100644 --- a/composer.json +++ b/composer.json @@ -47,8 +47,12 @@ "psr/log": "^3", "symfony/yaml": "^8.1" }, + "conflict": { + "humbug/php-scoper": "<0.18 || >=0.19" + }, "suggest": { - "humbug/php-scoper": "Required for the dependency-scoping pipeline (ScopePhpDependencies + the scoper-base config)." + "humbug/php-scoper": "Required for the dependency-scoping pipeline (ScopePhpDependencies + the scoper-base config).", + "jetbrains/phpstorm-stubs": "The dependency-scoping pipeline reads PHP's own symbols from it, and humbug/php-scoper installs it." }, "minimum-stability": "dev", @@ -110,7 +114,6 @@ "config": { "allow-plugins": { - "composer/*": true, "dealerdirect/phpcodesniffer-composer-installer": true, "infection/extension-installer": true, "phpstan/extension-installer": true diff --git a/composer.lock b/composer.lock index 317573f..64cca08 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0320119089574ccbbf5d2ee2316b8cd6", + "content-hash": "a9e01c0b8d23c929cab9a13533122acd", "packages": [ { "name": "composer/class-map-generator", From 6b41179a43a58b41309ae5f06e0b6b822ae054e8 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:43:25 +0100 Subject: [PATCH 17/23] Simplify the release workflow's shell The release workflow said several things twice or at length: two helper functions behind one project-path check, eight case arms sharing one body, a GITHUB_ENV copy of a step output, a second Node-version check on the commit the build had checked, and Composer installs with differing flags. Each now says it once, and every check keeps its outcome: - the release input checks on project-path are one condition with one message, and a row pins its character-pattern branch; - the eight forbidden-entry arms that shared one body are one arm, the certificate and secret arms keeping their order; - the POT check reads the scoping step's output in its condition, so the job no longer copies that fact into GITHUB_ENV; - the wp.org job takes its version from the build's output and keeps its own tag check, with a line saying why; - the test job drops the Node-version check the build already ran on the same commit; - the release's Composer installs share one flag set. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 96 ++++++-------------------- tests/Unit/ReleaseWorkflowTest.php | 9 +-- 2 files changed, 25 insertions(+), 80 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 6463587..04f3bb7 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -87,33 +87,6 @@ jobs: PROJECT_PATH: ${{ inputs.project-path }} PUBLISH: ${{ inputs.publish }} run: | - # Slash-wrap so a `..` segment anywhere (start, middle, end, or whole) is caught. - has_dotdot_segment() { - case "/$1/" in - */../*) return 0 ;; - *) return 1 ;; - esac - } - - validate_path_input() { - name="$1" - value="$2" - if ! printf '%s' "$value" | grep -Eq '^[A-Za-z0-9._/-]+$'; then - echo "::error::$name must match ^[A-Za-z0-9._/-]+$ — got '$value'." - exit 1 - fi - case "$value" in - /*) - echo "::error::$name must be relative — got '$value'." - exit 1 - ;; - esac - if has_dotdot_segment "$value"; then - echo "::error::$name must not contain a .. path segment — got '$value'." - exit 1 - fi - } - tag_version='' if [ "$REF_TYPE" = "tag" ]; then if ! printf '%s' "$REF_NAME" | grep -Eq '^v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}(-(dev|alpha|beta|rc)(\.(0|[1-9][0-9]*))?)?$'; then @@ -129,7 +102,11 @@ jobs: echo "::error::plugin-slug must match ^[a-z0-9-]+$ — got '$PLUGIN_SLUG'." exit 1 fi - validate_path_input 'project-path' "$PROJECT_PATH" + # Slash-wrapping catches a .. segment at the start, in the middle, at the end or as the whole path. + if ! [[ "$PROJECT_PATH" =~ ^[A-Za-z0-9._/-]+$ ]] || [[ "$PROJECT_PATH" == /* || "/$PROJECT_PATH/" == */../* ]]; then + echo "::error::project-path must be a relative path of [A-Za-z0-9._/-] characters without a .. segment — got '$PROJECT_PATH'." + exit 1 + fi echo "tag-version=$tag_version" >> "$GITHUB_OUTPUT" @@ -243,7 +220,6 @@ jobs: working-directory: ${{ inputs.project-path }} run: | requires_scoped=$(jq -r 'if (.extra["scoping-prefix"] | type) == "string" then "true" else "false" end' composer.json) - echo "REQUIRES_SCOPED=$requires_scoped" >> "$GITHUB_ENV" echo "requires-scoped=$requires_scoped" >> "$GITHUB_OUTPUT" - name: Setup PHP @@ -285,7 +261,7 @@ jobs: tools="$RUNNER_TEMP/release-tools" mkdir -p "$tools" cp "$locked/composer.json" "$locked/composer.lock" "$tools/" - composer install --working-dir="$tools" --no-interaction --no-progress --prefer-dist + composer install --working-dir="$tools" --no-interaction --no-progress --prefer-dist --ignore-platform-req=php+ echo "$tools/vendor/bin" >> "$GITHUB_PATH" - name: Build assets @@ -302,11 +278,10 @@ jobs: wp i18n make-pot . "languages/$PLUGIN_SLUG.pot" --domain="$PLUGIN_SLUG" - name: Verify the POT covers the scoped strings - if: ${{ inputs.generate-pot }} + if: ${{ inputs.generate-pot && steps.scoping.outputs.requires-scoped == 'true' }} working-directory: ${{ inputs.project-path }} run: | - if [ "$REQUIRES_SCOPED" = "true" ] \ - && [ -d vendor-prefixed ] \ + if [ -d vendor-prefixed ] \ && grep -rqF --include='*.php' "'$PLUGIN_SLUG'" vendor-prefixed \ && ! grep -Fq '#: vendor-prefixed/' "languages/$PLUGIN_SLUG.pot"; then echo "::error::vendor-prefixed/ carries strings in the '$PLUGIN_SLUG' text domain, but languages/$PLUGIN_SLUG.pot has none of them." @@ -316,7 +291,7 @@ jobs: # --no-scripts avoids invoking handlers that were just pruned from require-dev. - name: Prune to production dependencies working-directory: ${{ inputs.project-path }} - run: composer install --no-dev --prefer-dist --ignore-platform-req=php+ --optimize-autoloader --no-scripts + run: composer install --no-dev --no-interaction --no-progress --prefer-dist --ignore-platform-req=php+ --optimize-autoloader --no-scripts - name: Verify the headers cover the shipped packages working-directory: ${{ inputs.project-path }} @@ -460,35 +435,14 @@ jobs: status=0 while IFS= read -r entry; do case "$entry" in - "$PLUGIN_SLUG/node_modules/"*|"$PLUGIN_SLUG/"*/node_modules/*|"$PLUGIN_SLUG/tests/"*|"$PLUGIN_SLUG/"*/tests/*|"$PLUGIN_SLUG/vendor/bin/"*|"$PLUGIN_SLUG/"*/vendor/bin/*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/.git/"*|"$PLUGIN_SLUG/.github/"*|"$PLUGIN_SLUG/.idea/"*|"$PLUGIN_SLUG/.vscode/"*|"$PLUGIN_SLUG/.wordpress-org/"*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/.editorconfig"|"$PLUGIN_SLUG/.gitattributes"|"$PLUGIN_SLUG/.gitignore"|"$PLUGIN_SLUG/.distignore"|"$PLUGIN_SLUG/AGENTS.md"|"$PLUGIN_SLUG/CLAUDE.md"|"$PLUGIN_SLUG/changelog/"*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/composer.json"|"$PLUGIN_SLUG/composer.lock"|"$PLUGIN_SLUG/package.json"|"$PLUGIN_SLUG/package-lock.json") - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/phpcs"*.xml*|"$PLUGIN_SLUG/.phpcs"*.xml*|"$PLUGIN_SLUG/phpstan"*.neon*|"$PLUGIN_SLUG/.phpstan"*.neon*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/phpunit"*.xml*|"$PLUGIN_SLUG/.phpunit.result.cache"|"$PLUGIN_SLUG/.phpunit.cache/"*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/eslint.config."*|"$PLUGIN_SLUG/stylelint.config."*|"$PLUGIN_SLUG/tsconfig.json"|"$PLUGIN_SLUG/playwright.config."*) - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/scoper.inc.php"|"$PLUGIN_SLUG/.wp-env"*) + "$PLUGIN_SLUG/node_modules/"*|"$PLUGIN_SLUG/"*/node_modules/*|"$PLUGIN_SLUG/tests/"*|"$PLUGIN_SLUG/"*/tests/*|"$PLUGIN_SLUG/vendor/bin/"*|"$PLUGIN_SLUG/"*/vendor/bin/* \ + |"$PLUGIN_SLUG/.git/"*|"$PLUGIN_SLUG/.github/"*|"$PLUGIN_SLUG/.idea/"*|"$PLUGIN_SLUG/.vscode/"*|"$PLUGIN_SLUG/.wordpress-org/"* \ + |"$PLUGIN_SLUG/.editorconfig"|"$PLUGIN_SLUG/.gitattributes"|"$PLUGIN_SLUG/.gitignore"|"$PLUGIN_SLUG/.distignore"|"$PLUGIN_SLUG/AGENTS.md"|"$PLUGIN_SLUG/CLAUDE.md"|"$PLUGIN_SLUG/changelog/"* \ + |"$PLUGIN_SLUG/composer.json"|"$PLUGIN_SLUG/composer.lock"|"$PLUGIN_SLUG/package.json"|"$PLUGIN_SLUG/package-lock.json" \ + |"$PLUGIN_SLUG/phpcs"*.xml*|"$PLUGIN_SLUG/.phpcs"*.xml*|"$PLUGIN_SLUG/phpstan"*.neon*|"$PLUGIN_SLUG/.phpstan"*.neon* \ + |"$PLUGIN_SLUG/phpunit"*.xml*|"$PLUGIN_SLUG/.phpunit.result.cache"|"$PLUGIN_SLUG/.phpunit.cache/"*|"$PLUGIN_SLUG/infection.json"|"$PLUGIN_SLUG/infection.json."* \ + |"$PLUGIN_SLUG/eslint.config."*|"$PLUGIN_SLUG/stylelint.config."*|"$PLUGIN_SLUG/tsconfig.json"|"$PLUGIN_SLUG/playwright.config."* \ + |"$PLUGIN_SLUG/scoper.inc.php"|"$PLUGIN_SLUG/.wp-env"*) echo "::error::Archive must not contain $entry." status=1 ;; @@ -600,15 +554,6 @@ jobs: coverage: none tools: composer:v2 - # Without a declared version, setup-node only warns and runs the runner's own Node. - - name: Require a Node version in package.json - working-directory: ${{ inputs.project-path }} - run: | - if ! jq -e '[.volta.node, .volta.extends, ([.devEngines.runtime] | flatten[] | objects | select((.name | strings | ascii_downcase) == "node") | .version), .engines.node] | any' package.json > /dev/null; then - echo "::error::package.json declares no Node version — add engines.node, volta.node or a devEngines.runtime entry named node." - exit 1 - fi - - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -837,12 +782,11 @@ jobs: exit 1 fi + # The privileged job checks its own ref instead of trusting the jobs it follows. - name: Re-verify version tag before deploy env: REF_NAME: ${{ github.ref_name }} - run: | - printf '%s' "$REF_NAME" | grep -Eq '^v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}$' || { echo "::error::Refusing to deploy from non-version ref '$REF_NAME'."; exit 1; } - echo "VERSION=${REF_NAME#v}" >> "$GITHUB_ENV" + run: printf '%s' "$REF_NAME" | grep -Eq '^v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){2}$' || { echo "::error::Refusing to deploy from non-version ref '$REF_NAME'."; exit 1; } - name: Verify SVN credentials env: @@ -877,6 +821,6 @@ jobs: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SLUG: ${{ inputs.plugin-slug }} - VERSION: ${{ env.VERSION }} + VERSION: ${{ needs.build.outputs.version }} BUILD_DIR: ./publish/${{ inputs.plugin-slug }} ASSETS_DIR: ./wp-org-assets diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index c769310..978290b 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -175,7 +175,7 @@ public function test_pot_must_cover_the_scoped_strings( string $pot, int $expect ) ); - $result = $this->run_build_step( 'Verify the POT covers the scoped strings', array( 'REQUIRES_SCOPED' => 'true' ) ); + $result = $this->run_build_step( 'Verify the POT covers the scoped strings', array() ); self::assertSame( $expected_status, $result['status'], $result['output'] ); } @@ -550,8 +550,9 @@ public static function provide_release_inputs(): array { 'prerelease-number-with-a-leading-zero' => array( 'tag', 'v1.3.0-beta.01', '.', 'true', 'acme-plugin', 1, 'no leading zeros' ), 'branch-with-publish' => array( 'branch', 'trunk', '.', 'true', 'acme-plugin', 1, 'is not a tag' ), 'branch-without-publish' => array( 'branch', 'trunk', 'plugins/acme-plugin', 'false', 'acme-plugin', 0, '' ), - 'dot-dot-path-segment' => array( 'tag', 'v1.2.3', 'plugins/../acme-plugin', 'true', 'acme-plugin', 1, 'must not contain a .. path segment' ), - 'absolute-path' => array( 'tag', 'v1.2.3', '/tmp/acme-plugin', 'true', 'acme-plugin', 1, 'must be relative' ), + 'dot-dot-path-segment' => array( 'tag', 'v1.2.3', 'plugins/../acme-plugin', 'true', 'acme-plugin', 1, 'project-path must be a relative path' ), + 'absolute-path' => array( 'tag', 'v1.2.3', '/tmp/acme-plugin', 'true', 'acme-plugin', 1, 'project-path must be a relative path' ), + 'path-out-of-pattern' => array( 'tag', 'v1.2.3', 'plugins/acme plugin', 'true', 'acme-plugin', 1, 'project-path must be a relative path' ), 'slug-out-of-pattern' => array( 'tag', 'v1.2.3', '.', 'true', 'Acme_Plugin', 1, 'plugin-slug must match' ), ); } @@ -714,7 +715,7 @@ public function test_the_release_tools_install_from_the_lock_in_the_plugin_s_con self::assertSame( 0, $result['status'], $result['output'] ); $tools = $this->work_dir . '/runner-temp/release-tools'; - self::assertSame( array( "composer install --working-dir=$tools --no-interaction --no-progress --prefer-dist" ), \file( $this->work_dir . '/calls', \FILE_IGNORE_NEW_LINES ) ); + self::assertSame( array( "composer install --working-dir=$tools --no-interaction --no-progress --prefer-dist --ignore-platform-req=php+" ), \file( $this->work_dir . '/calls', \FILE_IGNORE_NEW_LINES ) ); self::assertFileEquals( $this->work_dir . '/project/vendor/ahegyes/wordpress-configs/php/release/composer.lock', "$tools/composer.lock" ); self::assertSame( "$tools/vendor/bin\n", \file_get_contents( $this->work_dir . '/github-path' ) ); } From 0774e477777e11ec026b3a6ce44deea3c3fd54a0 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:44:55 +0100 Subject: [PATCH 18/23] Write the workflows' inline PHP in the repository's style The release's floor check and the Composer audit's classifier were written in a compact style the repository's PHP does not use: [] arrays, no spaces inside parentheses, and array_filter and array_map closures where a loop reads plainer. Both now use array(), WPCS spacing, Yoda comparisons against preg_match() and a foreach for the floor messages, and the classifier moves from php -r into a heredoc, so its strings take single quotes. Messages and exit statuses are unchanged; extracted, both scripts pass WPCS but for the short ternary configs' base allows and the WordPress-globals sniff, which a CI script never meets. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 118 ++++++++++-------- .../workflows/reusable-supply-chain-audit.yml | 29 +++-- 2 files changed, 85 insertions(+), 62 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 04f3bb7..a7eaf57 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -299,95 +299,115 @@ jobs: vendor_dir=$(composer config vendor-dir) VENDOR_DIR="$vendor_dir" php <<'PHP' preg_match('/^[ \t\/*#@]*' . preg_quote($name, '/') . ':[ \t]*(.*?)[ \t]*$/mi', $source, $match) ? $match[1] : ''; + $entry_file = (string) getenv( 'ENTRY_FILE' ); + $source = (string) file_get_contents( $entry_file ); + $header = static fn ( string $name ): string => 1 === preg_match( '/^[ \t\/*#@]*' . preg_quote( $name, '/' ) . ':[ \t]*(.*?)[ \t]*$/mi', $source, $match ) ? $match[1] : ''; // Three numeric segments, so version_compare() reads 8.5 and 8.5.0 alike. - $normalize = static fn (string $version): string => implode('.', array_pad(array_slice(array_map('intval', explode('.', $version)), 0, 3), 3, 0)); + $normalize = static fn ( string $version ): string => implode( '.', array_pad( array_slice( array_map( 'intval', explode( '.', $version ) ), 0, 3 ), 3, 0 ) ); // Composer's platform-check reading: the lowest version an alternative admits, and the lowest across alternatives. - $lower_bound = static function (string $constraint) use ($normalize): ?string { + $lower_bound = static function ( string $constraint ) use ( $normalize ): ?string { $lowest = null; - foreach (preg_split('/\s*\|\|?\s*/', trim($constraint)) as $alternative) { - $bound = '0.0.0'; - $alternative = (string) preg_replace(['/(?<=[<>=~^!])\s+/', '/\s+-\s+\S+/'], '', $alternative); - foreach (preg_split('/[\s,]+/', $alternative) as $part) { - if ('*' === $part || preg_match('/^(?:<|<=|!=)/', $part)) { + foreach ( preg_split( '/\s*\|\|?\s*/', trim( $constraint ) ) as $alternative ) { + $bound = '0.0.0'; + $alternative = (string) preg_replace( array( '/(?<=[<>=~^!])\s+/', '/\s+-\s+\S+/' ), '', $alternative ); + foreach ( preg_split( '/[\s,]+/', $alternative ) as $part ) { + if ( '*' === $part || 1 === preg_match( '/^(?:<|<=|!=)/', $part ) ) { continue; } - if (!preg_match('/^(?:>=|>|==?|\^|~)?v?(\d+(?:\.\d+)*)(?:\.[*x])?(?:-[a-z]+[.\d]*)?(?:@\w+)?$/i', $part, $match)) { + + if ( 1 !== preg_match( '/^(?:>=|>|==?|\^|~)?v?(\d+(?:\.\d+)*)(?:\.[*x])?(?:-[a-z]+[.\d]*)?(?:@\w+)?$/i', $part, $match ) ) { return null; } - if (version_compare($normalize($match[1]), $bound, '>')) { - $bound = $normalize($match[1]); + + if ( version_compare( $normalize( $match[1] ), $bound, '>' ) ) { + $bound = $normalize( $match[1] ); } } - if (null === $lowest || version_compare($bound, $lowest, '<')) { + + if ( is_null( $lowest ) || version_compare( $bound, $lowest, '<' ) ) { $lowest = $bound; } } + return $lowest; }; // 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 = array(); + $project = is_file( 'composer.json' ) ? json_decode( (string) file_get_contents( 'composer.json' ), true ) : null; + if ( is_array( $project ) ) { $packages[] = $project; } - $installed_file = getenv('VENDOR_DIR') . '/composer/installed.json'; - if (is_file($installed_file)) { - $installed = json_decode((string) file_get_contents($installed_file), true); - foreach ($installed['packages'] ?? [] as $package) { - if (!in_array($package['name'], $installed['dev-package-names'] ?? [], true)) { + + $installed_file = getenv( 'VENDOR_DIR' ) . '/composer/installed.json'; + if ( is_file( $installed_file ) ) { + $installed = json_decode( (string) file_get_contents( $installed_file ), true ); + foreach ( $installed['packages'] ?? array() as $package ) { + if ( ! in_array( $package['name'], $installed['dev-package-names'] ?? array(), true ) ) { $packages[] = $package; } } } - foreach (glob('vendor-prefixed/*/*/composer.json') ?: [] as $manifest) { - $packages[] = json_decode((string) file_get_contents($manifest), true); + + foreach ( glob( 'vendor-prefixed/*/*/composer.json' ) ?: array() as $manifest ) { + $packages[] = json_decode( (string) file_get_contents( $manifest ), true ); } - $errors = []; - $floors = ['Requires PHP' => [], 'Requires at least' => [], 'WC requires at least' => []]; - $plugins = []; - foreach ($packages as $package) { + $errors = array(); + $floors = array( + 'Requires PHP' => array(), + 'Requires at least' => array(), + 'WC requires at least' => array(), + ); + $plugins = array(); + foreach ( $packages as $package ) { $name = $package['name'] ?? '(unnamed)'; - if (is_string($package['require']['php'] ?? null)) { - $bound = $lower_bound($package['require']['php']); - if (null === $bound) { + if ( is_string( $package['require']['php'] ?? null ) ) { + $bound = $lower_bound( $package['require']['php'] ); + if ( is_null( $bound ) ) { $errors[] = "Cannot read the PHP constraint '{$package['require']['php']}' of $name."; } else { - $floors['Requires PHP'][$name] = $bound; + $floors['Requires PHP'][ $name ] = $bound; } } - if (is_string($package['extra']['requires-wp'] ?? null)) { - $floors['Requires at least'][$name] = $normalize($package['extra']['requires-wp']); + + if ( is_string( $package['extra']['requires-wp'] ?? null ) ) { + $floors['Requires at least'][ $name ] = $normalize( $package['extra']['requires-wp'] ); } - if (is_string($package['extra']['requires-wc'] ?? null)) { - $floors['WC requires at least'][$name] = $normalize($package['extra']['requires-wc']); + + if ( is_string( $package['extra']['requires-wc'] ?? null ) ) { + $floors['WC requires at least'][ $name ] = $normalize( $package['extra']['requires-wc'] ); } - foreach ((array) ($package['extra']['requires-plugins'] ?? []) as $slug) { - $plugins[(string) $slug][] = $name; + + foreach ( (array) ( $package['extra']['requires-plugins'] ?? array() ) as $slug ) { + $plugins[ (string) $slug ][] = $name; } } - foreach ($floors as $header_name => $bounds) { - $declared = $header($header_name); - $above = array_filter($bounds, static fn (string $bound): bool => version_compare($bound, $normalize($declared), '>')); - if ([] !== $above) { - $needs = implode(', ', array_map(static fn (string $package, string $bound): string => "$package ($bound)", array_keys($above), $above)); - $errors[] = "$entry_file $header_name '$declared' is below what shipped packages require: $needs."; + foreach ( $floors as $header_name => $bounds ) { + $declared = $header( $header_name ); + $needs = array(); + foreach ( $bounds as $package => $bound ) { + if ( version_compare( $bound, $normalize( $declared ), '>' ) ) { + $needs[] = "$package ($bound)"; + } + } + + if ( array() !== $needs ) { + $errors[] = "$entry_file $header_name '$declared' is below what shipped packages require: " . implode( ', ', $needs ) . '.'; } } - $declared_plugins = array_map('trim', explode(',', $header('Requires Plugins'))); - foreach (array_diff_key($plugins, array_flip($declared_plugins)) as $slug => $names) { - $errors[] = "$entry_file Requires Plugins lacks '$slug', which " . implode(', ', $names) . ' requires.'; + + $declared_plugins = array_map( 'trim', explode( ',', $header( 'Requires Plugins' ) ) ); + foreach ( array_diff_key( $plugins, array_flip( $declared_plugins ) ) as $slug => $names ) { + $errors[] = "$entry_file Requires Plugins lacks '$slug', which " . implode( ', ', $names ) . ' requires.'; } - foreach ($errors as $error) { + foreach ( $errors as $error ) { echo "::error::$error\n"; } - exit([] === $errors ? 0 : 1); + + exit( array() === $errors ? 0 : 1 ); PHP - name: Create plugin archive diff --git a/.github/workflows/reusable-supply-chain-audit.yml b/.github/workflows/reusable-supply-chain-audit.yml index 7a89b3a..19a82e5 100644 --- a/.github/workflows/reusable-supply-chain-audit.yml +++ b/.github/workflows/reusable-supply-chain-audit.yml @@ -86,22 +86,25 @@ jobs: exit 0 fi - # shellcheck disable=SC2016 - findings=$(AUDIT_JSON="$audit_json" php -r ' - $data = json_decode((string) file_get_contents(getenv("AUDIT_JSON")), true); - if (!is_array($data) || !array_key_exists("advisories", $data)) { - exit(1); + findings=$(AUDIT_JSON="$audit_json" php <<'PHP' + $entries) { - $filtered[] = $package . " (" . implode(", ", array_unique(array_column((array) $entries, "listName"))) . ")"; + + $filtered = array(); + foreach ( (array) ( $data['filter'] ?? array() ) as $package => $entries ) { + $filtered[] = $package . ' (' . implode( ', ', array_unique( array_column( (array) $entries, 'listName' ) ) ) . ')'; } - if ($filtered !== []) { - echo "filter:", implode(", ", $filtered); - } elseif (count((array) $data["advisories"]) + count((array) ($data["abandoned"] ?? [])) + count((array) ($data["cooldown"] ?? [])) > 0) { - echo "findings"; + + if ( array() !== $filtered ) { + echo 'filter:', implode( ', ', $filtered ); + } elseif ( count( (array) $data['advisories'] ) + count( (array) ( $data['abandoned'] ?? array() ) ) + count( (array) ( $data['cooldown'] ?? array() ) ) > 0 ) { + echo 'findings'; } - ') || findings="invalid" + PHP + ) || findings="invalid" case "$findings" in filter:*) From 07a644751d2fbb94d6b0e39dcbd27c29b606ace5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:45:23 +0100 Subject: [PATCH 19/23] Simplify the supply-chain audit's exit handling Both audit steps toggled errexit around the audit to capture its status, and the npm step classified its report with a Node heredoc chained to an error block. Each step now captures the status with `|| status=$?`, and the npm report is classified by one jq expression with the same outcomes: findings in a readable report are softened, while an error report or an unreadable one fails. Quality's PHPCS smoke drops a `set -e -o pipefail` that its `shell: bash` already applies. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/quality.yml | 1 - .../workflows/reusable-supply-chain-audit.yml | 34 ++++--------------- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8fc5742..ddefe45 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -73,7 +73,6 @@ jobs: - name: Scan fixture with php/quality-assurance/phpcs.dist.xml shell: bash run: | - set -e -o pipefail # The production profile skips */tests/*, so a temp copy is scanned outside that path. cp -R tests/fixtures/plugin-stub "$RUNNER_TEMP/plugin-stub" vendor/bin/phpcs -v --standard=php/quality-assurance/phpcs.dist.xml "$RUNNER_TEMP/plugin-stub" | tee phpcs-scan.log diff --git a/.github/workflows/reusable-supply-chain-audit.yml b/.github/workflows/reusable-supply-chain-audit.yml index 19a82e5..a689113 100644 --- a/.github/workflows/reusable-supply-chain-audit.yml +++ b/.github/workflows/reusable-supply-chain-audit.yml @@ -74,12 +74,10 @@ jobs: FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} run: | audit_json="$(mktemp)" - set +e + status=0 # Composer has no distinct findings exit code, so the JSON report decides; advisory mode softens advisories and abandoned packages, never a filter-list match. # shellcheck disable=SC2086 - composer audit --locked $AUDIT_FLAGS --format=json > "$audit_json" - status=$? - set -e + composer audit --locked $AUDIT_FLAGS --format=json > "$audit_json" || status=$? cat "$audit_json" if [ "$status" -eq 0 ]; then @@ -144,11 +142,9 @@ jobs: FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} run: | audit_json="$(mktemp)" - set +e + status=0 # shellcheck disable=SC2086 - npm audit --package-lock-only $AUDIT_FLAGS --json > "$audit_json" - status=$? - set -e + npm audit --package-lock-only $AUDIT_FLAGS --json > "$audit_json" || status=$? cat "$audit_json" if [ "$status" -eq 0 ]; then @@ -159,25 +155,7 @@ jobs: exit "$status" fi - AUDIT_JSON="$audit_json" node <<'NODE' || { - const fs = require('fs'); - - let data; - try { - data = JSON.parse(fs.readFileSync(process.env.AUDIT_JSON, 'utf8')); - } catch { - process.exit(1); - } - - if (data.error || !data.metadata || !data.metadata.vulnerabilities) { - process.exit(1); - } - - const total = Object.values(data.metadata.vulnerabilities).reduce((sum, value) => sum + (Number(value) || 0), 0); - process.exit(total > 0 ? 0 : 1); - NODE - echo "::error::npm audit failed before producing a valid findings report." - exit "$status" - } + # Only a report that counts vulnerabilities is softened, while an error or an unreadable report fails. + jq -e '(.error | not) and ([.metadata.vulnerabilities // {} | .[] | numbers] | add // 0) > 0' "$audit_json" > /dev/null 2>&1 || { echo "::error::npm audit failed before producing a valid findings report."; exit "$status"; } echo "::warning::npm audit reported findings; fail-on-findings=false leaves this advisory-only." From dc267a54bde7afc64696a3c156d125d865f76385 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:54:06 +0100 Subject: [PATCH 20/23] Exclude every file the release forbids from the baseline .distignore The release's archive check gained AGENTS.md, CLAUDE.md, changelog/, every phpstan*.neon* and .phpstan*.neon* file, PHPUnit's result cache and any .key file under vendor/, but the baseline consumers copy excluded none of them, so a project built from the baseline failed its release on a file the baseline let through, such as PHPStan's own phpstan.neon.dist. The baseline now excludes each of them, and *.key at any depth, as the release requires; .pem stays root-anchored, so certificate bundles under vendor/ still ship. A wp dist-archive build of a probe plugin from the baseline passes the release's check. Assisted-by: Claude Code:claude-opus-5-5 --- .distignore | 13 +++++++++---- README.md | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.distignore b/.distignore index 2776436..1b735f9 100644 --- a/.distignore +++ b/.distignore @@ -9,6 +9,9 @@ .gitattributes .gitignore .distignore +AGENTS.md +CLAUDE.md +/changelog node_modules tests vendor/bin @@ -18,9 +21,11 @@ package.json package-lock.json .phpcs*.xml* phpcs*.xml* -.phpstan.neon -phpstan.dist.neon +.phpstan*.neon* +phpstan*.neon* phpunit*.xml* +.phpunit.result.cache +.phpunit.cache infection.json* eslint.config.* stylelint.config.* @@ -31,10 +36,10 @@ scoper.inc.php .wordpress-org # Secret material — never ship, even when a consumer forgets to list it. -# .pem/.key are root-anchored (leading /) so certs bundled under vendor/ (e.g. CA bundles) still ship. +# .pem is root-anchored (leading /) so certs bundled under vendor/ (e.g. CA bundles) still ship. .env* /*.pem -/*.key +*.key id_rsa* .npmrc auth.json diff --git a/README.md b/README.md index 6d2acbb..0f27906 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ The Playwright factory sets `testDir` to `tests/e2e/` and artifacts under `tests ## Distribution and editor files -`.distignore` is the baseline of `wp dist-archive` exclusions: copy it to the project root and extend it with project-specific source paths. `reusable-release.yml` requires the file. It excludes tests, package-manager manifests, CI and IDE files, development configs such as every `phpcs*.xml*` ruleset, and secret material (`.env*`, root-anchored `*.pem` and `*.key`, `id_rsa*`, `.npmrc`, `auth.json`). +`.distignore` is the baseline of `wp dist-archive` exclusions: copy it to the project root and extend it with project-specific source paths. `reusable-release.yml` requires the file. It excludes tests, package-manager manifests, CI and IDE files, development configs such as every `phpcs*.xml*` ruleset, and secret material (`.env*`, root-anchored `*.pem`, `*.key`, `id_rsa*`, `.npmrc`, `auth.json`). `.editorconfig` follows WordPress core: tabs by default, two spaces for YAML and JSON, trailing whitespace kept in Markdown, and CRLF line endings for `*.txt`. From d3c4033cad7e99408f575f2f5c65f676c5ed3b9b Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:54:06 +0100 Subject: [PATCH 21/23] Read the artifact test's port raw and state what the deploy checks The site check's port lookup lost jq's -r flag when it began merging the override, so a port written as a string would reach curl in quotes. The wp.org job's comment also said it trusts nothing the earlier jobs found, though it takes its version from the build; it now says what its ref check guards. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index a7eaf57..0bc677c 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -658,7 +658,7 @@ jobs: if [ -f "${config%.json}.override.json" ]; then files+=("${config%.json}.override.json") fi - port=$(jq -s 'reduce .[] as $file ({}; . * $file) | .env.development.port // .port // 8888' "${files[@]}") + port=$(jq -r -s 'reduce .[] as $file ({}; . * $file) | .env.development.port // .port // 8888' "${files[@]}") curl --fail --silent --show-error --output /dev/null "http://localhost:${port}/" - name: Run E2E tests against built artifact @@ -802,7 +802,7 @@ jobs: exit 1 fi - # The privileged job checks its own ref instead of trusting the jobs it follows. + # The deploy re-checks its own ref, so no change to the build's checks lets a branch or prerelease ref reach SVN. - name: Re-verify version tag before deploy env: REF_NAME: ${{ github.ref_name }} From 4ab1f3831a49ca74eefa84e38d088c9ca3ac4ebe Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:54:06 +0100 Subject: [PATCH 22/23] Separate a test's statements and drop a comment narrating a fake A port test ran a statement straight after a closing brace, and the CodeQL test explained its curl fake in a comment its sibling fakes do without. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Unit/CodeqlWorkflowTest.php | 1 - tests/Unit/ReleaseWorkflowTest.php | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/CodeqlWorkflowTest.php b/tests/Unit/CodeqlWorkflowTest.php index 07f6fd6..73b8c28 100644 --- a/tests/Unit/CodeqlWorkflowTest.php +++ b/tests/Unit/CodeqlWorkflowTest.php @@ -23,7 +23,6 @@ public static function provide_probe_answers(): array { #[DataProvider( 'provide_probe_answers' )] public function test_analyze_is_skipped_only_where_advanced_security_is_off( string $code, string $body, string $available, string $annotation ): void { - // Like curl, the stub writes the answer's body to the -o file and prints the status -w asks for. $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\necho call >> \"\$HOME/curl-calls\"\nwhile [ \$# -gt 0 ]; do\n\tcase \"\$1\" in\n\t\t-o) printf '%s' " . \escapeshellarg( $body ) . " > \"\$2\"; shift 2 ;;\n\t\t*) shift ;;\n\tesac\ndone\nprintf '%s' " . \escapeshellarg( $code ) . "\n" ); $this->write_executable( 'bin/sleep', "#!/usr/bin/env bash\n" ); \mkdir( $this->work_dir . '/runner-temp' ); diff --git a/tests/Unit/ReleaseWorkflowTest.php b/tests/Unit/ReleaseWorkflowTest.php index 978290b..55d8877 100644 --- a/tests/Unit/ReleaseWorkflowTest.php +++ b/tests/Unit/ReleaseWorkflowTest.php @@ -848,6 +848,7 @@ public function test_the_site_is_requested_on_the_effective_development_port( st if ( ! \is_null( $override ) ) { $this->write_project( array( '.wp-env.override.json' => $override ) ); } + $this->write_executable( 'bin/curl', "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$HOME/curl-arguments\"\n" ); $result = $this->run_step( 'reusable-release.yml', 'test', 'Assert the site serves', array( 'WP_ENV_CONFIG_FILE' => '' ) ); From ad9b98cf391228040660a35f53f0d3e7921fca92 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Fri, 6 Nov 2026 17:57:14 +0100 Subject: [PATCH 23/23] Let a wp.org plugin's prerelease pass Plugin Check's readme check A prerelease leaves readme.txt's Stable tag at the last stable release, but with wp-org on the build runs Plugin Check's readme check, which reports a Stable tag that differs from the header's Version as the stable_tag_mismatch error, so every prerelease of a wp.org plugin failed its build. The build now passes that code to Plugin Check's ignore list when the header's version is a prerelease. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/reusable-release.yml | 2 ++ docs/workflows.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 0bc677c..84d1866 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -502,6 +502,8 @@ jobs: build-dir: ${{ inputs.project-path }}/plugin-check-build/${{ inputs.plugin-slug }} slug: ${{ inputs.plugin-slug }} exclude-checks: ${{ steps.excluded.outputs.checks }} + # A prerelease leaves the Stable tag at the last stable release, which the readme check reports as a mismatch. + ignore-codes: ${{ contains(steps.versions.outputs.version, '-') && 'stable_tag_mismatch' || '' }} # An empty token skips the pull request comment, which this job's token has no permission to post. repo-token: '' diff --git a/docs/workflows.md b/docs/workflows.md index c2b5a70..6d956ec 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -242,7 +242,7 @@ The `build` job fails when: A strict `>` lower bound in a package's PHP constraint is read as inclusive. The version is taken from the tag and verified against the files, never stamped in. -A prerelease tag publishes a GitHub prerelease, which GitHub's latest release skips, so only sites on a prerelease update channel are offered it. It never deploys to wp.org, which offers sites only the `Stable tag:` of `readme.txt`, so that header keeps naming the last stable release. +A prerelease tag publishes a GitHub prerelease, which GitHub's latest release skips, so only sites on a prerelease update channel are offered it. It never deploys to wp.org, which offers sites only the `Stable tag:` of `readme.txt`, so that header keeps naming the last stable release, and Plugin Check ignores its `stable_tag_mismatch` for a prerelease version. The artifact test needs the wp-env config to map `wp-content/plugins/` to the plugin source: it remaps exactly that `mappings` key at the built zip and pins the latest stable WordPress core, at the root and in the development and tests environments, through the config's override file, merged into any override the project keeps, so co-mounted plugins, the port and lifecycle scripts survive. It requests the site on the development port the config and its override set.