test(core): freeze the direct superglobal int casts as a ratchet (#1087 passo 2) #1595
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| # `release/**` so 6.6.x hotfix PRs (and any future minor-line | |
| # maintenance branch) get the same PHPStan + PHPUnit + WPCS + | |
| # coverage-floor gates as PRs targeting `main`. Without this, | |
| # hotfix PRs were merging with zero CI signal. | |
| # `develop` so the batched-release integration branch enforces the | |
| # same gates per PR — develop must stay deployable to the testes site. | |
| branches: [main, develop, "release/**"] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| lint: | |
| name: PHPStan (PHP 8.3) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup-composer | |
| with: | |
| php-version: '8.3' | |
| - name: Run PHPStan | |
| run: vendor/bin/phpstan analyze | |
| # Level 9, reported only for the classes that read rows out of $wpdb. | |
| # | |
| # BLOCKING, with an allowance of zero, since #1075 took the last file to | |
| # zero. It ran with `continue-on-error` while #1060 worked the count down | |
| # from 243 — a blocking job red on every PR teaches people to ignore it — | |
| # and the flag came off at zero, the same evidence-gated shape as the | |
| # post-deploy smoke. | |
| # | |
| # The allowance stays at 0. A new `mixed` reaching a typed parameter in one | |
| # of these classes is the #1058 defect, which passed CI, PHPStan level 8 and | |
| # 7.500 tests; raise the number only to record a deliberate, argued | |
| # exception, never to get a red PR green. | |
| rows: | |
| name: Row shapes (level 9) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup-composer | |
| with: | |
| php-version: '8.3' | |
| - name: Analyse at level 9 | |
| # Level 9 reports the whole repository; a non-zero exit here is | |
| # expected and says nothing on its own. The report script is what | |
| # decides. | |
| run: | | |
| vendor/bin/phpstan analyse -c phpstan-rows.neon.dist \ | |
| --error-format=json --no-progress --memory-limit=2G > rows.json || true | |
| - name: Report the row-reading classes | |
| run: php .github/scripts/phpstan-rows-report.php rows.json 0 | |
| audit: | |
| name: Composer audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: shivammathur/setup-php@v2 | |
| with: | |
| php-version: '8.3' | |
| tools: composer:v2 | |
| coverage: none | |
| - name: Validate composer.json / composer.lock are in sync | |
| run: composer validate --strict --no-check-publish | |
| - name: Run composer audit | |
| # `composer audit` hits packagist.org/api/security-advisories/. | |
| # That endpoint has occasional 10-second DNS timeouts from | |
| # GitHub Actions runners (curl error 28). Retry up to 3 times | |
| # before failing the job — the audit is read-only, retrying | |
| # is safe. | |
| run: | | |
| for attempt in 1 2 3; do | |
| if composer audit --locked --no-interaction; then | |
| exit 0 | |
| fi | |
| echo "::warning::composer audit attempt $attempt failed; retrying in 15s" | |
| sleep 15 | |
| done | |
| echo "::error::composer audit failed after 3 attempts" | |
| exit 1 | |
| test: | |
| # 6.6.5 — minimum PHP bumped 8.1 → 8.3. Matrix went 4 versions | |
| # (8.1, 8.2, 8.3, 8.4) → 2 (8.3, 8.4). 8.1 / 8.2 visitors are | |
| # capped at 6.6.4 by WordPress's `Requires PHP` enforcement. | |
| name: PHPUnit (PHP ${{ matrix.php }}) | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| php: ['8.3', '8.4'] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup-composer | |
| with: | |
| php-version: ${{ matrix.php }} | |
| - name: Run PHPUnit | |
| run: vendor/bin/phpunit | |
| # The #563 coverage campaign grew the suite to ~6200 tests, many of them | |
| # @runTestsInSeparateProcesses (alias-mock containment) — slow under pcov on | |
| # 2-core runners (the single-process coverage run hit both the 30m timeout and | |
| # the 512M memory ceiling on #622). The suite was made order-independent | |
| # (namespaced Core stubs converted to globals so no test leaks a lingering | |
| # symbol), which lets the coverage run be split into 4 parallel shards. Each | |
| # shard runs a balanced subset (.github/scripts/shard-tests.php, LPT-packed by | |
| # per-file test-method count so the process-isolated forks spread evenly) and | |
| # emits a partial .cov; the `coverage` job below merges them with phpcov and | |
| # gates the floor. Wall-clock ~50m → ~15m. | |
| coverage-shard: | |
| name: Coverage shard ${{ matrix.shard }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup-composer | |
| with: | |
| php-version: '8.3' | |
| coverage: pcov | |
| ini-values: pcov.directory=./includes,pcov.exclude="#(libraries|views)/#",memory_limit=2G | |
| - name: Run PHPUnit shard with coverage | |
| run: | | |
| mkdir -p build/cov | |
| php .github/scripts/shard-tests.php ${{ matrix.shard }} 4 > build/phpunit.shard.xml | |
| vendor/bin/phpunit -c build/phpunit.shard.xml --coverage-php "build/cov/shard-${{ matrix.shard }}.cov" | |
| - name: Upload shard coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-shard-${{ matrix.shard }} | |
| path: build/cov/shard-${{ matrix.shard }}.cov | |
| retention-days: 1 | |
| # Gating job — keeps the exact "Coverage (PHP 8.3)" name the branch protection | |
| # requires. Runs always() so a shard failure produces a definitive red here | |
| # (never a skipped required check), then merges the shard .cov files and | |
| # enforces the floor. | |
| coverage: | |
| name: Coverage (PHP 8.3) | |
| needs: coverage-shard | |
| # Run on shard success OR failure (so a real shard failure produces a | |
| # definitive red here), but NOT when the run is cancelled — e.g. a newer | |
| # push cancels this run via the concurrency group; without this guard the | |
| # merge job would run and report the cancellation as a spurious failure. | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Promoted from advisory to gating in S3 of #161. The floor is set | |
| # below the current line-coverage baseline so the gate doesn't fire | |
| # on day one but does catch a regression. Ratchet the floor upward | |
| # whenever a PR genuinely improves coverage. | |
| # | |
| # 50 → 55 in the §7 ratchet (#310) — closes out the #254 §7 test-gap | |
| # sprint that landed direct unit coverage for the RateLimitLogger / | |
| # RateLimitStats pair (#311), the ReregistrationSubmissionDetails | |
| # renderer (#312), the ActivityLogClearPlaintext + EmailHashRehash | |
| # migration strategies (#313), the 9 Form Editor metaboxes (#315), | |
| # and the §7.4a Recruitment slice (#316, REST × 4 + trait + Reason | |
| # repository). Conservative 5pp bump leaves headroom for the | |
| # in-flight §7.4b (#317) and §7.4c (#318) follow-ups. | |
| # | |
| # 55 → 66 — PHP coverage campaign (Fase 1+2): direct unit tests added | |
| # across the logic layer (self-scheduling CPT/handlers, reregistration | |
| # email/csv, recruitment csv-importer/dispatcher, audience ajax/admin, | |
| # repositories, REST controllers, validators, rate-limit, pdf data | |
| # assembly), skipping HTML-render/markup boilerplate by design. Overall | |
| # statement coverage 61.17% → 69.82% (31113/44564). Bump to 66 keeps a | |
| # ~3.8pp buffer under the measured number (≤5pp policy — see CLAUDE.md). | |
| # | |
| # 6.12.0 re-measure: 68.05% (30722/45146). The release batch added code | |
| # (per-module loaders, #600/#601) faster than it added coverage, so the | |
| # measured number slipped vs the prior run — no real gain to lock in this | |
| # batch. Floor stays at 66 (now a ~2.05pp buffer, still within the ≤5pp | |
| # policy); not lowered, not raised above the lowest observed run. | |
| # | |
| # 66 → 67 — markup-extraction sweep (#605/#606/#607): the inline admin | |
| # markup of RecruitmentAdminPageRenderer / UrlShortenerAdminPage / | |
| # ReregistrationAdminRenderer moved into templates/*.php (out of the | |
| # coverage scope), removing ~788 uncovered statements from the denominator. | |
| # Re-measured 69.16% (30676/44358). Bump to 67 locks in the gain with a | |
| # ~2.16pp buffer (≤5pp policy). | |
| # | |
| # 67 → 73 — module coverage sweep (#563): direct unit/render tests lifted | |
| # every remaining sub-70% module over the line — frontend, url-shortener, | |
| # settings, self-scheduling, then admin (65.7%→71.1%) and recruitment | |
| # (56.6%→82.0%) via AJAX-export, REST-controller, list-table, edit-page and | |
| # renderer coverage. Every includes/ module is now ≥70%. Overall statement | |
| # coverage 69.16% → 77.96% (34375/44095). Bump to 73 locks in the gain with | |
| # a ~4.96pp buffer (≤5pp policy). | |
| # | |
| # 73 → 78 — push admin + frontend to ≥80% (#563): AJAX-export, list-table, | |
| # edit-page, render and pipeline-stage tests took admin 71.1%→91.3% and | |
| # frontend 70.5%→92.4% (both also cleared 90%). Overall statement coverage | |
| # 77.96% → 82.95% (36579/44095). Bump to 78 locks in the gain with a | |
| # ~4.95pp buffer (≤5pp policy). | |
| # | |
| # 78 → 82 — take every remaining sub-80% module to ≥80% (#563): api, | |
| # reregistration, repositories, settings, url-shortener, generators, (root), | |
| # shortcodes, submissions, migrations — via REST-controller, reader/writer, | |
| # renderer, tab, generator, orchestrator and lifecycle coverage. Every | |
| # includes/ module is now ≥80% (lowest: audience 80.7%). Overall statement | |
| # coverage 82.95% → 86.37% (38087/44095, single-process). Bump to 82 keeps a | |
| # ~4.4pp buffer under the measured number (≤5pp policy); note the CI gate | |
| # reads the sharded phpcov merge, which runs a hair under the single-process | |
| # figure (~0.2pp on #623), so 82 stays comfortably clear. | |
| # | |
| # 82 → 84 — recruitment coverage push (#809 Fase 1): reader tests (#812) | |
| # plus the two heaviest renderers — RecruitmentPublicShortcodeRenderer | |
| # (~46%→~99%) and RecruitmentAdminPage (~39%→~99%) — took the recruitment | |
| # module ~86% → 90.0%. Overall statement coverage 88.56% (40543/45780, | |
| # single-process). Bump to 84 keeps a ~4.4pp buffer under the measured | |
| # number (≤5pp policy); the sharded phpcov merge still runs ~0.2pp under, | |
| # so 84 stays comfortably clear. | |
| # | |
| # 84 → 85 — coverage tail (#910, #809 Fase 3): fixed the pcov mis-attribution | |
| # of AbstractDismissibleNotice (0→full) + covered EmailTemplateDefaults, and | |
| # took the CertificatesCalendarRestController (~50→~100%) and the | |
| # self-scheduling AppointmentHandler (~55→~95%) error-branches (cancellation | |
| # auth/policy, email fan-out, CPF/RF user-linking WP_Error/exception paths). | |
| # Overall line coverage 89.38% (43381/48537, single-process). Bump to 85 keeps | |
| # a ~4.4pp buffer under the measured number (≤5pp policy); the sharded phpcov | |
| # merge still runs ~0.2pp under, so 85 stays comfortably clear. | |
| # | |
| # 85 → 86 — api/self-scheduling coverage-tail sweep (#910, #809 Fase 3): raised | |
| # every remaining api/ REST controller + self-scheduling/ handler under 90% to | |
| # ≥90% (or its pcov guard/multi-line-artifact ceiling). Deliberately a | |
| # conservative +1: the added coverage is dominated by process-isolated tests, | |
| # whose lines the single-process local measure under-counts (PHPUnit merges | |
| # isolated coverage per-shard on CI but not in a mixed single-process run), so | |
| # the true (CI, sharded) figure sits above the ~89% baseline — 86 stays well | |
| # clear while still ratcheting the pending #910 gain. | |
| env: | |
| COVERAGE_FLOOR_LINES: '86' | |
| steps: | |
| - name: Fail if any coverage shard did not succeed | |
| if: needs.coverage-shard.result != 'success' | |
| run: | | |
| echo "::error::One or more coverage shards did not succeed (result=${{ needs.coverage-shard.result }})." | |
| exit 1 | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup-composer | |
| with: | |
| php-version: '8.3' | |
| coverage: none | |
| - name: Download shard coverage | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: coverage-shard-* | |
| path: build/cov | |
| merge-multiple: true | |
| - name: Merge shard coverage into clover | |
| run: vendor/bin/phpcov merge build/cov --clover coverage.xml | |
| - name: Enforce line-coverage floor | |
| run: | | |
| # Parse the project-level <metrics> element from the clover XML | |
| # and compare `coveredstatements / statements` against the floor. | |
| php -r ' | |
| $xml = simplexml_load_file("coverage.xml"); | |
| if ( ! $xml ) { fwrite( STDERR, "Failed to parse coverage.xml\n" ); exit( 2 ); } | |
| $m = $xml->project->metrics; | |
| $stmts = (int) $m["statements"]; | |
| $covered = (int) $m["coveredstatements"]; | |
| if ( $stmts === 0 ) { fwrite( STDERR, "No statements recorded\n" ); exit( 2 ); } | |
| $pct = ( $covered / $stmts ) * 100; | |
| $floor = (float) getenv( "COVERAGE_FLOOR_LINES" ); | |
| printf( "Line coverage: %.2f%% (floor: %.2f%%)\n", $pct, $floor ); | |
| // Also emit to the GitHub Actions step summary for at-a-glance review. | |
| $summary = getenv( "GITHUB_STEP_SUMMARY" ); | |
| if ( $summary ) { | |
| file_put_contents( $summary, sprintf( | |
| "### Coverage\n\n| Lines | %.2f%% |\n| --- | --- |\n| Floor | %.2f%% |\n| Statements | %d / %d |\n", | |
| $pct, $floor, $covered, $stmts | |
| ) ); | |
| } | |
| if ( $pct < $floor ) { | |
| fwrite( STDERR, sprintf( "::error::Line coverage %.2f%% is below the floor %.2f%%.\n", $pct, $floor ) ); | |
| exit( 1 ); | |
| } | |
| ' | |
| - name: Upload coverage to Coveralls | |
| # Reporting-only: the merge gate is the "Enforce line-coverage floor" | |
| # step above, which fully enforces COVERAGE_FLOOR_LINES on its own. A | |
| # coveralls.io outage (503 "queue full") must never block merge, so this | |
| # third-party upload is non-blocking. | |
| continue-on-error: true | |
| uses: coverallsapp/github-action@v2 | |
| with: | |
| file: coverage.xml | |
| format: clover | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # Installs a throwaway WordPress onto an empty database and activates the | |
| # plugin into it for real. This is the only gate that sees a FRESH activation: | |
| # `deploy-develop.yml`'s post-deploy smoke runs against the established testes | |
| # install, where every table already exists from an earlier release, so it | |
| # cannot see a CREATE statement that stopped working. That is the defect class | |
| # that shipped in 6.0.1 — column-level COMMENT clauses made dbDelta fail | |
| # silently and four recruitment tables were never created on activation. | |
| # | |
| # It also runs the uninstaller with the Danger Zone opt-in on and asserts the | |
| # footprint is gone, which makes `uninstall.php` an enforced manifest rather | |
| # than a list nothing checks (the #991 ffc_foreign_keys_db_version case: an | |
| # option added after the audit that enumerated its siblings, left behind on | |
| # every deletion). | |
| # | |
| # No Composer step: the plugin ships its own PSR-4 autoloader and needs no | |
| # vendor/ at runtime, so this installs exactly what a user would. | |
| fresh-install: | |
| name: Fresh install (PHP 8.3) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| services: | |
| # Pinned to MariaDB rather than MySQL, and to the line the testes host | |
| # actually runs, because the point of the job is to reproduce the | |
| # provider's dbDelta behaviour. The post-deploy smoke prints the host's | |
| # real `VERSION()` and `@@sql_mode` on every deploy — when those drift | |
| # from what is pinned here, move the pin. | |
| # | |
| # Host, as reported by the smoke on deploy #527: | |
| # 11.8.8-MariaDB-log sql_mode=NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION | |
| # Note the sql_mode difference from the 10.11 image this was first pinned | |
| # to, which adds ERROR_FOR_DIVISION_BY_ZERO — the reason to match the host | |
| # rather than pick a familiar LTS. | |
| mariadb: | |
| image: mariadb:11.8 | |
| env: | |
| MARIADB_ROOT_PASSWORD: wordpress | |
| MARIADB_DATABASE: wordpress | |
| ports: | |
| - 3306:3306 | |
| options: >- | |
| --health-cmd="healthcheck.sh --connect --innodb_initialized" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=10 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: shivammathur/setup-php@v2 | |
| with: | |
| php-version: '8.3' | |
| extensions: mysqli, zip, gd | |
| coverage: none | |
| # The two downloads below are retried for the same reason `composer audit` | |
| # above is: raw.githubusercontent.com and wordpress.org intermittently | |
| # time out or cut a transfer short from Actions runners, and this job is a | |
| # required check — so a transient CDN blip would block merging rather than | |
| # merely fail a report. Both are read-only fetches, so retrying is safe. | |
| # | |
| # The MariaDB image pull is NOT covered: that happens during the runner's | |
| # service provisioning, before any step runs, so the workflow cannot wrap | |
| # it. | |
| - name: Install WP-CLI | |
| run: | | |
| for attempt in 1 2 3; do | |
| if curl -fsSL --connect-timeout 20 -o wp-cli.phar \ | |
| https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar; then | |
| break | |
| fi | |
| if [ "$attempt" = 3 ]; then | |
| echo "::error::could not download the wp-cli phar after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "::warning::wp-cli download attempt $attempt failed; retrying in 10s" | |
| sleep 10 | |
| done | |
| chmod +x wp-cli.phar | |
| sudo mv wp-cli.phar /usr/local/bin/wp | |
| wp --info | |
| - name: Install a throwaway WordPress | |
| run: | | |
| for attempt in 1 2 3; do | |
| # A cut-short tarball leaves a partial tree behind, which the next | |
| # attempt would refuse to write into — so start each from nothing. | |
| rm -rf "$HOME/wp" | |
| if wp core download --path="$HOME/wp"; then | |
| break | |
| fi | |
| if [ "$attempt" = 3 ]; then | |
| echo "::error::could not download WordPress core after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "::warning::WordPress download attempt $attempt failed; retrying in 10s" | |
| sleep 10 | |
| done | |
| wp config create --path="$HOME/wp" \ | |
| --dbname=wordpress --dbuser=root --dbpass=wordpress --dbhost=127.0.0.1 | |
| wp core install --path="$HOME/wp" \ | |
| --url=http://localhost --title="FFC fresh install" \ | |
| --admin_user=admin --admin_password=admin \ | |
| --admin_email=ci@example.invalid --skip-email | |
| - name: Activate the plugin into an empty database | |
| run: | | |
| mkdir -p "$HOME/wp/wp-content/plugins/ffcertificate" | |
| rsync -a --exclude '.git' ./ "$HOME/wp/wp-content/plugins/ffcertificate/" | |
| wp plugin activate ffcertificate --path="$HOME/wp" | |
| - name: Check what the activation created | |
| run: php .github/scripts/fresh-install-check.php "$HOME/wp" uninstall.php activate | |
| - name: Delete the plugin with the Danger Zone opt-in on | |
| run: | | |
| # `wp plugin uninstall` removes the plugin directory, and these scripts | |
| # live inside it — so copy them out before pulling the floor up. | |
| mkdir -p "$HOME/checks" | |
| cp .github/scripts/fresh-install-check.php \ | |
| .github/scripts/ffc-uninstall-manifest.php \ | |
| uninstall.php "$HOME/checks/" | |
| # Default OFF by design (the WooCommerce/EDD convention), so the heavy | |
| # cleanup only runs when an admin opted in. Opt in here — leaving it | |
| # off would make the next step assert nothing. | |
| wp eval --path="$HOME/wp" ' | |
| $s = (array) get_option( "ffc_settings", array() ); | |
| $s["delete_data_on_uninstall"] = "1"; | |
| update_option( "ffc_settings", $s ); | |
| ' | |
| wp plugin uninstall ffcertificate --deactivate --path="$HOME/wp" | |
| - name: Check nothing was left behind | |
| run: php "$HOME/checks/fresh-install-check.php" "$HOME/wp" "$HOME/checks/uninstall.php" uninstall |