Files
kristianwindandClaude Opus 5 a1f6e54f9d The smoke test could not fail, and other people's eyes
Four fixes, two of them serious, three of them found by the session porting
this into subscriptions.

1. THE SMOKE TEST COULD NOT FAIL. `wp eval-file` runs the file through eval()
   INSIDE A METHOD, so the top level is function scope. `$wc_edm_failures = 0`
   at the top and `global $wc_edm_failures` in the helper were two different
   variables: the helpers incremented the real global, the verdict read the
   local, and the local was always zero.

   Measured rather than reasoned. A deliberately false assertion added to the
   unmodified file:

       FAIL  DELIBERATE FAILURE — this run must go red
       All assertions passed on WordPress 7.1 with WooCommerce 11.1.0.
       EXIT=0

   and with the fix, the same forced failure:

       FAIL  DELIBERATE FAILURE — this run must go red
       1 assertion(s) failed on WordPress 7.1.
       EXIT=1

   The FAIL lines always printed, so failures found by reading the output were
   real finds. What was broken is the CI gate: a regression on either forge
   would have gone green. Four lines at the top now prove the counter reaches
   the verdict before anything relies on it — this is the one file whose whole
   job is to be able to go red, so it checks that it can.

2. `git ls-files` QUOTES NON-ASCII PATHS. "TESTMILJØ.md" comes back as the
   literal string "TESTMILJ\303\230.md", which matches no pattern in
   .distignore, so the file is kept — an internal document walking into the
   release zip with nothing said. Fixed in ci/dist-files.sh and in
   ci/static.sh's trailing-?> check, which had the same bug and would have
   silently skipped the file it exists to check. Verified by adding such a
   filename and watching it be excluded. `done < <(...)` rather than a pipe, or
   the loop runs in a subshell and the failure counter stops incrementing.

3. THE FEED CACHE WAS SIX HOURS, and Kristian hit it: a release published and
   his shop shown nothing. The reasoning ("WordPress checks twice a day") was
   wrong. WordPress gates its own check by screen in wp_update_plugins() — one
   minute on Dashboard → Updates, one hour on Plugins, 2–12 hours in the
   background — so WordPress is already the rate limiter and a longer cache
   only hides releases underneath it. Fifteen minutes now, pinned by an
   assertion.

4. The wp_update_plugins() assertion made a real HTTP request, because the
   force-check tests had just deleted the transient. It passed only because
   this feed repo is public, which made it measure the environment rather than
   the code. Re-primed first.

ci_in_container() replaces the inline docker plumbing in ci_php(), taken from
the subscriptions port: the script goes in as a file rather than interpolated
into `sh -c`, and --entrypoint sh matters for images whose entrypoint swallows
its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:18:22 +02:00

