Skip to content

Build/Test Tools: Check the upgraded site over HTTP. - #13355

Draft
adimoldovan wants to merge 10 commits into
WordPress:trunkfrom
adimoldovan:smoke-tests-after-upgrade
Draft

Build/Test Tools: Check the upgraded site over HTTP.#13355
adimoldovan wants to merge 10 commits into
WordPress:trunkfrom
adimoldovan:smoke-tests-after-upgrade

Conversation

@adimoldovan

@adimoldovan adimoldovan commented Sep 1, 2026

Copy link
Copy Markdown

Trac ticket:

Upgrade Tests end at wp core version, which reads a string off the filesystem. A site that upgrades to the right version and then fatals on every page reports green.

This adds one step at the end of the job. It moves the site to port 8889, serves it with PHP's built-in server, and makes eight requests.

Request Must contain
GET / content="WordPress <version>", matched against wp core version, and </html>
GET /?p=1 Hello world! and </html>
GET /?rest_route=/ a whole JSON body whose name is Upgrade Test
GET /wp-admin/upgrade.php No Update Required and </html>
GET /wp-login.php id="loginform" and </html>
GET /?p=99999999 HTTP 404
POST /wp-login.php wordpress_logged_in_ in the cookie jar
GET /wp-admin/ id="wpadminbar" and </html>

The step then fails if PHP wrote a fatal error to the server log. That catches a fatal during shutdown, which leaves every response whole.

Each check asserts a marker that a working install must produce, and also </html>. A fatal part way down a page leaves an HTTP 200 that holds half a document, and every other marker sits near the top.

On a failure the step prints the response body and the server log. It costs about 5 seconds and uses jq, which is already on the runner image.

The JIT

The server starts with -d opcache.jit=disable. Without that it crashes.

The runner sets opcache.jit to tracing with a 256M buffer. Stock PHP ships the JIT off. opcache.enable_cli does not keep the JIT away from php -S, because opcache counts the built-in server as a web SAPI rather than as the CLI.

Measured on the runner image, one cold server per cycle, PHP 8.4 and 8.5, WordPress 7.1:

php -S Crashes
JIT on 38 of 1600
JIT off 0 of 1600

nginx with PHP-FPM crashed the same way, 19 of 400. Every core dump put the crash inside opcache.so:

