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
}
2026-09-28 21:14:10 +02:00
# 🔴 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 " $@ " )
}
2026-09-16 10:34:38 +02:00
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.
2026-09-20 09:03:04 +02:00
#
# 🔴 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 ))
2026-09-17 00:14:40 +02:00
fi
2026-09-16 10:34:38 +02:00
ci_step "no debug leftovers"
2026-09-28 21:14:10 +02:00
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 "
2026-09-16 10:34:38 +02:00
note_fail "Debug functions must not ship."
else
2026-09-28 21:14:10 +02:00
echo " none in $scan_files tracked PHP files"
2026-09-16 10:34:38 +02:00
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.
2026-09-28 21:14:10 +02:00
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 "
2026-09-16 10:34:38 +02:00
note_fail "Use <?php, not short open tags."
fi
2026-09-28 21:14:10 +02:00
open_tag_files = $scan_files
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-28 21:14:10 +02:00
[ " $fails " = " $before " ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
2026-09-16 10:34:38 +02:00
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 ))
2026-09-20 10:28:52 +02:00
# `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
2026-09-16 10:34:38 +02:00
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
2026-09-20 22:39:19 +02:00
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
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-20 09:31:37 +02:00
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
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-28 21:14:10 +02:00
ci_step "no tracked file configures the clone it was handed"
2026-09-25 12:17:48 +02:00
# `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.
#
2026-09-25 12:35:45 +02:00
# 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.
2026-09-25 12:17:48 +02:00
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
2026-09-28 21:14:10 +02:00
#
# 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."
2026-09-25 12:17:48 +02:00
else
2026-09-28 21:14:10 +02:00
echo " $scan_files tracked files leave the clone's git identity alone"
2026-09-25 12:17:48 +02:00
fi
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.
2026-09-20 22:39:19 +02:00
2026-09-16 10:34:38 +02:00
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."