Skip to content

test(core): freeze the direct superglobal int casts as a ratchet (#1087 passo 2) #1595

test(core): freeze the direct superglobal int casts as a ratchet (#1087 passo 2)

test(core): freeze the direct superglobal int casts as a ratchet (#1087 passo 2) #1595

Workflow file for this run

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