#!/usr/bin/env bash # Stand up a throwaway WordPress + WooCommerce, install this plugin into it, # and run the site tests from ci/plugin.conf inside it. # # WP_VERSION=latest|nightly|6.8 WC_VERSION=latest|beta|11.1.0 PHP_VERSION=8.3 # # The point of the version knobs is the question this whole directory exists to # answer: *will the shop still work after I press update?* Running the same # tests against `latest` and against `nightly`/`beta` answers it before the # update happens rather than after. # # Written for a Gitea Actions job, which runs INSIDE a container that talks to # the HOST's docker daemon. A bind mount of $PWD there mounts an empty # directory nobody created, and the symptom is "Could not open input file", # which reads like a typo in the path. So the plugin goes in over `docker cp` # and all state lives in named volumes. That also makes the script behave # identically on GitHub's runners and on a laptop. set -euo pipefail cd "$(dirname "$0")/.." . ci/lib.sh ci_load_conf WP_VERSION="${WP_VERSION:-latest}" WC_VERSION="${WC_VERSION:-latest}" PHP_VERSION="${PHP_VERSION:-8.3}" # 🔴 NOT just $$. A Gitea job runs in a fresh container where the shell gets a # low PID, and those REPEAT across runs — so "wpci-$$" collides with the # leftovers of any earlier run that did not clean up, and the job dies on # `network with name wpci-147-net already exists`. Measured on Gitea run 999: # every run landing on PID 147 was wedged permanently, while the same script on # a laptop never collides because PIDs there are large and varied. # # The epoch and $RANDOM make the name unique per run even inside a container # that always starts at the same PID. id="wpci-$$-$(date +%s)-${RANDOM:-0}" net="$id-net" # The name is unique to this run, so anything already using it is debris from a # crashed run and is ours to clear. Without this a single wedged name stays # wedged forever, which is exactly what happened on the Gitea runner. docker rm -f -v "$id-db" "$id-cli" >/dev/null 2>&1 || true docker network rm "$net" >/dev/null 2>&1 || true # 🔴 -v, and it is the whole bug. `mariadb:lts` declares VOLUME # /var/lib/mysql, so every run creates an ANONYMOUS volume, and `docker rm -f` # does not remove anonymous volumes — only `docker rm -f -v` does. The named # $id-wp below is removed correctly, which is exactly what made this function # look complete: removing three of the four things you created is # indistinguishable from removing all four unless you count. # # Measured 2026-09-17 after a sibling plugin traced it: the Gitea runner's disk # was 100% full, 26 GB in 95 volumes with 1 in use, and a push there failed # with `unable to create temporary object directory` — three layers from the # cause. This machine had 127 dangling volumes and 15.2 GB reclaimable. cleanup() { # Ask the containers which volumes they own BEFORE removing them. A global # before/after count would be wrong here: several sessions share this box and # run these container pairs concurrently, so a neighbour's volume would read # as this run's leak. local owned="" owned=$(docker inspect -f \ '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{end}}{{end}}' \ "$id-db" "$id-cli" 2>/dev/null || true) docker rm -f -v "$id-db" "$id-cli" >/dev/null 2>&1 || true docker volume rm -f "$id-wp" >/dev/null 2>&1 || true docker network rm "$net" >/dev/null 2>&1 || true # Count rather than trust the flag. This is advisory on a failed run — a test # that died halfway has more urgent news — but on a successful run a leftover # volume is a real regression and says so. local leaked="" for v in $owned; do docker volume inspect "$v" >/dev/null 2>&1 && leaked="$leaked $v" done if [ -n "$leaked" ]; then echo "::warning::This run left docker volume(s) behind:$leaked — 95 of these filled the Gitea runner's disk." # `return` from an EXIT trap does NOT change the script's exit status; # only `exit` does. Without this the warning would print and the build # would still go green, which is the failure mode of every other check # this kit has had to fix tonight. if [ "${site_test_ok:-0}" = 1 ]; then ci_fail "The site test leaked the volume(s) above. Left alone this fills the runner's disk, and the symptom is a push failing with 'unable to create temporary object directory'." exit 1 fi fi } trap cleanup EXIT echo "WordPress=$WP_VERSION WooCommerce=$WC_VERSION PHP=$PHP_VERSION plugin=$PLUGIN_SLUG" ci_step "database" docker network create "$net" >/dev/null docker volume create "$id-wp" >/dev/null docker run -d --name "$id-db" --network "$net" \ -e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=wordpress \ -e MARIADB_USER=wordpress -e MARIADB_PASSWORD=wordpress mariadb:lts >/dev/null # wordpress:cli runs as www-data (33); a fresh named volume is root-owned, so # ownership is fixed from a root container before anything tries to write. docker run --rm -v "$id-wp:/var/www/html" alpine chown 33:33 /var/www/html docker run -d --name "$id-cli" --network "$net" -v "$id-wp:/var/www/html" \ -e WORDPRESS_DB_HOST="$id-db" "wordpress:cli-php$PHP_VERSION" sleep infinity >/dev/null # The image ships no php.ini, so PHP's 128M default applies and `wp core # download` dies unpacking the zip. WP_CLI_PHP_ARGS does not help here: the # phar is executed directly, not through the wp launcher. docker exec -u root "$id-cli" sh -c 'echo memory_limit=512M > /usr/local/etc/php/conf.d/zz-memory.ini' wp() { docker exec "$id-cli" wp "$@"; } for i in $(seq 1 60); do docker exec "$id-db" mariadb -uwordpress -pwordpress -e 'select 1' wordpress >/dev/null 2>&1 && break if [ "$i" = 60 ]; then echo "MariaDB never came up." >&2; exit 1; fi sleep 2 done ci_step "WordPress $WP_VERSION" if [ "$WP_VERSION" = latest ]; then wp core download --quiet else wp core download --version="$WP_VERSION" --quiet fi wp config create --dbname=wordpress --dbuser=wordpress --dbpass=wordpress \ --dbhost="$id-db" --quiet # Deprecations are the way a WordPress or PHP upgrade actually breaks a plugin: # nothing fails, a notice appears, and six months later it is a fatal. They are # logged to a file (not the page) and checked at the end of this script. wp config set WP_DEBUG true --raw --quiet wp config set WP_DEBUG_LOG true --raw --quiet wp config set WP_DEBUG_DISPLAY false --raw --quiet wp core install --url=http://plugin.test --title="$PLUGIN_SLUG CI" \ --admin_user=admin --admin_password=admin --admin_email=ci@example.com \ --skip-email --quiet echo " installed $(wp core version)" if [ "$NEEDS_WOOCOMMERCE" = yes ]; then ci_step "WooCommerce $WC_VERSION" case "$WC_VERSION" in latest) wp plugin install woocommerce --activate --quiet ;; beta) # Resolved inside the container, which has PHP and network; the host # runner is not guaranteed either. target=$(docker exec -i -e SLUG=woocommerce "$id-cli" php < ci/latest-prerelease.php) if [ -n "$target" ]; then echo " prerelease available: $target" wp plugin install woocommerce --version="$target" --activate --quiet else echo " no prerelease newer than stable right now — using the stable release" wp plugin install woocommerce --activate --quiet fi ;; *) wp plugin install woocommerce --version="$WC_VERSION" --activate --quiet ;; esac # HPOS, because that is what a shop set up in the last few years runs, and a # plugin that touches orders behaves differently under it. wp option update woocommerce_custom_orders_table_enabled yes >/dev/null echo " installed $(wp plugin get woocommerce --field=version)" fi ci_step "$PLUGIN_SLUG" # Exactly the files the release zip will contain, plus the site tests. Testing # the working tree instead would test a plugin no shop ever receives: one # unanchored .gitignore line is enough for a file to be present locally, # absent from the release, and never missed until a customer installs it. { ./ci/dist-files.sh; printf '%s\n' ${SITE_TESTS:-}; } | tr '\n' '\0' | tar --null -T - -cf - \ | docker exec -i "$id-cli" sh -c \ "mkdir -p /var/www/html/wp-content/plugins/$PLUGIN_SLUG \ && tar -xf - -C /var/www/html/wp-content/plugins/$PLUGIN_SLUG" wp plugin activate "$PLUGIN_SLUG" --quiet wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \ || { echo "The plugin did not stay active after activation." >&2; exit 1; } echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)" # 🔴 The output is captured and the summary checked HERE, by the caller. # # A smoke file cannot assert about its own early exit: `wp eval-file` runs it # in function scope, so a `return` partway through skips everything after it — # including any floor the file carries at the bottom. Measured: truncating # tests/smoke.php before the shipping section ran two thirds of the assertions # and this script reported green, because the file's own floor was one of the # things never reached. # # So the number the file prints is verified against MIN_SITE_ASSERTIONS out # here, where an early return cannot reach. The floor inside the file stays as # well; it catches a short run that still gets to the end. for f in ${SITE_TESTS:-}; do ci_step "$f" out=$(wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f" 2>&1) || { printf '%s\n' "$out"; exit 1; } printf '%s\n' "$out" case "$f" in *smoke*) ran=$(printf '%s' "$out" | sed -n -E 's/^All ([0-9]+) assertions passed.*/\1/p' | tail -1) if [ -z "$ran" ]; then echo ci_fail "$f printed no assertion total. It exited before its summary — which is what a file that returned halfway through looks like, and it cannot report that about itself." exit 1 fi if [ "$ran" -lt "$MIN_SITE_ASSERTIONS" ]; then echo ci_fail "$f ran only $ran assertions, expected at least $MIN_SITE_ASSERTIONS. Part of the suite did not run." exit 1 fi echo " $ran assertions, at or above the floor of $MIN_SITE_ASSERTIONS" ;; esac done ci_step "PHP notices and deprecations" # Only lines naming this plugin. WordPress core and WooCommerce deprecate their # own things on every release, and failing on those would make the build red # for something nobody here can fix — which is how a red build gets ignored. log=$(wp eval 'echo WP_CONTENT_DIR;')/debug.log if docker exec "$id-cli" test -f "$log"; then if docker exec "$id-cli" grep -F "/plugins/$PLUGIN_SLUG/" "$log"; then echo ci_fail "The plugin logged the notices above on WordPress $(wp core version). They are the shape an update breaks in: harmless now, fatal two releases later." exit 1 fi echo " none from this plugin" else echo " nothing logged at all" fi # Set only here, so the leak check above is advisory on a run that failed for # some other reason and fatal on one that otherwise passed. site_test_ok=1 ci_step "done" echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"