From 87542fbd6cedc9e47c8317429bb584cc8e6e7a8b Mon Sep 17 00:00:00 2001
From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:02:12 +0300
Subject: [PATCH 01/11] Build/Test Tools: Check the upgraded site over HTTP.
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.
---
.../workflows/reusable-upgrade-testing.yml | 64 +++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml
index 169abe20006a4..ed248c101c85c 100644
--- a/.github/workflows/reusable-upgrade-testing.yml
+++ b/.github/workflows/reusable-upgrade-testing.yml
@@ -56,6 +56,7 @@ jobs:
# - Updates to the version of WordPress being tested.
# - Updates the database.
# - Checks the version of WordPress after the upgrade.
+ # - Serves the upgraded site and confirms it renders.
upgrade-tests:
name: PHP ${{ inputs.php }} with ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}
permissions:
@@ -135,3 +136,66 @@ jobs:
- name: Post-upgrade version check
run: wp core version
+
+ # `wp core version` reads a string off the filesystem, and nothing else in this job makes an
+ # HTTP request, so a site that upgrades and then fatals on every page still reports green.
+ # Every assertion below is for something a working install must produce, not for the absence
+ # of an error string, which stops matching the moment the error is reworded.
+ #
+ # Multisite is excluded for now: the network domain is stored in `wp-config.php` and the
+ # `site` and `blogs` tables, and only `:80` and `:443` are stripped from the host when
+ # matching a network, so moving the site to another port takes more than an option update.
+ - name: Post-upgrade smoke check
+ if: ${{ ! inputs.multisite }}
+ run: |
+ wp option update home "${SITE_URL}"
+ wp option update siteurl "${SITE_URL}"
+
+ wp server --host=127.0.0.1 --port=8889 > "${RUNNER_TEMP}/wp-server.log" 2>&1 &
+ trap '[ $? -eq 0 ] || cat "${RUNNER_TEMP}/wp-server.log"' EXIT
+ timeout 30 bash -c 'until curl --silent --output /dev/null "${SITE_URL}/"; do sleep 1; done'
+
+ jar="${RUNNER_TEMP}/cookies.txt"
+
+ # Fetches a path and fails the step unless the response body contains the given string.
+ check() {
+ local body
+ body="$(curl --silent --show-error --fail --cookie "${jar}" --cookie-jar "${jar}" "${SITE_URL}${1}")"
+ if ! grep -qF "${2}" <<< "${body}"; then
+ echo "::error::${1} did not contain: ${2}"
+ exit 1
+ fi
+ echo "ok ${1}"
+ }
+
+ version="$(wp core version)"
+
+ check '/' ""
+ check '/?p=1' 'Hello world!'
+ check '/?rest_route=/' '"name":"Upgrade Test"'
+ check '/wp-admin/upgrade.php' 'No Update Required'
+ check '/wp-login.php' 'id="loginform"'
+
+ status="$(curl --silent --output /dev/null --write-out '%{http_code}' "${SITE_URL}/?p=99999999")"
+ if [ "${status}" != '404' ]; then
+ echo "::error::a missing post returned ${status}, expected 404"
+ exit 1
+ fi
+ echo "ok 404 handling"
+
+ curl --silent --show-error --fail --output /dev/null \
+ --cookie "${jar}" --cookie-jar "${jar}" \
+ --data-urlencode 'log=admin' --data-urlencode 'pwd=password' \
+ "${SITE_URL}/wp-login.php"
+ if ! grep -q 'wordpress_logged_in_' "${jar}"; then
+ echo '::error::logging in did not set an authentication cookie'
+ exit 1
+ fi
+ echo 'ok login'
+
+ # The admin bar only renders for a logged-in user, and /wp-admin/ redirects when logged out.
+ check '/wp-admin/' 'id="wpadminbar"'
+ env:
+ SITE_URL: http://127.0.0.1:8889
+ # PHP's built-in server is single threaded, and WordPress makes loopback requests.
+ PHP_CLI_SERVER_WORKERS: 4
From 7abc187e081bbbc535165389fae5e728344592df Mon Sep 17 00:00:00 2001
From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:13:42 +0300
Subject: [PATCH 02/11] Build/Test Tools: Trim the smoke check comments.
Drop the block above the step. Keep one line on the `if`, which says why the
step skips multisite.
---
.github/workflows/reusable-upgrade-testing.yml | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml
index ed248c101c85c..1d91f2962b93f 100644
--- a/.github/workflows/reusable-upgrade-testing.yml
+++ b/.github/workflows/reusable-upgrade-testing.yml
@@ -137,15 +137,8 @@ jobs:
- name: Post-upgrade version check
run: wp core version
- # `wp core version` reads a string off the filesystem, and nothing else in this job makes an
- # HTTP request, so a site that upgrades and then fatals on every page still reports green.
- # Every assertion below is for something a working install must produce, not for the absence
- # of an error string, which stops matching the moment the error is reworded.
- #
- # Multisite is excluded for now: the network domain is stored in `wp-config.php` and the
- # `site` and `blogs` tables, and only `:80` and `:443` are stripped from the host when
- # matching a network, so moving the site to another port takes more than an option update.
- name: Post-upgrade smoke check
+ # A network keeps its domain in wp-config.php and the database, not just an option.
if: ${{ ! inputs.multisite }}
run: |
wp option update home "${SITE_URL}"
From 17e47e293bad54bad5e6216b695acb6c534c9e88 Mon Sep 17 00:00:00 2001
From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:20:10 +0300
Subject: [PATCH 03/11] Build/Test Tools: Stop the smoke check deadlocking on
cron.
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.
---
.github/workflows/reusable-upgrade-testing.yml | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml
index 1d91f2962b93f..639c0b2d4de17 100644
--- a/.github/workflows/reusable-upgrade-testing.yml
+++ b/.github/workflows/reusable-upgrade-testing.yml
@@ -143,17 +143,19 @@ jobs:
run: |
wp option update home "${SITE_URL}"
wp option update siteurl "${SITE_URL}"
+ # Cron spawns a request back to this server, which then has nothing left to serve it.
+ wp config set DISABLE_WP_CRON true --raw
wp server --host=127.0.0.1 --port=8889 > "${RUNNER_TEMP}/wp-server.log" 2>&1 &
trap '[ $? -eq 0 ] || cat "${RUNNER_TEMP}/wp-server.log"' EXIT
- timeout 30 bash -c 'until curl --silent --output /dev/null "${SITE_URL}/"; do sleep 1; done'
+ timeout 30 bash -c "until curl --silent --output /dev/null \"${SITE_URL}/\"; do sleep 1; done"
jar="${RUNNER_TEMP}/cookies.txt"
# Fetches a path and fails the step unless the response body contains the given string.
check() {
local body
- body="$(curl --silent --show-error --fail --cookie "${jar}" --cookie-jar "${jar}" "${SITE_URL}${1}")"
+ body="$(curl --silent --show-error --fail --max-time 30 --cookie "${jar}" --cookie-jar "${jar}" "${SITE_URL}${1}")"
if ! grep -qF "${2}" <<< "${body}"; then
echo "::error::${1} did not contain: ${2}"
exit 1
@@ -169,14 +171,14 @@ jobs:
check '/wp-admin/upgrade.php' 'No Update Required'
check '/wp-login.php' 'id="loginform"'
- status="$(curl --silent --output /dev/null --write-out '%{http_code}' "${SITE_URL}/?p=99999999")"
+ status="$(curl --silent --output /dev/null --max-time 30 --write-out '%{http_code}' "${SITE_URL}/?p=99999999")"
if [ "${status}" != '404' ]; then
echo "::error::a missing post returned ${status}, expected 404"
exit 1
fi
echo "ok 404 handling"
- curl --silent --show-error --fail --output /dev/null \
+ curl --silent --show-error --fail --max-time 30 --output /dev/null \
--cookie "${jar}" --cookie-jar "${jar}" \
--data-urlencode 'log=admin' --data-urlencode 'pwd=password' \
"${SITE_URL}/wp-login.php"
@@ -190,5 +192,3 @@ jobs:
check '/wp-admin/' 'id="wpadminbar"'
env:
SITE_URL: http://127.0.0.1:8889
- # PHP's built-in server is single threaded, and WordPress makes loopback requests.
- PHP_CLI_SERVER_WORKERS: 4
From e4e6d777bd93c84bca734371f815b1d4111fe609 Mon Sep 17 00:00:00 2001
From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:02:38 +0300
Subject: [PATCH 04/11] Build/Test Tools: Harden the post-upgrade smoke check.
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.
---
.../workflows/reusable-upgrade-testing.yml | 29 ++++++++++++++-----
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml
index 639c0b2d4de17..ce16ad692c206 100644
--- a/.github/workflows/reusable-upgrade-testing.yml
+++ b/.github/workflows/reusable-upgrade-testing.yml
@@ -64,6 +64,10 @@ jobs:
runs-on: ${{ inputs.os }}
timeout-minutes: 20
+ env:
+ WP_ADMIN_USER: admin
+ WP_ADMIN_PASSWORD: password
+
services:
database:
image: ${{ inputs.db-type }}:${{ inputs.db-version }}
@@ -98,8 +102,8 @@ jobs:
- name: Install WordPress
run: |
wp core ${{ inputs.multisite && 'multisite-install' || 'install' }} \
- --url=http://localhost/ --title="Upgrade Test" --admin_user=admin \
- --admin_password=password --admin_email=me@example.org --skip-email
+ --url=http://localhost/ --title="Upgrade Test" --admin_user="${WP_ADMIN_USER}" \
+ --admin_password="${WP_ADMIN_PASSWORD}" --admin_email=me@example.org --skip-email
- name: Pre-upgrade version check
run: wp core version
@@ -146,9 +150,19 @@ jobs:
# Cron spawns a request back to this server, which then has nothing left to serve it.
wp config set DISABLE_WP_CRON true --raw
- wp server --host=127.0.0.1 --port=8889 > "${RUNNER_TEMP}/wp-server.log" 2>&1 &
- trap '[ $? -eq 0 ] || cat "${RUNNER_TEMP}/wp-server.log"' EXIT
- timeout 30 bash -c "until curl --silent --output /dev/null \"${SITE_URL}/\"; do sleep 1; done"
+ wp server --host=127.0.0.1 --port="${SITE_URL##*:}" > "${RUNNER_TEMP}/wp-server.log" 2>&1 &
+ server_pid=$!
+
+ stop_server() {
+ local code=$?
+ pkill -P "${server_pid}" || true
+ kill "${server_pid}" 2> /dev/null || true
+ [ "${code}" -eq 0 ] || cat "${RUNNER_TEMP}/wp-server.log"
+ exit "${code}"
+ }
+ trap stop_server EXIT
+
+ timeout 30 bash -c "until curl --silent --output /dev/null \"${SITE_URL}/\"; do kill -0 ${server_pid} 2> /dev/null || exit 1; sleep 1; done"
jar="${RUNNER_TEMP}/cookies.txt"
@@ -158,6 +172,7 @@ jobs:
body="$(curl --silent --show-error --fail --max-time 30 --cookie "${jar}" --cookie-jar "${jar}" "${SITE_URL}${1}")"
if ! grep -qF "${2}" <<< "${body}"; then
echo "::error::${1} did not contain: ${2}"
+ head -n 20 <<< "${body}"
exit 1
fi
echo "ok ${1}"
@@ -165,7 +180,7 @@ jobs:
version="$(wp core version)"
- check '/' ""
+ check '/' "content=\"WordPress ${version}\""
check '/?p=1' 'Hello world!'
check '/?rest_route=/' '"name":"Upgrade Test"'
check '/wp-admin/upgrade.php' 'No Update Required'
@@ -180,7 +195,7 @@ jobs:
curl --silent --show-error --fail --max-time 30 --output /dev/null \
--cookie "${jar}" --cookie-jar "${jar}" \
- --data-urlencode 'log=admin' --data-urlencode 'pwd=password' \
+ --data-urlencode "log=${WP_ADMIN_USER}" --data-urlencode "pwd=${WP_ADMIN_PASSWORD}" \
"${SITE_URL}/wp-login.php"
if ! grep -q 'wordpress_logged_in_' "${jar}"; then
echo '::error::logging in did not set an authentication cookie'
From 66ff167ce76ac15a15eaf49ada05c0460ad54793 Mon Sep 17 00:00:00 2001
From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:54:17 +0300
Subject: [PATCH 05/11] Build/Test Tools: Make the smoke check report what
broke.
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.
---
.../workflows/reusable-upgrade-testing.yml | 117 ++++++++++++------
1 file changed, 79 insertions(+), 38 deletions(-)
diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml
index ce16ad692c206..01b9816048876 100644
--- a/.github/workflows/reusable-upgrade-testing.yml
+++ b/.github/workflows/reusable-upgrade-testing.yml
@@ -145,65 +145,106 @@ jobs:
# A network keeps its domain in wp-config.php and the database, not just an option.
if: ${{ ! inputs.multisite }}
run: |
- wp option update home "${SITE_URL}"
- wp option update siteurl "${SITE_URL}"
+ site_url="http://127.0.0.1:${SITE_PORT}"
+ server_log="${RUNNER_TEMP}/wp-server.log"
+ jar="${RUNNER_TEMP}/cookies.txt"
+ response="${RUNNER_TEMP}/response.txt"
+ code="${RUNNER_TEMP}/http-code.txt"
+
+ # Moving the site and turning off cron are not undone, so this step has to stay last.
+ wp option update home "${site_url}"
+ wp option update siteurl "${site_url}"
# Cron spawns a request back to this server, which then has nothing left to serve it.
wp config set DISABLE_WP_CRON true --raw
- wp server --host=127.0.0.1 --port="${SITE_URL##*:}" > "${RUNNER_TEMP}/wp-server.log" 2>&1 &
+ wp server --host=127.0.0.1 --port="${SITE_PORT}" > "${server_log}" 2>&1 &
server_pid=$!
stop_server() {
- local code=$?
+ local status=$?
pkill -P "${server_pid}" || true
kill "${server_pid}" 2> /dev/null || true
- [ "${code}" -eq 0 ] || cat "${RUNNER_TEMP}/wp-server.log"
- exit "${code}"
+ [ "${status}" -eq 0 ] || cat "${server_log}"
+ exit "${status}"
}
trap stop_server EXIT
- timeout 30 bash -c "until curl --silent --output /dev/null \"${SITE_URL}/\"; do kill -0 ${server_pid} 2> /dev/null || exit 1; sleep 1; done"
+ timeout 30 bash -c "until curl --silent --output /dev/null \"${site_url}/\"; do kill -0 ${server_pid} 2> /dev/null || exit 1; sleep 1; done"
- jar="${RUNNER_TEMP}/cookies.txt"
+ # Prints what came back and fails the step.
+ fail() {
+ echo "::error::${1}"
+ head -n 20 "${response}"
+ exit 1
+ }
- # Fetches a path and fails the step unless the response body contains the given string.
- check() {
- local body
- body="$(curl --silent --show-error --fail --max-time 30 --cookie "${jar}" --cookie-jar "${jar}" "${SITE_URL}${1}")"
- if ! grep -qF "${2}" <<< "${body}"; then
- echo "::error::${1} did not contain: ${2}"
- head -n 20 <<< "${body}"
- exit 1
+ # Requests a path, leaving the body in ${response} and the status in ${http_code}. An
+ # HTTP error is a result and comes back to the caller. No reply at all is not: the
+ # step says whether the server is still up, because that tells the two causes apart.
+ fetch() {
+ local path="${1}" status=0
+ shift
+ : > "${response}"
+ curl --silent --show-error --max-time 30 --output "${response}" \
+ --write-out '%{http_code}' --cookie "${jar}" --cookie-jar "${jar}" \
+ "$@" "${site_url}${path}" > "${code}" || status=$?
+ http_code="$(cat "${code}")"
+ if [ "${status}" -ne 0 ] && [ "${status}" -ne 22 ]; then
+ if ! kill -0 "${server_pid}" 2> /dev/null; then
+ # 139 here means a signal 11, so the page brought the interpreter down with it.
+ server_status=0
+ wait "${server_pid}" || server_status=$?
+ fail "${path} took the server down, which left ${server_status}"
+ fi
+ fail "${path} got no reply, curl exited ${status}"
fi
- echo "ok ${1}"
+ return "${status}"
+ }
+
+ # Fails the step unless the response arrives whole and contains every given string.
+ check() {
+ local path="${1}" marker status=0
+ shift
+ fetch "${path}" --fail-with-body || status=$?
+ [ "${status}" -eq 0 ] || fail "${path} returned HTTP ${http_code}"
+ for marker in "$@"; do
+ grep -qF "${marker}" "${response}" || fail "${path} did not contain: ${marker}"
+ done
+ echo "ok ${path}"
}
version="$(wp core version)"
- check '/' "content=\"WordPress ${version}\""
- check '/?p=1' 'Hello world!'
+ # A fatal after the first bytes leaves a 200 holding half a page, so every check also
+ # asks for the end of the document. Unlike an error string, that cannot be reworded.
+ check '/' "content=\"WordPress ${version}\"" '