Four checks in ci/ printed a reassuring line having examined nothing. Found independently by the woo-ai-manager and woo-cache-manager sessions in their own copies of this kit; all four reproduce here. A check that examined nothing prints the same sentence as one that examined everything, and only the passing side can tell them apart. That is why these survived being verified: a pattern that is too broad fails loudly the first time it meets real code, while one that finds nothing passes forever. So every success line now carries a count — the old text domain line could not tell 142 from 0. - static.sh, direct-access guard: counts what the loop examined and fails below MIN_SHIPPED_PHP_FILES. shipped_files() swallows a dist-files.sh failure with `|| true`, so an empty list is one broken .distignore away, and this is the check standing between a PHP file and being requested straight from a browser outside every capability check the code assumes has happened. The floor is above 1 because a pattern that wrongly excludes includes/ collapses the list without emptying it. - static.sh, credential scan: counts what it read, fatal on zero. A pushed key is compromised even after the commit is deleted, so a silent no-op here is not a weak check but the absence of one wearing a green tick. The floor is 1 and is not configurable: this list has no filter that could go partially wrong, so the only way it misreports is by coming out empty. - check-textdomain.php: three zeros, three verdicts. No PHP files at all is always fatal and deliberately not caller-configurable — it says the scan root is wrong, not that the plugin is small. Below --min is fatal. Zero calls with no --min is a warning, since a small plugin may legitimately have no translatable strings, but it is never silent. - check-textdomain.php: fix the annotated path. str_replace( $root, '', $path ) with the default root of "." removed every dot anywhere in the path, so a real error in includes/class-db.php was reported on "includes/class-dbphp" — not a file, not clickable, and not greppable. Every error this script printed with the default root was mangled that way. The floors live in ci/plugin.conf, the only file under ci/ that knows anything about this plugin, rather than hardcoded in static.sh: static.sh is copied verbatim between plugins too, so a number in it would make this plugin's shape everybody's constraint. ci_load_conf now requires them — an unset floor turns its comparison into a no-op and silently restores the bug. Each floor was broken once and watched to fail before being believed: empty shipped list, wrong scan root, --min above the real count, zero calls without --min, empty credential list, and a plugin.conf missing both floors. Static checks green, and now report 13 shipped PHP files, 142 translation calls across 17 files, 44 tracked files read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274 lines
13 KiB
Bash
Executable File
274 lines
13 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.
|
|
guard_scanned=0
|
|
while read -r f; do
|
|
[ -n "$f" ] || continue
|
|
guard_scanned=$((guard_scanned + 1))
|
|
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)"
|
|
# 🔴 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
|
|
|
|
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"
|
|
# --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))
|
|
|
|
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 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.
|
|
#
|
|
# Deliberately narrow. Broad entropy heuristics fire on minified assets, hashes
|
|
# and base64 images, and a check that cries wolf gets commented out — which is
|
|
# worse than not having it. These are prefixes that mean one thing only.
|
|
#
|
|
# 🔴 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.
|
|
before=$fails
|
|
secrets_scanned=0
|
|
while IFS= read -r -d '' f; do
|
|
[ -f "$f" ] || continue
|
|
secrets_scanned=$((secrets_scanned + 1))
|
|
if LC_ALL=C grep -nE '(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-----)' "$f"; then
|
|
note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it."
|
|
fi
|
|
done < <(git ls-files -z)
|
|
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
|
|
|
|
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."
|