Core was generated by `php -S 127.0.0.1:8889 -t .'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00007f1f77346226 in ?? () from /usr/lib/php/20240924/opcache.so

The site under test still runs the configuration a WordPress host runs.

The rest of the setup

The step skips multisite. A network keeps its domain in wp-config.php and in the site and blogs tables, and WordPress strips only :80 and :443 from the host before it matches a network, so a move to another port needs more than an option update.

It sets DISABLE_WP_CRON and WP_HTTP_BLOCK_EXTERNAL. A cron spawn is a request no check asked for, and the update checks on /wp-admin/ leave the runner for api.wordpress.org. WP_Http::block_request() exempts the site's own host, so the site can still talk to itself.

Effect on other branches

Branches 6.6 to 7.1 call reusable-upgrade-testing.yml@trunk, so this step runs on their upgrade jobs too. Branches 6.4 and 6.5 call the older upgrade-testing-run.yml and do not change. upgrade-develop-testing.yml calls this workflow too, and runs on any change to src/**.php.

All the markers exist in the source of every branch from 6.6 to trunk, and every caller runs Ubuntu.

Testing Instructions

The workflow runs on any pull request that touches upgrade-testing.yml or reusable-upgrade-testing.yml, so this one exercises it.

To reproduce the job locally:

  1. Start a database:

    docker run -d --name wp-smoke-db -e MYSQL_ROOT_PASSWORD=root \
      -e MYSQL_DATABASE=test_db -p 13306:3306 mysql:8.4
    
  2. In an empty directory, install an older version and upgrade it:

    wp core download --version=7.0
    wp config create --dbname=test_db --dbuser=root --dbpass=root --dbhost=127.0.0.1:13306
    wp core install --url=http://localhost/ --title="Upgrade Test" --admin_user=admin \
      --admin_password=password --admin_email=me@example.org --skip-email
    wp core update --minor && wp core update-db
    wp core update && wp core update-db
    
  3. Copy the run: block of the new "Post-upgrade smoke check" step into smoke.sh and run it with
    RUNNER_TEMP=/tmp SITE_PORT=8889 WP_ADMIN_USER=admin WP_ADMIN_PASSWORD=password bash -e smoke.sh.
    It prints nine ok lines and exits 0.

  4. Confirm it catches a broken site. Each case below is one file in wp-content/mu-plugins/.
    Write it, run the script again, then delete it. The WP_CLI guard keeps WP-CLI working, so the
    break reaches the step over HTTP rather than stopping the first wp command.

    File content Expected failure
    <?php if ( ! defined( "WP_CLI" ) ) { boom_undefined(); } ::error::/ returned HTTP 500
    <?php add_action( "wp_footer", function () { boom_undefined(); } ); ::error::/ did not contain: </html>
    <?php if ( ! defined( "WP_CLI" ) ) { register_shutdown_function( function () { boom_undefined(); } ); } ::error::the server logged a fatal error
    <?php add_filter( "template_redirect", function () { if ( is_404() ) { status_header( 200 ); } }, 1 ); ::error::a missing post returned 200, expected 404

    The third case leaves every response whole and is caught only by the log check.

  5. Confirm it catches missing content: wp post delete 1 --force, then run the script again. It
    fails with ::error::/?p=1 returned HTTP 404.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Drafting the workflow step and this description, and running the crash measurements quoted above. I ran the local verification described here and reviewed and edited the result.

Upgrade Tests end at `wp core version`, which reads a string off the filesystem.
No step in the job makes an HTTP request. A site that upgrades to the right
version and then fatals on every page still reports green.

Serve the upgraded site with `wp server` and check what a working install must
produce: the version in the generator tag, the default post, a 404 for a missing
one, the REST index, the login form, an authentication cookie from a real login,
and the admin bar on the dashboard. A check for a missing error string breaks as
soon as someone rewords the string.

This skips multisite. The network domain lives in `wp-config.php` and in the
`site` and `blogs` tables. WordPress strips only `:80` and `:443` from the host
before it matches a network, so a move to another port needs more than an option
update.
@adimoldovan adimoldovan self-assigned this Sep 1, 2026
Drop the block above the step. Keep one line on the `if`, which says why the
step skips multisite.
Six of 264 upgrade jobs failed. A request hung for 30 seconds and died:

    Fatal error: Maximum execution time of 30+2 seconds exceeded (terminated)
    in wp-includes/rest-api.php on line 2820

WordPress cron spawns a request back to the same server, and PHP's built-in
server has nothing left to serve it. Four worker processes did not prevent
this, and that mode is experimental: two jobs also died with a segmentation
fault. Turn cron off instead, and drop the workers.

Give every request a 30 second limit, so a hang that slips through fails the
step instead of spending the 20 minute job timeout.

Let the outer shell expand the URL in the wait loop. actionlint reads the
single-quoted form as shellcheck SC2016.
Stop the background server when the step ends, and keep the step's exit code.
Print the response body when a check finds the wrong content. Read the port
from the site URL. Share the admin credentials with the install step. Match
the generator tag without its closing slash. Stop the wait loop when the
server dies.
Every marker sat near the top of the response. A fatal in the second half of a
page leaves an HTTP 200 with half a document, and the check passed. Each HTML
check now also looks for the closing tag, and the REST index must parse as
whole JSON. Neither marker depends on the wording of an error message.

A failed request printed almost nothing. All requests now go through one helper
that keeps the body and the status code. A response that fails prints what the
site returned. The login POST fails the same way as the other checks.

An empty reply came back as a bare curl error. The step now checks whether the
server still runs. A page that kills the PHP process says so and prints the
exit status.

The step reads the server log at the end. A fatal during shutdown leaves every
response whole and the site broken.
The step reported an empty reply as a bare curl error. It now reports two
facts: whether the server process is still up, and whether anything still
listens on the port. The process that holds the port is a grandchild of the
step, so one fact alone cannot tell the two causes apart.

The wait for the server to start had no message. A server that never binds now
says so, instead of leaving a bare exit 124 in the log.

The REST index check read a scratch file that the previous request filled. It
now makes its own request and asserts the parsed value, so line order no longer
decides what it reads.

The 404 page must arrive whole, as every other page must. A missing server log
can no longer replace the exit status the step meant to report.
`wp server` wraps `php -S` in a router and puts the server two processes below
the step. Every path the step asks for resolves without that router: `/` and
`/wp-admin/` reach their `index.php` as directory indexes, and the rest are
files on disk.

Dropping it removes a layer. The server is now the step's own child, so when a
request gets no reply the step reports the status the server exited with,
rather than the status of the process that started it.
PHP's built-in server answered the connection and then closed it with no
reply, on about four jobs in every run. One process accepts the connection
and runs WordPress, so when that process stalls, nothing answers.

nginx now owns the listening socket, and PHP-FPM runs the site behind it.
A worker that dies mid-request becomes an HTTP 502 the step reports, and
the pool starts another. Both programs are on the runner image, and the
step runs them as the runner user with its own config in RUNNER_TEMP.

The step also blocks external HTTP. The update checks on /wp-admin/ left
the runner for api.wordpress.org, which no check asked for.
PHP workers crashed with SIGSEGV on about five percent of cold pools, on
PHP 8.4 and 8.5, while they served the first request. A run of 1200 pools
on the runner image found the cause: the runner sets opcache.jit to
tracing with a 256M buffer. Stock PHP ships the JIT off, so the crash
needs a setup no site runs.

With the JIT off and opcache still on, none of 400 pools crashed. With
the JIT on, 19 of 400 crashed.
A core dump named the crash: every frame sat in opcache.so, and the server
that produced it was php -S. opcache counts the built-in server as a web
SAPI, not as the CLI, so opcache.enable_cli never kept it out and it ran
the JIT the runner turns on. One bug hit both servers.

With the JIT off, php -S crashed on none of 1600 cold starts, against 38
with it on. It serves every path the check asks for, so the pool, the
server block, the socket and the second process all go.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant