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/.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 c646c74..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 @@ -90,12 +89,27 @@ 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 + + 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 + + supply-chain-block-json-tools: + name: Supply-chain audit (block.json tools) permissions: contents: read uses: ./.github/workflows/reusable-supply-chain-audit.yml with: - npm-audit-flags: '--omit=dev --audit-level=high' + project-path: node/block-json + composer-audit: false block-json: name: block.json schema check 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/.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/.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/.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/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 48420a1..84d1866 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: @@ -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: @@ -86,37 +87,10 @@ 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}$'; 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}" @@ -128,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" @@ -191,14 +169,39 @@ 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 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: | @@ -217,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 @@ -259,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 @@ -276,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." @@ -290,98 +291,123 @@ 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 }} run: | - php <<'PHP' + 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; } - if (is_file('vendor/composer/installed.json')) { - $installed = json_decode((string) file_get_contents('vendor/composer/installed.json'), 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' => []]; - $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'] ); } - 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 @@ -409,27 +435,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 }} @@ -438,41 +455,19 @@ 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") - 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.dist.neon") - echo "::error::Archive must not contain $entry." - status=1 - ;; - "$PLUGIN_SLUG/phpunit"*.xml*|"$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 ;; # 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" \ @@ -507,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: '' @@ -579,15 +576,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: @@ -618,9 +606,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" @@ -664,9 +654,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 -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 @@ -727,14 +721,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: + attestations: write contents: write + id-token: write 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: @@ -751,24 +743,27 @@ 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 }} - 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) - 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 + # GitHub's latest release, which the stable update channel reads, skips prereleases. + if [[ "$VERSION" == *-* ]]; then + args+=(--prerelease) + 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) @@ -778,7 +773,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 @@ -808,12 +804,11 @@ jobs: exit 1 fi + # 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 }} - 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: @@ -848,6 +843,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/.github/workflows/reusable-supply-chain-audit.yml b/.github/workflows/reusable-supply-chain-audit.yml index 4a31d52..a689113 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 @@ -74,34 +74,35 @@ 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 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"] ?? [])) > 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:*) @@ -141,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 @@ -156,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." diff --git a/.github/workflows/reusable-workflow-checks.yml b/.github/workflows/reusable-workflow-checks.yml index 5064089..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 @@ -82,7 +103,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/.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/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/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/README.md b/README.md index f52ee35..0f27906 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 { @@ -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`. 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", diff --git a/docs/scripts-contract.md b/docs/scripts-contract.md index 6b1147e..7296e88 100644 --- a/docs/scripts-contract.md +++ b/docs/scripts-contract.md @@ -101,9 +101,9 @@ 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. | -| `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. | +| 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. | | `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. | @@ -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 0eaa057..6d956ec 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -15,13 +15,13 @@ 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. ## 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 | | --- | --- | --- | --- | --- | @@ -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 | | --- | --- | --- | --- | --- | @@ -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. | @@ -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 @@ -217,35 +219,40 @@ 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. -- `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. +- `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 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. +`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: -- 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`; +- `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`; -- 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`. +- `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`, 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. -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. +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. `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. The `wp-org` job reads `SVN_USERNAME` and `SVN_PASSWORD` from the calling repository's `wp-org-release` environment directly, because environment secrets cannot pass through `workflow_call`. Create that environment, store the two secrets there and only there, and attach its deployment-protection rules, such as required reviewers; a preflight step fails the deploy with instructions when they are missing. -The caller grants `actions: read` and `contents: write`. [The scripts contract](scripts-contract.md#release) lists the files and scripts the workflow expects. +A private or internal repository's release carries no attestation, because GitHub offers attestations there only on Enterprise Cloud. `gh attestation verify --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 @@ -262,7 +269,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 @@ -301,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`. @@ -314,13 +323,15 @@ 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` -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`. 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-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/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": { 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" } diff --git a/tests/README.md b/tests/README.md index 3048e40..6b35070 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,7 +9,8 @@ The tests prove that the shared configs, the scoping pipeline and the reusable w - `PhpstanConfigTest` analyzes fixtures with the shared PHPStan rules: level 10 findings, nested `vendor/` directories scanned but not analyzed, and the types `@global` tags declare. - `ScopePhpDependenciesTest` scopes `fixtures/scoping-project/` end to end and checks the output, the generated autoloader, the recipes, the `function_exists()` checks php-scoper adds to guards of global functions, and every failure the scope run reports. Its dompdf render test is in the `slow` group. - `ScopedPackagesTest` resolves the packages to scope from inline packages through each link Composer records: a replaced dependency, a provided virtual package, a Composer plugin, a metapackage, and a requirement the root replaces or provides. `Support/RecordsInstalledPackagesTrait.php` writes `installed.json` and `installed.php` for both tests, as Composer does. - - `PhpunitWorkflowTest`, `PluginCheckWorkflowTest`, `ReleaseWorkflowTest` and `SupplyChainAuditWorkflowTest` run steps of `reusable-phpunit.yml`, `reusable-plugin-check.yml`, `reusable-release.yml` and `reusable-supply-chain-audit.yml`. `Support/RunsWorkflowStepsTrait.php` loads a workflow with `symfony/yaml`, finds a step by its job and `name`, and runs its script with `bash -e`, as GitHub Actions does for a step that names no shell. Renaming a tested step breaks its test. + - `BlockJsonCheckWorkflowTest`, `CodeqlWorkflowTest`, `PhpunitWorkflowTest`, `PlaywrightWorkflowTest`, `PluginCheckWorkflowTest`, `ReleaseWorkflowTest`, `SupplyChainAuditWorkflowTest` and `WorkflowChecksWorkflowTest` run steps of the reusable workflows they are named after. `Support/RunsWorkflowStepsTrait.php` loads a workflow with `symfony/yaml`, finds a step by its job and `name`, runs its script with `bash -e`, as GitHub Actions does for a step that names no shell, and reads the outputs and environment it writes, multi-line values included. Renaming a tested step breaks its test. + - `PhpunitWorkflowTest` also checks that the end-to-end workflow resolves the site's PHP with the PHPUnit workflow's own step, and `BlockJsonCheckWorkflowTest` that `node/block-json/`'s lock holds the ajv-cli the check runs. - **CI smokes** (`.github/workflows/quality.yml`). Checks that run a shared config as a consumer would: - the PHPCS production profile explains itself with `phpcs -e` and scans `fixtures/plugin-stub/`; - `npm run lint:config` loads each Node baseline against the installed toolchain through `fixtures/node-config/smoke.mjs`; @@ -32,7 +33,7 @@ This repository's own CI runs the PHP lint, PHP syntax, PHPUnit, scripts lint, s | Fixture | Used by | | --- | --- | | `block-json/` | The block.json check: one schema-valid block. | -| `composer-audit/` | `SupplyChainAuditWorkflowTest`: reports captured from real Composer runs, one with a malware filter-list match and one with an advisory. | +| `composer-audit/` | `SupplyChainAuditWorkflowTest`: reports captured from real Composer runs, one with a malware filter-list match, one with an advisory and one with a package in its cooldown. | | `build-fixture/` | The older-PHP syntax lane and Plugin Check: a GitHub-distributed build with a scoped package under `vendor-prefixed/`. | | `node-config/` | `npm run lint:config`: the smoke harness and its probe inputs. | | `phpstan/` | `PhpstanConfigTest`: a level 10 probe, a project with nested vendor directories and a `@global` probe. | diff --git a/tests/Support/RunsWorkflowStepsTrait.php b/tests/Support/RunsWorkflowStepsTrait.php index dc68eaa..4508930 100644 --- a/tests/Support/RunsWorkflowStepsTrait.php +++ b/tests/Support/RunsWorkflowStepsTrait.php @@ -101,9 +101,21 @@ protected function run_step( string $workflow, string $job, string $name, array $pairs = array(); foreach ( $files as $key => $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..73b8c28 --- /dev/null +++ b/tests/Unit/CodeqlWorkflowTest.php @@ -0,0 +1,50 @@ + + */ + 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 { + $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..55d8877 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 */ @@ -142,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'] ); } @@ -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,19 @@ 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, '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' ), ); } @@ -446,25 +579,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 +636,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++ ) { @@ -546,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' ) ); } @@ -590,8 +759,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 +820,35 @@ 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" + } + } +}