Kristian named the five plugins as a family, and three repos disagreed with what
he calls them. His decision: rename the repo, never the installed slug.
MEASURED BEFORE DOING IT, because four published releases carry the old URL in
their Update URI header. A throwaway repo was created, renamed and deleted:
old API path 301 -> new
old web path 301 -> new
old /releases/download/... path 301 -> new
and WordPress follows up to 5 redirects by default (class-wp-http.php:191), on
GET. Confirmed afterwards against the real thing: the old feed URL resolves to
v1.4.5 and the old download path still serves 48185 bytes.
So a shop on 1.4.2 keeps updating through the redirect, and 1.4.6 moves it onto
the new address directly.
PLUGIN_SLUG and PLUGIN_MAIN are deliberately untouched and now disagree with the
repository name. That is permanent: the installed directory has been
woocommerce-email-discount-manager since the first release and is on a live
shop, and changing it would not move that installation — it would put a SECOND
copy beside it and leave the old one active. ci/plugin.conf says so in the file,
because otherwise somebody will helpfully align them.
Also in, both for the family rather than for this plugin alone:
- docs/REUSE.md gains the hostname-keyed hook as a first-class section. It is
the only bug we have found that is CAUSED by there being more than one plugin,
so it is invisible to anyone reading a single repo — along with the same shape
in a shared-*.php file copied into five plugins, which fatals with "Cannot
redeclare" on the second activation without function_exists() guards.
- ci/static.sh fails on anything shaped like a credential in a tracked file.
Suggested by the AI-manager port: a pushed key cannot be unpushed, only
rotated. Deliberately narrow — sk-ant-, sk-, ghp_, gho_, github_pat_, AKIA,
PRIVATE KEY blocks — because a broad entropy heuristic fires on minified
assets and a check that cries wolf gets commented out.
Verified failing before being believed: a file containing sk-ant-api03-... makes
the step report the filename and the suite exit 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
241 lines
11 KiB
Bash
Executable File
241 lines
11 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 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.
|
|
before=$fails
|
|
while IFS= read -r -d '' f; do
|
|
[ -f "$f" ] || continue
|
|
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)
|
|
[ "$fails" = "$before" ] && echo " nothing that looks like a key or a private key"
|
|
|
|
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."
|