Files
nolimit-discounts/ci/static.sh
T
kristianwindandClaude Opus 5 dccb87d15e Document the plugin, and check that the documentation still describes it
Five files under docs/, the names the family agreed: README, SETUP,
ARCHITECTURE, CONTRACT, RELEASE. Plus ci/check-docs.php, ported from
woo-security-manager 52dd99d, wired into the static job.

CONTRACT.md is generated from the source rather than written from memory, and
that was the right instruction: my first sweep read only single-quoted hook
names and missed two rows, one of them update_plugins_{$hostname} — the hook
behind the live-shop collision, written with interpolation and therefore
invisible to a pattern built from the concept of the line. Nine hooks
documented: six wc_edm_* filters this plugin offers, three wpmgr_* it both
offers and answers through its copy of the frozen shared-menu.php.

Two hooks the checker structurally cannot see are listed by hand with a note
saying so: wc_edm_prune_audit_log (add_action on a class constant) and
update_plugins_{$hostname} (interpolated). A generated list beats a remembered
one and is still a list of what the generator can see.

The checker needed two fixes before it was safe to run here.

🔴 The published version reads HOOK_PREFIX from ci/plugin.conf for the
documented side, and refuses without it — then tests the source side against a
hardcoded 'nlsec_'. Ported verbatim that is not a green over nothing, which
would be easy to spot. Measured in this tree with CONTRACT.md documenting no
hook at all:

  3 hook(s) in the shipped source and not in docs/CONTRACT.md:
  wpmgr_dashboard_cards, wpmgr_menu_glyph, wpmgr_menu_title

Red, specific, and blind to all six wc_edm_* hooks — because 'wpmgr_' is the
same string in every tree and 'nlsec_' is the one that is not. Fix those three
and it goes green over six undocumented contract hooks, with a red run behind
you as evidence that it works. A partially blind check that fails is more
convincing than one that passes.

Note which step disarmed the guard. The floor at 20 shipped PHP files refused
to run in this 14-file tree, and that refusal was the only thing standing in
the way; the porting instructions were to add HOOK_PREFIX and adjust the floor.
Doing as told is what removed the protection — two wrongs cancelling into a
pass, in the order CLAUDE.md predicts, because the floor is the error you can
see and the prefix is not.

Both numbers now come from the conf (HOOK_PREFIX, MIN_SHIPPED_PHP_FILES) and
both refuse rather than defaulting. The inherited literal 20 is gone: a second
hardcoded number per plugin is a second thing to re-measure on every port.

Ten selftest assertions, 58 total. Four failure modes, the two refusals, the
counts on the pass, and the row that matters most:

🔴 A hook name in a COMMENT must not read as a hook call. CONTRACT.md documents
a filter by showing the apply_filters() a consumer makes, and the same example
sits in a docblock — so a checker that reads comments reports this plugin
consuming a filter it answers, in the file explaining why it must not. That
assertion is a control asserting a PASS, which is the kind that can quietly
become unable to fail, so it names the specific success line rather than
reading an exit code, and it is proven both ways: with token_get_all's
T_COMMENT branch made inert, exactly one of 58 assertions fails and it is that
one.

docs/TESTING-AND-RELEASES.md is folded into RELEASE.md and deleted rather than
left beside it — two copies of a rule is how the copies start to disagree.
Folding surfaced that it still documented the pre-rename Update URI
(kw/woocommerce-email-discount-manager) and still recommended, as future work,
the split into a releases-only repository that has already happened. Both
corrected in place, with the correction dated.

docs/ is not in ci/dist-files.sh — measured, 0 docs files in the zip.

static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with
WooCommerce 11.1.2. ci/ and docs/ ship nothing, so no version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:45:29 +02:00

