The family shape is source private, releases in a separate public repo so a shop can fetch an update without credentials and without the source being readable. This plugin was the only one still serving its own update feed out of the repository that holds ci/, docs/ and tests/. kw/nolimit-discounts-dist created (public, zips only). RELEASE_REPO in ci/plugin.conf now names it, and ci/publish-release.sh honours it over GITHUB_REPOSITORY — falling back to the running repo, which is what a plugin without a split still wants. 🔴 The "Update URI:" header moves in the SAME commit as the publishing target, and ci/static.sh now fails when the two disagree. The header is what every installed copy polls; if it names a repository nothing is published to any more, the shop stops being offered updates and nothing announces it, because latest_release() simply returns false. That is the same failure as a privacy page standing still while the code moves, and the check is what makes it impossible to forget. Proven red before being believed: pointing RELEASE_REPO at another repository fails with both sides named. RELEASE_REPO is required by ci_load_conf, because an unset value would turn the comparison into a no-op and restore exactly the bug it prevents. Not done here, and it is the blocker for actually cutting a release: publishing to another repository needs a token with write access to it. The job token only covers the repo it runs in, so this needs a cross-repo PAT in the repository secrets, and that is Kristian's to add. Also not done, deliberately: the source repo stays public for now. Making it private before the dist repo has releases would break the feed for the copy installed on the live shop, whose header still points at the old address. 1.4.6 and earlier keep working through Gitea's rename redirect - measured, the old name still answers 200 - and that redirect is the only thing holding them up, so it must not be removed until a build carrying the new header has propagated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
333 lines
17 KiB
Bash
Executable File
333 lines
17 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 "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
|
|
|
|
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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 🔴 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.
|
|
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|\*\*\*\*'
|
|
|
|
before=$fails
|
|
secrets_scanned=0
|
|
while IFS= read -r -d '' f; do
|
|
[ -f "$f" ] || continue
|
|
secrets_scanned=$((secrets_scanned + 1))
|
|
# 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
|
|
note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it."
|
|
fi
|
|
# -I is right HERE: this rule is heuristic and name-anchored, so a chance
|
|
# match inside a compiled asset is noise, not a finding.
|
|
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
|
|
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."
|