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.
2026-09-17 00:14:40 +02:00
guard_scanned = 0
2026-09-16 10:34:38 +02:00
while read -r f; do
[ -n " $f " ] || continue
2026-09-17 00:14:40 +02:00
guard_scanned = $(( guard_scanned + 1 ))
2026-09-16 10:34:38 +02:00
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) "
2026-09-17 00:14:40 +02:00
# 🔴 The count is the result, not decoration. "every shipped file is guarded"
# is also what ZERO shipped files prints, and shipped_files() swallows a
# dist-files.sh failure with `|| true`, so an empty list is one broken
# .distignore away. The floor is above 1 on purpose: a pattern that wrongly
# excludes includes/ leaves the main file behind and collapses the list
# without emptying it. Proven by stubbing ci/dist-files.sh to print nothing.
if [ " $guard_scanned " -lt " $MIN_SHIPPED_PHP_FILES " ] ; then
note_fail "Only $guard_scanned shipped PHP file(s) were examined for the direct-access guard, expected at least $MIN_SHIPPED_PHP_FILES . The file list is wrong, so this check proved nothing."
elif [ " $fails " = " $before " ] ; then
echo " $guard_scanned shipped PHP files examined, every one guarded"
fi
2026-09-16 10:34:38 +02:00
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-17 00:14:40 +02:00
# --min is deliberately far below the real count. It is not an assertion about
# how many strings this plugin has — a tight number goes red on ordinary churn
# and gets raised until it means nothing. It asserts the scan still finds this
# plugin's strings AT ALL, because "every translation call carries the text
# domain" is also what this prints having found none.
ci_php ci/check-textdomain.php "--min= $MIN_TRANSLATION_CALLS " " $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-17 22:38:12 +02:00
ci_step "the update feed and the release target name the same repository"
# 🔴 The header is what every installed copy polls; RELEASE_REPO is where the
# zip is actually published. If they disagree, the shop keeps asking an address
# nothing is published to any more and quietly stops receiving updates —
# latest_release() just returns false, and nothing says so. Same failure as a
# privacy page that stands still while the code moves: the two must change in
# one commit, and this is what makes that impossible to forget.
update_uri = $( ci_plugin_header 'Update URI' )
echo " Update URI: ${ update_uri :- <missing> } "
echo " RELEASE_REPO: $RELEASE_REPO "
case " $update_uri " in
*/" $RELEASE_REPO " ) echo " they agree" ;;
*) note_fail "Update URI is ' $update_uri ' but releases are published to ' $RELEASE_REPO '. Installed copies poll the header, so they would stop being offered updates without a word." ;;
esac
2026-09-16 18:18:50 +02:00
ci_step "no secrets in tracked files"
# Suggested by the AI-manager port, and it costs nothing: a pushed key cannot be
# unpushed. It has to be rotated, and the window between the push and noticing
# is the whole problem.
#
2026-09-17 00:20:44 +02:00
# Two rules, at two different confidence levels.
#
# The first is content-anchored: prefixes that mean one thing only. Broad
# ENTROPY heuristics are still refused here — they fire on minified assets,
# hashes and base64 images, and a check that cries wolf gets commented out,
# which is worse than not having it.
#
# The second is name-anchored, and closes a hole the first cannot see. A prefix
# list matches the shape of a VALUE, so it is blind to any credential that does
# not carry a vendor's marker: an api_key assigned twenty opaque characters
# walks straight past it. This is not an entropy heuristic — it fires only
# where the code itself names the value a secret, which is why it does not
# touch a minified asset. Obvious placeholders are excluded so a settings
# default does not cry wolf.
#
# ⚠️ It DOES fire on a documentation example, and there is no bypass marker on
# purpose — an unexercised escape hatch is its own hollow check. The first
# version of this comment spelled the example out in full and the rule flagged
# its own explanation, which is the one false positive measured so far. Write
# such an example in prose, or break the literal up.
#
# 🔴 A count cannot tell you a pattern is not INERT. Proven the hard way in a
# sibling plugin: it planted an AWS key, the file count rose, and the scan
# passed green — that prefix was simply missing from its list. So every shape
# claimed below is planted and run through this whole script, not grepped in
# isolation, and a lookalike control is checked not to fire.
2026-09-17 00:14:40 +02:00
#
# 🔴 And it says how many files it read. A pushed key is compromised even after
# the commit is deleted, so this scan silently examining nothing is not a weak
# check — it is the absence of one, wearing a green tick.
#
# The floor here is 1 and is NOT plugin-configurable, unlike the shipped-file
# one above: this list is every tracked file with no filter that could go
# partially wrong, so the only way it misreports is by coming out empty, and
# any higher number would just be a guess about the plugin's size. The count on
# the success line is what catches a list that shrank without emptying.
2026-09-17 00:20:44 +02:00
secret_value_pat = '(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
# The code calls it a secret and then hands it twenty-odd opaque characters.
secret_name_pat = "['\"](api[_-]?key|secret|password|passwd|token|auth[_-]?token|private[_-]?key)['\"][[:space:]]*(=>|=|:)[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]|define\([[:space:]]*['\"][A-Z0-9_]*(SECRET|PASSWORD|TOKEN|API_KEY)['\"][[:space:]]*,[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]"
# A default nobody has filled in yet is not a leak. Without this the check goes
# red on the one thing a settings screen is guaranteed to contain.
placeholder_pat = 'REPLACE|YOUR[_-]|EXAMPLE|CHANGE[_-]?ME|TODO|FIXME|PLACEHOLDER|xxxxx|XXXXX|\*\*\*\*'
2026-09-16 18:18:50 +02:00
before = $fails
2026-09-17 00:14:40 +02:00
secrets_scanned = 0
2026-09-16 18:18:50 +02:00
while IFS = read -r -d '' f; do
[ -f " $f " ] || continue
2026-09-17 00:14:40 +02:00
secrets_scanned = $(( secrets_scanned + 1 ))
2026-09-17 00:25:52 +02:00
# No -I here, deliberately, and the two rules differ on this for a reason.
# grep -I SILENTLY SKIPS a file it decides is binary, so a PEM header inside
# one would be waved through without a word. Without -I the same file reports
# "binary file matches" and fails, which is the right direction to be wrong
# in. (Narrowed by accident when the name rule was added, and caught only by
# asking what the flag actually does — the tree has no binary files today, so
# nothing went red to announce it.)
if LC_ALL = C grep -nE " $secret_value_pat " " $f " ; then
2026-09-16 18:18:50 +02:00
note_fail " $f looks like it contains a credential. It cannot be unpushed — rotate it."
fi
2026-09-17 00:25:52 +02:00
# -I is right HERE: this rule is heuristic and name-anchored, so a chance
# match inside a compiled asset is noise, not a finding.
2026-09-17 00:20:44 +02:00
if LC_ALL = C grep -nEI " $secret_name_pat " " $f " | LC_ALL = C grep -vE " $placeholder_pat " ; then
note_fail " $f assigns a long opaque value to something the code calls a secret. If it is one, it cannot be unpushed — rotate it."
fi
2026-09-16 18:18:50 +02:00
done < <( git ls-files -z)
2026-09-17 00:14:40 +02:00
if [ " $secrets_scanned " -lt 1 ] ; then
note_fail "The credential scan examined no files at all. git tracks nothing here, so this check proved nothing."
elif [ " $fails " = " $before " ] ; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi
2026-09-16 18:18:50 +02:00
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."