544 lines
28 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
}
# 🔴 scan_tracked: the only way this file greps a set of tracked files.
#
# The form it replaces — `if git ls-files -z … | xargs -0 grep -nE …; then` —
# has a false GREEN in it. `xargs` exits 123 when ANY of its invocations exited
# 1-125, and `grep` exits 1 on no-match, so a run where one batch matched and
# another did not exits 123 and `if` reads that as clean. Measured:
#
# one batch, a match xargs exit 0 -> caught
# one batch, no match xargs exit 123 -> clean, correct
# two batches, one match xargs exit 123 -> CLEAN, and it printed the hit
#
# Harmless only while the whole list fits in one exec. git ls-files hands over
# enough paths to split the moment a tree grows or a path gets long, and then
# the check passes over a violation it has already printed. Nothing about the
# output says which case you are in. Reported by the Nolimit Security session
# 2026-09-28, who hit it after widening their own scan from 13 files to 102.
#
# So: no xargs, and the verdict is the OUTPUT rather than any exit status. The
# loop also yields the count for free, which is the other thing every one of
# these checks was missing — "none" over zero files and "none" over nineteen
# are the same green.
#
# Sets: scan_hits (matches, one "file:line:text" per line), scan_files (how
# many files were read). A grep failure (exit > 1) is reported, never swallowed.
scan_tracked() { # $1 = -E|-P, $2 = pattern, rest = git pathspecs
local mode=$1 pattern=$2 f m rc
shift 2
scan_hits=''
scan_files=0
# -z: git QUOTES a non-ASCII path, and the quoted form is not a path that
# exists, so a line-based read silently skips the file it meant to check.
while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
scan_files=$((scan_files + 1))
m=$(grep -n "$mode" -e "$pattern" -- "$f")
rc=$?
if [ "$rc" -gt 1 ]; then
note_fail "grep failed on $f (exit $rc) — this scan did not examine it."
elif [ "$rc" = 0 ]; then
scan_hits="${scan_hits}$(printf '%s\n' "$m" | sed "s|^|$f:|")
"
fi
done < <(git ls-files -z "$@")
}
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"
# 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.
#
# 🔴 Tokenised by ci/check-abspath.php, not grepped. The grep version asked
# whether the words appeared anywhere in the file, so a COMMENT saying the
# guard was handled elsewhere passed it. Measured with a planted file whose
# only mention was in a comment: "15 shipped PHP files examined, every one
# guarded" — green over a file with no guard at all.
#
# The count and the floor stay: "every shipped file is guarded" is also what
# zero shipped files prints, and shipped_files() swallows a dist-files.sh
# failure with `|| true`. The floor is above 1 because a bad .distignore
# pattern can collapse the list without emptying it.
shipped_php=$(shipped_files)
if [ -z "$shipped_php" ]; then
note_fail "No shipped PHP files were listed at all. The file list is wrong, so this check proved nothing."
else
# shellcheck disable=SC2086
ci_php ci/check-abspath.php "--min=$MIN_SHIPPED_PHP_FILES" $shipped_php || fails=$((fails + 1))
fi
ci_step "no debug leftovers"
scan_tracked -E '\b(var_dump|print_r|dd)[[:space:]]*\(' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The debug-leftover scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Debug functions must not ship."
else
echo " none in $scan_files tracked PHP files"
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.
scan_tracked -P '<\?(?!php|=|xml)' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The open-tag scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Use <?php, not short open tags."
fi
open_tag_files=$scan_files
# -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 " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
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))
# `case` rather than piping the list into grep -qx: under pipefail a grep
# that matches can exit 141 when the producer is signalled, so a HIT becomes
# a failure. The list is small today and this loop runs once per include, so
# this also drops a fork per iteration. Newlines on both sides make it an
# exact line match, which is what -qx gave.
case $'\n'"$shipped"$'\n' in
*$'\n'"$path"$'\n'*) ;;
*) note_fail "The code loads '$path', but the release zip does not contain it. Committed?" ;;
esac
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
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
ci_step "the plugin is called the same thing everywhere"
# 🔴 The rename moved the code and left the text behind: the readme still told
# people to look under WooCommerce for a page that had moved to the Nolimit
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
# Kristian found it by reading the readme, which is the only reason it was
# found at all — nothing fails when a sentence goes stale.
#
# Same failure as a privacy page standing still while the code moves. The name
# in the header is the one WordPress shows, so it is the one everything else
# has to agree with.
header_name=$(ci_plugin_header 'Plugin Name')
echo " header: $header_name"
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
echo " $readme: $readme_name"
[ "$header_name" = "$readme_name" ] \
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
# Old names in text a user reads. The changelog is exempt: an entry describes
# what was true for that release, and rewriting it would be a different lie.
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
if [ -n "$stale" ]; then
printf '%s\n' "$stale" | sed 's/^/ /'
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
else
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
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 "the shared menu file is the family copy"
# includes/shared-menu.php is byte-identical in all six plugins. Editing it
# here would not make this plugin's version win — which copy a site loads is
# decided by activation order, and PHP cannot un-declare a function — it would
# make the menu differ between shops depending on which plugin booted first,
# and change its mind when one is deactivated. So the file is frozen, and
# taking a new version is something all six do in the same window.
shared_menu=includes/shared-menu.php
if [ ! -f "$shared_menu" ]; then
echo "::warning::$shared_menu is not present, so this plugin registers no shared menu."
else
have=$(sha256sum "$shared_menu" | cut -d" " -f1)
echo " sha256: $have"
[ "$have" = "$WPMGR_MENU_SHA" ] \
&& echo " matches the frozen family copy" \
|| note_fail "$shared_menu does not match the frozen family copy. Expected $WPMGR_MENU_SHA, got $have. Do not edit it — take the new version the others took."
fi
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 tracked file configures the clone it was handed"
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
# that sets an identity before committing therefore renames every later commit
# in that working copy, including a release made by a person days afterwards
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
# and says nothing about one that is wrong, so the missing case is loud and
# this one is silent: nothing fails, nothing warns, and the author is only
# visible to somebody reading `git log` for another reason.
#
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
# which still signs its own commit as the robot — correct, that one is not a
# person's — without touching the clone. Measured across the family: six
# misattributed hand commits in one sibling, two in another, including
# releases. This tree was clean and one run away.
#
# The pattern has to separate a WRITE from a read and from the repair, and the
# obvious widening gets all three wrong. Measured row by row:
#
# git config user.name "compatibility check" WRITE caught
# git config --local user.name "x" WRITE caught
# git config --global user.email "x@y" WRITE caught <- --local only: missed
# git config --system user.name "x" WRITE caught
# git config --local --unset user.name REPAIR ignored <- --[a-z-]+: flagged
# git config --unset user.email REPAIR ignored <- --[a-z-]+: flagged
# echo "$(git config user.name)" READ ignored <- --[a-z-]+: flagged
# git -c user.name="x" … commit -m y FIX ignored
# git config core.autocrlf false other ignored
# git config --global core.pager cat other ignored
# # git config user.name "x" (a comment) prose ignored
# (nothing planted) clean ignored
#
# ⚠️ Do NOT widen the scope group to `--[a-z-]+`. It reads `--unset` as a scope
# and then finds `user.` right after it, so the check goes red on the very
# repair this message recommends — and a build that fails on the remedy is how
# a correct check gets deleted: the failure accuses the check, and the next
# person believes it. Three sessions reached for `--[a-z-]+` independently on
# 2026-09-25, which is why it is written here rather than re-derived.
#
# Enumerating the three real scopes is one half. The other is requiring a VALUE
# after the key — `user\.[a-zA-Z]+[[:space:]]+[^[:space:])]` — which is what
# makes a read not a violation: `$(git config user.name)` has `)` where a write
# has a value. Found by nolimit-seo, whose first version failed on it.
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
#
# Writing the fault in prose inside a tracked file will trip this, by design.
# To name it in documentation without going red, write the key as user.<key> —
# the pattern needs [a-zA-Z] after the dot, so an angle-bracket placeholder is
# prose and a real key is a violation.
# Every tracked file, not just ci/*.sh. A workflow step, a hook, a Makefile or
# a docs snippet somebody pastes can write an identity just as permanently, and
# `ci/*.sh` was scope chosen from where the first instance happened to live —
# the same "what are your checks pointed at" question as the contrast check.
# Nolimit Security widened theirs from 13 files to 102 after the thing that
# actually ran on this box turned out to be outside that glob.
scan_tracked -E '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]' .
if [ "$scan_files" -lt 1 ]; then
note_fail "The git-identity scan read no tracked files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "A tracked file writes a git identity that outlives it. Scope it to the one command with 'git -c user.name=… commit', which does not write .git/config."
else
echo " $scan_files tracked files leave the clone's git identity alone"
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 documentation still describes this tree"
# A document nobody checks goes stale, and the expensive half is not "does the
# file exist" — it is whether docs/CONTRACT.md and the source still name the
# same hooks, in both directions. A hook we offer and never documented is a
# name a sibling plugin cannot discover without reading our source; a row whose
# hook we since renamed is a comment describing a tree that is not this one.
#
# Ported from woo-security-manager 52dd99d. Its floors and its hook prefix come
# from ci/plugin.conf and it refuses rather than defaulting — see the note in
# the file about why the prefix cannot be derived from TEXT_DOMAIN.
ci_php ci/check-docs.php . || fails=$((fails + 1))
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.
# 🔴 A value in two files drifts, and reading each one from its authoritative
# file is a correct answer to a different question. The two checks below ask
# "is this value current", one per value, each in the file that value belongs
# in — so a SECOND copy of the same value in the other file is never compared
# with anything. Measured 2026-09-28: the plugin header said WC 11.1.2 and
# README.txt said 11.1.0, four releases after the bump, with this step green
# and printing "plugin header says 11.1.2, current release is 11.1.2" — true,
# complete, and about the copy that was already right.
#
# So: any field carried by BOTH files must agree. Not "is it current" (that is
# advisory below, and rightly), but "do our own two copies say the same thing",
# which is a fact about this tree and therefore fatal.
for field in 'Tested up to' 'WC tested up to' 'Requires at least' 'Requires PHP'; do
h=$(ci_plugin_header "$field")
r=
[ -n "$readme" ] && r=$(sed -n -E "s/^$field:[[:space:]]*([^[:space:]]+).*/\1/p" "$readme" | head -1)
if [ -n "$h" ] && [ -n "$r" ]; then
if [ "$h" = "$r" ]; then
echo " both files say '$field: $h'"
else
note_fail "'$field' is '$h' in the plugin header and '$r' in $readme. One of them is stale and nothing else compares them."
fi
fi
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."