Widening the scope group to --[a-z-]+ was wrong in a way the previous
commit's matrix could not see, because it had no repair row: --unset is
matched as a scope flag, and user. follows it, so
git config --local --unset user.name
is flagged as a violation. That is the remedy this check recommends in its
own failure message. A build that fails on its own repair is how a correct
check gets deleted — the failure accuses the check, and the next person
believes it. The same widening flags a read, echo "$(git config user.name)".
Two halves, both needed. Enumerate the three real scopes, and require a
VALUE after the key: user\.[a-zA-Z]+[[:space:]]+[^[:space:])]. The trailing
class is what makes a read legal — $(git config user.name) has ) where a
write has a value. Sixteen rows measured one at a time, 16/16, and eleven of
them now run in ci/selftest.sh (44 assertions).
Reported by the Nolimit Security session; three of us reached for
--[a-z-]+ independently, so the comment above the step says explicitly not
to, with the reason, rather than leaving it to be re-derived. The read row
came from nolimit-seo.
Two things found while proving it, both about the probe rather than the
check:
ci/selftest.sh is itself a ci/*.sh file, so its fixtures trip the check they
test. Spelled out in full, the first version of this block reported six
false positives and no count, while the same rows measured standalone were
16/16 correct — a failing probe is evidence about the probe until you have
looked at it. Fixed the way the credential fixtures three hundred lines up
in the same file already were: assemble the value through $gc so the source
matches nothing while the written file matches fully. The labels need it
too, so they name the shape instead of quoting the line.
And a plant that lands in a SOURCED file is not inert text. nolimit-seo
planted into ci/lib.sh, which ci/static.sh sources, so the line ran and set
the clone's identity for real. The target here is ci/build-zip.sh and only
ci/lib.sh is sourced — verified rather than assumed — and a final assertion
checks the clone still has no local identity, because if a fixture had
executed then every verdict above it was measured against a tree nobody
intended.
static=0 selftest=0 site=0; site still reports 69 assertions on WordPress
7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
442 lines
23 KiB
Bash
Executable File
442 lines
23 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"
|
|
# 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"
|
|
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))
|
|
# `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 ci/ script 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.
|
|
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l)
|
|
if [ "$scripts_scanned" -lt 1 ]; then
|
|
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing."
|
|
elif git ls-files -z 'ci/*.sh' \
|
|
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]'; then
|
|
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config."
|
|
else
|
|
echo " $scripts_scanned scripts in ci/ 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 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.
|
|
|
|
|
|
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."
|