2026-09-16 10:34:38 +02:00
#!/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
2026-09-16 13:18:22 +02:00
# -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
2026-09-16 10:34:38 +02:00
[ -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 )) ; }
2026-09-16 13:18:22 +02:00
done < <( git ls-files -z '*.php' )
2026-09-16 10:34:38 +02:00
[ " $fails " = " $before " ] && echo " clean"
ci_step "text domain"
2026-09-16 10:40:19 +02:00
ci_php ci/check-textdomain.php " $TEXT_DOMAIN " . || fails = $(( fails + 1 ))
2026-09-16 10:34:38 +02:00
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
2026-09-16 10:40:19 +02:00
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
2026-09-16 10:34:38 +02:00
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
2026-09-16 12:59:44 +02:00
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"
2026-09-16 10:34:38 +02:00
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."