224 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Checks that need no WordPress: syntax, plugin hygiene, version consistency,
# and whether the plugin header still tells the truth about what it supports.
#
# Fast enough to run before every commit. `ci/site-test.sh` is the slow half.
set -uo pipefail
cd "$(dirname "$0")/.."
. ci/lib.sh
ci_load_conf
fails=0
note_fail() { ci_fail "$1"; fails=$((fails + 1)); }
# Only files that actually reach a server. A tool under ci/ has no business
# carrying an ABSPATH guard, and a test file is not shipped at all. Same list
# the release zip is built from, so the two cannot drift.
shipped_files() {
./ci/dist-files.sh | grep '\.php$' || true
}
ci_step "PHP syntax"
# The header promises a minimum PHP version; without it in the loop a piece of
# newer syntax passes here and fatals on the customer's server instead.
min_php=$(ci_plugin_header 'Requires PHP')
tarball=$(mktemp -t wpci-lint-XXXXXX.tar)
git ls-files -z '*.php' | tar --null -T - -cf "$tarball"
for v in "${min_php:-7.4}" 8.4; do
echo " php $v"
# docker cp of a tarball, not -v "$PWD":/src — a Gitea Actions job runs
# inside a container talking to the HOST's docker daemon, so a host path
# here mounts an empty directory nobody created and the lint passes by
# linting nothing. Copying a directory needs its parent to already exist in
# the container, which /src does not; a single file into /tmp always works.
cid=$(docker create "php:$v-cli" sh -c '
set -e
mkdir -p /src && tar -xf /tmp/src.tar -C /src
n=0
for f in $(find /src -name "*.php"); do php -l "$f" >/dev/null; n=$((n + 1)); done
echo " $n files parse"
# Guards against the failure this step had on its first run: an empty /src
# makes the loop a no-op and the whole check reports success.
[ "$n" -gt 0 ] || { echo " no PHP files arrived in the container" >&2; exit 1; }')
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
code=0
docker start -a "$cid" || code=$?
docker rm -f "$cid" >/dev/null
[ "$code" = 0 ] || note_fail "PHP $v cannot parse this plugin."
done
rm -f "$tarball"
ci_step "protection against direct access"
before=$fails
# Without the guard a PHP file can be requested straight from the browser,
# outside WordPress and therefore outside every capability check the code
# assumes has already happened.
while read -r f; do
[ -n "$f" ] || continue
grep -q "defined( 'ABSPATH' )\|defined( 'WP_UNINSTALL_PLUGIN' )" "$f" \
|| { ci_annotate error "$f" 1 "Missing the guard against direct access: if ( ! defined( 'ABSPATH' ) ) { exit; }"; fails=$((fails + 1)); }
done <<< "$(shipped_files)"
[ "$fails" = "$before" ] && echo " every shipped file is guarded"
ci_step "no debug leftovers"
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
note_fail "Debug functions must not ship."
else
echo " none"
fi
ci_step "no short open tags, no closing ?>"
before=$fails
# With short_open_tag off the code is printed to the visitor as text instead of
# being run; whitespace after a closing tag is sent to the browser and breaks
# headers and redirects.
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
note_fail "Use <?php, not short open tags."
fi
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
# exists, so this loop would silently skip the file it was meant to check.
while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
done < <(git ls-files -z '*.php')
[ "$fails" = "$before" ] && echo " clean"
ci_step "text domain"
ci_php ci/check-textdomain.php "$TEXT_DOMAIN" . || fails=$((fails + 1))
ci_step "every file the plugin loads is a file the release contains"
# The bug this exists for: a new class file is written, required from the main
# file, works perfectly in every local test because it is sitting right there
# in the working tree — and is not committed, so the release zip does not have
# it. This plugin degrades instead of fataling on a missing include, which is
# good for a half-finished upgrade and terrible here: the shop would just
# quietly lose a feature.
shipped=$(./ci/dist-files.sh)
referenced=$(printf '%s\n' "$shipped" | grep '\.php$' | xargs grep -ohE "'[A-Za-z0-9_./-]+\.php'" 2>/dev/null | tr -d "'" | sort -u)
checked=0
for path in $referenced; do
# A plugin-relative include always names a directory of this plugin. That
# rules out the look-alikes: "admin-ajax.php" and "admin.php" are admin URLs
# and menu slugs, "woocommerce/woocommerce.php" is another plugin's basename,
# and "wp-admin/includes/upgrade.php" is resolved against ABSPATH.
case "$path" in */*) ;; *) continue ;; esac
[ -d "${path%%/*}" ] || continue
checked=$((checked + 1))
printf '%s\n' "$shipped" | grep -qx "$path" \
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
done
if [ "$checked" = 0 ]; then
# A check that verifies nothing must say so rather than reporting success.
# Not a failure: a single-file plugin, or one with an autoloader instead of
# a list of requires, legitimately has nothing here.
echo "::warning::No plugin-relative include was found to check. That is expected for a single-file plugin or an autoloader, and a broken scan otherwise."
else
echo " $checked plugin-relative include(s) checked"
fi
ci_step "one version, stated once"
# WordPress compares the update feed against the header and nothing else, so a
# constant or a readme that disagrees with it is how a release ends up either
# offered forever or never offered at all.
version=$(ci_plugin_version)
echo " plugin header: $version"
[ -n "$version" ] || note_fail "Could not read Version: from $PLUGIN_MAIN."
while read -r line; do
[ -n "$line" ] || continue
const=${line%%=*}
value=${line#*=}
echo " $const: $value"
[ "$value" = "$version" ] \
|| note_fail "$const is $value but the plugin header says $version."
done <<< "$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'[[:space:]]*,[[:space:]]*'([^']+)'.*/\1=\2/p" "$PLUGIN_MAIN")"
for readme in readme.txt README.txt; do
[ -f "$readme" ] || continue
stable=$(sed -n -E 's/^Stable tag:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
echo " $readme stable tag: ${stable:-<none>}"
[ "$stable" = "$version" ] \
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
done
ci_step "no placeholders left in the plugin header"
# The header is the plugin's business card: wp-admin shows the author and the
# links to whoever installs it, so a placeholder there survives for as long as
# nobody happens to look at that screen. This plugin shipped with
# "https://example.com/" as its Plugin URI for several releases.
#
# It catches the generic markers only. A made-up vendor name — this one went
# out as "WEDSMP" — looks exactly like a real one to any check, so an empty
# field is warned about but a wrong-but-plausible value cannot be.
before=$fails
for field in 'Plugin Name' 'Description' 'Author' 'Author URI' 'Plugin URI'; do
value=$(ci_plugin_header "$field")
if [ -z "$value" ]; then
echo "::warning::The plugin header has no '$field:'."
continue
fi
case "$value" in
*example.com*|*example.org*|*yourname*|*YOUR_*|*TODO*|*FIXME*|*changeme*|*'<'*'>'*)
note_fail "$field is still a placeholder: $value"
;;
esac
done
[ "$fails" = "$before" ] && echo " name, description, author and links are all filled in"
ci_step "the compatibility headers still tell the truth"
# Advisory, never fatal. WordPress ships a new version whether or not anything
# here is wrong, and a build that goes red on release day for a header nobody
# has had a chance to update yet is a build that gets ignored. What this does
# is make the staleness visible; compat.yml opens the PR that fixes it, but
# only once the real tests have passed against the new version.
# stable-check, not version-check: version-check's payload also carries old
# offers, and a loose match on it returned "4.7.35" as the current release.
current_wp=$(curl -fsS --max-time 20 'https://api.wordpress.org/core/stable-check/1.0/' 2>/dev/null \
| tr ',' '\n' | sed -n -E 's/.*"([0-9][^"]*)"[[:space:]]*:[[:space:]]*"latest".*/\1/p' | head -1)
current_wc=$(curl -fsS --max-time 20 'https://api.wordpress.org/plugins/info/1.0/woocommerce.json' 2>/dev/null \
| sed -n -E 's/.*"version":"([^"]+)".*/\1/p' | head -1)
behind() { # claimed current -> true when claimed is the older of the two
[ -n "$1" ] && [ -n "$2" ] || return 1
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}
# "Tested up to" is a readme.txt field — WordPress never reads it from the
# plugin header, so checking the header for it reports "missing" on a plugin
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
# reads that one from the plugin header and nowhere else.
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
tested_wp=
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
echo " WordPress: ${readme:-readme} says ${tested_wp:-<missing>}, current release is ${current_wp:-<unknown>}"
if [ -z "$tested_wp" ]; then
echo "::warning::No 'Tested up to:' in ${readme:-the readme}. WordPress lists the plugin as untested."
elif behind "$tested_wp" "$current_wp"; then
echo "::warning::'Tested up to: $tested_wp' is behind WordPress $current_wp."
fi
if [ "$NEEDS_WOOCOMMERCE" = yes ]; then
tested_wc=$(ci_plugin_header 'WC tested up to')
echo " WooCommerce: plugin header says ${tested_wc:-<missing>}, current release is ${current_wc:-<unknown>}"
if [ -z "$tested_wc" ]; then
echo "::warning::No 'WC tested up to:' in the plugin header. WooCommerce then shows this plugin as untested in wp-admin — that banner is exactly what a shop owner reads before deciding whether to press update."
elif behind "$tested_wc" "$current_wc"; then
echo "::warning::'WC tested up to: $tested_wc' is behind WooCommerce $current_wc."
fi
requires_wc=$(ci_plugin_header 'WC requires at least')
echo " WooCommerce minimum: ${requires_wc:-<missing>}"
fi
echo
if [ "$fails" != 0 ]; then
echo "$fails check(s) failed."
exit 1
fi
echo "Static checks: green."