Test against the newest WordPress and WooCommerce, and ship updates
Three things this repository could not do: prove the plugin still works on a
WordPress or WooCommerce released after it, get a fix onto a shop without
someone uploading a zip, and do either of those for the next plugin without
starting over.
ci/ is the test harness. ci/site-test.sh builds a throwaway WordPress +
WooCommerce in containers at any versions you name — including `nightly` and
`beta` — installs exactly the files the release zip will contain, and runs
tests/smoke.php inside it with wp eval-file. Real cart, real coupons, real
WC_Shipping_Rate objects: a mocked cart keeps passing after WooCommerce has
changed underneath it, which is the one failure worth catching here. WP_DEBUG
is on and any notice logged from this plugin's directory fails the build,
because that is the shape an upgrade actually breaks in — nothing fails, a
notice appears, and two releases later it is a fatal.
compat.yml runs that weekly against today's releases, the next WordPress and
the next WooCommerce, the oldest PHP the header promises and the newest PHP.
When it is green it opens a PR bumping "Tested up to" and "WC tested up to" —
only when green, because WooCommerce shows that second value to the shop owner
as an untested-plugin banner, and that banner is what they read before deciding
whether to press update.
includes/class-updater.php is the distribution half. It hooks
update_plugins_{hostname} for the host in the new "Update URI" header and reads
the Gitea release API, so WordPress handles the notice, the download, the
"Enable auto-updates" link and wp plugin update with no further code. Update
URI earns its place twice: since 5.8 a plugin that declares one is never asked
about on wordpress.org, so a .org plugin sharing this slug cannot be pushed
onto a shop running this one. The check sends nothing about the site and
overrides WordPress's default user agent, which carries the site's own URL.
release.yml tags, tests, builds, publishes — then reads the feed back with no
credentials. A private repository and a release with no zip attached both leave
a page that looks perfect to anyone logged in and an update no shop is ever
offered.
Measured before committing, all green: WP 7.1 + WC 11.1 on PHP 8.3 and 7.4,
WP 7.2-alpha on 8.3, and the beta cell falling back to stable on 8.4. No
deprecations from this plugin on any of them.
Three guards are there because the bug they catch is invisible without them:
the annotated tag is re-fetched before its message is read (checkout turns it
into a lightweight tag pointing at the merge commit), the two workflow roots
are kept in sync (.gitea/workflows hides .github/workflows entirely), and every
file the code loads must be in the release zip — which caught class-updater.php
being referenced and uncommitted while every local test passed.
docs/REUSE.md is the port to the next plugin: copy ci/, rewrite five lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e4a40e53a0
commit
eca2851d35
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the installable plugin zip and check it is actually installable.
|
||||
#
|
||||
# Writes build/<slug>.zip, containing a single top-level <slug>/ directory —
|
||||
# WordPress identifies an installed plugin by that folder name, so a zip that
|
||||
# unpacks to anything else installs a second copy alongside the first instead
|
||||
# of updating it.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
version=$(ci_plugin_version)
|
||||
[ -n "$version" ] || { echo "Could not read Version: from $PLUGIN_MAIN." >&2; exit 1; }
|
||||
|
||||
rm -rf build
|
||||
mkdir -p "build/$PLUGIN_SLUG"
|
||||
|
||||
# One list, shared with the hygiene checks and the test site, so a file cannot
|
||||
# be excluded from the tests and included in the release.
|
||||
./ci/dist-files.sh | while IFS= read -r f; do
|
||||
mkdir -p "build/$PLUGIN_SLUG/$(dirname "$f")"
|
||||
cp "$f" "build/$PLUGIN_SLUG/$f"
|
||||
done
|
||||
|
||||
# `zip` is on GitHub's runners but not in every Gitea runner image, and a
|
||||
# release that fails on the packaging step is the most annoying possible place
|
||||
# to discover a missing tool. python3 is present in both.
|
||||
(
|
||||
cd build
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -rq "$PLUGIN_SLUG.zip" "$PLUGIN_SLUG"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 -m zipfile -c "$PLUGIN_SLUG.zip" "$PLUGIN_SLUG"
|
||||
else
|
||||
echo "Neither zip nor python3 is available to build the archive." >&2
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
|
||||
# Listing entries by name rather than parsing `unzip -l` output, with the same
|
||||
# python3 fallback as above because unzip is not guaranteed on a runner either.
|
||||
zip_entries() {
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -Z1 "build/$PLUGIN_SLUG.zip"
|
||||
else
|
||||
python3 -c 'import sys,zipfile;print("\n".join(zipfile.ZipFile(sys.argv[1]).namelist()))' \
|
||||
"build/$PLUGIN_SLUG.zip"
|
||||
fi
|
||||
}
|
||||
|
||||
ci_step "what the zip contains"
|
||||
zip_entries | sed 's/^/ /'
|
||||
|
||||
ci_step "checks"
|
||||
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|
||||
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
|
||||
echo " the main file is present"
|
||||
|
||||
# A leaked ci/ or tests/ directory is not merely untidy: everything under
|
||||
# wp-content/plugins/ can in principle be fetched over HTTP from a live shop.
|
||||
for unwanted in ci tests docs .git .github .gitea; do
|
||||
if zip_entries | grep -q "^$PLUGIN_SLUG/$unwanted/"; then
|
||||
echo "The zip contains $unwanted/, which must never reach a shop." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " no tooling, tests or git files leaked in"
|
||||
|
||||
# Every entry sits under exactly one top-level directory named for the slug.
|
||||
strays=$(zip_entries | grep -v "^$PLUGIN_SLUG/" || true)
|
||||
[ -z "$strays" ] || { echo "Files outside $PLUGIN_SLUG/ in the zip:" >&2; echo "$strays" >&2; exit 1; }
|
||||
echo " everything unpacks into $PLUGIN_SLUG/"
|
||||
|
||||
size=$(du -h "build/$PLUGIN_SLUG.zip" | cut -f1)
|
||||
echo
|
||||
echo "build/$PLUGIN_SLUG.zip — version $version, $size"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update "Tested up to" and "WC tested up to" to the versions that were just
|
||||
# tested, and open a pull request if anything changed.
|
||||
#
|
||||
# Run only after the compatibility matrix has gone green against the current
|
||||
# releases. The headers are a claim made to whoever installs the plugin — and
|
||||
# in WooCommerce's case a banner the shop owner reads before deciding whether
|
||||
# to update at all — so bumping them on a red build would be publishing a claim
|
||||
# we have just watched fail.
|
||||
#
|
||||
# Writes nothing when the headers are already current, so a quiet week costs a
|
||||
# green run and no noise.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
current_wp=$(curl -fsS --max-time 20 'https://api.wordpress.org/core/stable-check/1.0/' \
|
||||
| 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' \
|
||||
| sed -n -E 's/.*"version":"([^"]+)".*/\1/p' | head -1)
|
||||
|
||||
[ -n "$current_wp" ] || { echo "Could not read the current WordPress version." >&2; exit 1; }
|
||||
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
changed=()
|
||||
|
||||
if [ -n "$readme" ]; then
|
||||
tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
if [ "$tested_wp" != "$current_wp" ]; then
|
||||
sed -i -E "s/^Tested up to:.*/Tested up to: $current_wp/" "$readme"
|
||||
changed+=("WordPress ${tested_wp:-?} → $current_wp")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_WOOCOMMERCE" = yes ] && [ -n "$current_wc" ]; then
|
||||
tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
if [ "$tested_wc" != "$current_wc" ]; then
|
||||
sed -i -E "s/^([[:space:]]*\*[[:space:]]*WC tested up to:).*/\1 $current_wc/" "$PLUGIN_MAIN"
|
||||
[ -n "$readme" ] && sed -i -E "s/^WC tested up to:.*/WC tested up to: $current_wc/" "$readme"
|
||||
changed+=("WooCommerce ${tested_wc:-?} → $current_wc")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ${#changed[@]} -eq 0 ]; then
|
||||
echo "The compatibility headers are already current — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-read from disk rather than trusting the sed: a pattern that matched
|
||||
# nothing would otherwise produce an empty, cheerful pull request.
|
||||
for f in "$PLUGIN_MAIN" ${readme:+"$readme"}; do
|
||||
git diff --quiet -- "$f" && continue
|
||||
echo "--- $f"
|
||||
git diff -- "$f" | sed -n '/^[+-][^+-]/p'
|
||||
done
|
||||
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
|
||||
|
||||
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
|
||||
git config user.name "compatibility check"
|
||||
git config user.email "noreply@localhost"
|
||||
git checkout -b "$branch"
|
||||
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
|
||||
$(printf '%s\n' "${changed[@]}")
|
||||
|
||||
The compatibility matrix went green against these versions before this
|
||||
was written, so the headers now say what was actually tested. WooCommerce
|
||||
shows the 'WC tested up to' value to the shop owner as an untested-plugin
|
||||
banner, which is what they read before deciding whether to press update."
|
||||
|
||||
git push -u origin "$branch"
|
||||
|
||||
gh pr create \
|
||||
--title "Tested against WordPress $current_wp and WooCommerce $current_wc" \
|
||||
--body "$(printf 'The weekly compatibility run passed against the current releases, so the headers are updated to match:\n\n%s\n\nOpened automatically by `ci/bump-tested-up-to.sh`. The versions here were tested before this PR existed; if the matrix had failed, the job that opens this would not have run.\n' "$(printf -- '- %s\n' "${changed[@]}")")"
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Verify that every translation call carries the plugin's text domain.
|
||||
*
|
||||
* A mistyped or missing domain does not break anything visibly — the string
|
||||
* simply never gets translated, and nobody notices until the shop is asked to
|
||||
* run in another language.
|
||||
*
|
||||
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
||||
* cannot pass for the real thing.
|
||||
*/
|
||||
|
||||
// Copied between plugins, so there is no sensible default domain: a wrong one
|
||||
// would report "all good" while checking for somebody else's text domain.
|
||||
if ( empty( $argv[1] ) ) {
|
||||
fwrite( STDERR, "Usage: php ci/check-textdomain.php <text-domain> [root]\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$domain = $argv[1];
|
||||
$root = $argv[2] ?? '.';
|
||||
$functions = array(
|
||||
'__',
|
||||
'_e',
|
||||
'_x',
|
||||
'_ex',
|
||||
'_n',
|
||||
'_nx',
|
||||
'_n_noop',
|
||||
'_nx_noop',
|
||||
'esc_html__',
|
||||
'esc_html_e',
|
||||
'esc_html_x',
|
||||
'esc_attr__',
|
||||
'esc_attr_e',
|
||||
'esc_attr_x',
|
||||
);
|
||||
|
||||
$problems = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveCallbackFilterIterator(
|
||||
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ),
|
||||
static function ( $file ) {
|
||||
$name = $file->getFilename();
|
||||
return ! in_array( $name, array( '.git', 'node_modules', 'vendor', 'build' ), true );
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$tokens = token_get_all( file_get_contents( $path ) );
|
||||
$count = count( $tokens );
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$token = $tokens[ $i ];
|
||||
|
||||
if ( ! is_array( $token ) || T_STRING !== $token[0] || ! in_array( $token[1], $functions, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip method calls and definitions: ->__(), function __().
|
||||
$prev = $i - 1;
|
||||
while ( $prev >= 0 && is_array( $tokens[ $prev ] ) && T_WHITESPACE === $tokens[ $prev ][0] ) {
|
||||
--$prev;
|
||||
}
|
||||
if ( $prev >= 0 && is_array( $tokens[ $prev ] ) && in_array( $tokens[ $prev ][0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the opening parenthesis.
|
||||
$j = $i + 1;
|
||||
while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) {
|
||||
++$j;
|
||||
}
|
||||
if ( $j >= $count || '(' !== $tokens[ $j ] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect every literal string passed at the top level of this call.
|
||||
$depth = 0;
|
||||
$strings = array();
|
||||
|
||||
for ( $k = $j; $k < $count; $k++ ) {
|
||||
$t = $tokens[ $k ];
|
||||
|
||||
if ( '(' === $t ) {
|
||||
++$depth;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ')' === $t ) {
|
||||
--$depth;
|
||||
if ( 0 === $depth ) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( is_array( $t ) && T_CONSTANT_ENCAPSED_STRING === $t[0] ) {
|
||||
$strings[] = trim( $t[1], "'\"" );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! in_array( $domain, $strings, true ) ) {
|
||||
printf(
|
||||
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'.\n",
|
||||
ltrim( str_replace( $root, '', $path ), '/' ),
|
||||
$token[2],
|
||||
$token[1],
|
||||
$domain
|
||||
);
|
||||
++$problems;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $problems > 0 ) {
|
||||
printf( "\n%d translation calls are missing the text domain.\n", $problems );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
echo "Every translation call carries the text domain '{$domain}'.\n";
|
||||
exit( 0 );
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# List the files that go into the installed plugin: everything git tracks,
|
||||
# minus the patterns in .distignore.
|
||||
#
|
||||
# Deliberately the only answer to "what ships". The release zip and the
|
||||
# hygiene checks both read it, so a file cannot be excluded from one and
|
||||
# included in the other — which is the version of this bug that ends with
|
||||
# internal notes readable over HTTP on a live shop.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[ -f .distignore ] || { echo ".distignore is missing — the zip would contain git files and internal notes." >&2; exit 1; }
|
||||
|
||||
patterns=()
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
|
||||
[ -n "$line" ] && patterns+=("${line%/}")
|
||||
done < .distignore
|
||||
|
||||
git ls-files | while IFS= read -r file; do
|
||||
keep=1
|
||||
for p in "${patterns[@]}"; do
|
||||
# A bare name excludes the file or directory wherever it appears, which is
|
||||
# how .distignore is read everywhere else. Anchor with a leading / to mean
|
||||
# the repo root only.
|
||||
case "$p" in
|
||||
/*) case "$file" in "${p#/}"|"${p#/}"/*) keep=0 ;; esac ;;
|
||||
*) case "$file" in $p|$p/*|*/$p|*/$p/*) keep=0 ;; esac ;;
|
||||
esac
|
||||
[ "$keep" = 0 ] && break
|
||||
done
|
||||
[ "$keep" = 1 ] && printf '%s\n' "$file"
|
||||
done
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Print the newest published beta/RC of a wordpress.org plugin, or nothing.
|
||||
*
|
||||
* Usage: SLUG=woocommerce php ci/latest-prerelease.php
|
||||
*
|
||||
* The .org API lists every published version, prereleases included. The newest
|
||||
* one that is not a plain stable release is what a shop on the beta channel
|
||||
* would get, which makes it the earliest available warning that the next
|
||||
* release breaks us. A prerelease *older* than current stable is a leftover
|
||||
* from a finished cycle and is ignored — testing it would answer a question
|
||||
* nobody asked.
|
||||
*
|
||||
* @package ci
|
||||
*/
|
||||
|
||||
$slug = getenv( 'SLUG' );
|
||||
if ( ! $slug ) {
|
||||
fwrite( STDERR, "SLUG is not set.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$url = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information'
|
||||
. '&request[slug]=' . rawurlencode( $slug )
|
||||
. '&request[fields][versions]=1';
|
||||
|
||||
$body = @file_get_contents( $url ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
if ( false === $body ) {
|
||||
// A network blip must not fail the build — the caller falls back to the
|
||||
// stable release, which is still a useful test.
|
||||
fwrite( STDERR, "Could not reach the wordpress.org plugin API.\n" );
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
$info = json_decode( $body, true );
|
||||
$stable = isset( $info['version'] ) ? $info['version'] : '0';
|
||||
|
||||
$pre = array_filter(
|
||||
array_keys( isset( $info['versions'] ) ? $info['versions'] : array() ),
|
||||
static function ( $version ) {
|
||||
return (bool) preg_match( '/-(beta|rc)/i', $version );
|
||||
}
|
||||
);
|
||||
|
||||
usort( $pre, 'version_compare' );
|
||||
$newest = end( $pre );
|
||||
|
||||
if ( $newest && version_compare( $newest, $stable, '>' ) ) {
|
||||
echo $newest;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Shared helpers for the ci/ scripts. Sourced, never executed.
|
||||
#
|
||||
# Nothing in here is specific to one plugin — ci/plugin.conf holds that.
|
||||
|
||||
# shellcheck shell=bash
|
||||
|
||||
ci_repo_root() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
|
||||
}
|
||||
|
||||
# Load ci/plugin.conf and fail loudly on anything it forgot to set. A missing
|
||||
# value would otherwise turn into an empty path and a test that passes by
|
||||
# testing nothing.
|
||||
ci_load_conf() {
|
||||
local root
|
||||
root="$(ci_repo_root)"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$root/ci/plugin.conf"
|
||||
|
||||
local missing=
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE; do
|
||||
[ -n "${!var:-}" ] || missing="$missing $var"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "ci/plugin.conf is missing:$missing" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$root/$PLUGIN_MAIN" ]; then
|
||||
echo "ci/plugin.conf points PLUGIN_MAIN at '$PLUGIN_MAIN', which does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The version WordPress will compare against the update feed. Read from the
|
||||
# plugin header, which is the only copy WordPress ever looks at.
|
||||
ci_plugin_version() {
|
||||
sed -n -E 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' \
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1
|
||||
}
|
||||
|
||||
# Read any other plugin header field, e.g. ci_plugin_header 'Requires at least'.
|
||||
ci_plugin_header() {
|
||||
sed -n -E "s/^[[:space:]]*\*?[[:space:]]*$1:[[:space:]]*(.*)/\1/p" \
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
||||
}
|
||||
|
||||
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# GitHub and Gitea both render these as annotations on the file and line.
|
||||
ci_annotate() { # level file line message
|
||||
printf '::%s file=%s,line=%s::%s\n' "$1" "$2" "$3" "$4"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# The only file under ci/ that knows anything about this particular plugin.
|
||||
#
|
||||
# Everything else in ci/ is plugin-agnostic on purpose: copying ci/, tests/ and
|
||||
# the two workflow roots into another plugin and rewriting these six lines is
|
||||
# the whole port. docs/REUSE.md walks through it.
|
||||
|
||||
# Directory name the plugin gets inside wp-content/plugins, and the name of the
|
||||
# built zip. WordPress identifies a plugin by "<slug>/<main file>", so changing
|
||||
# this after release orphans every installed copy.
|
||||
PLUGIN_SLUG=woocommerce-email-discount-manager
|
||||
|
||||
# The file carrying the plugin header. Version: in here is the version.
|
||||
PLUGIN_MAIN=woocommerce-email-discount-manager.php
|
||||
|
||||
# Every __() and esc_html__() call must use this domain or the translation is
|
||||
# silently dropped at runtime.
|
||||
TEXT_DOMAIN=wc-email-discount
|
||||
|
||||
# WooCommerce is installed into the test site before the plugin is activated.
|
||||
# Set to no for a plugin that does not need it, and the site comes up faster.
|
||||
NEEDS_WOOCOMMERCE=yes
|
||||
|
||||
# Run inside the live test site with `wp eval-file`, in this order. Fixtures
|
||||
# create the data; the smoke files assert against it. A non-zero exit or an
|
||||
# uncaught error fails the build.
|
||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish a release with the plugin zip attached, on whichever forge is running
|
||||
# this, and then verify the result the way a shop will actually see it.
|
||||
#
|
||||
# Needs: TAG, GITHUB_REPOSITORY, GITHUB_API_URL, GITHUB_SERVER_URL, GH_TOKEN.
|
||||
#
|
||||
# The verification at the end is the part worth keeping. The update feed is
|
||||
# read anonymously by every shop, and the two ways it breaks — a private
|
||||
# repository and a release with no zip attached — both leave a release page
|
||||
# that looks perfectly fine to whoever is logged in. Nobody would notice for
|
||||
# months; what they would notice is that updates stopped arriving.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
: "${TAG:?TAG is not set}"
|
||||
: "${GH_TOKEN:?GH_TOKEN is not set}"
|
||||
: "${GITHUB_REPOSITORY:?}"
|
||||
: "${GITHUB_API_URL:?}"
|
||||
|
||||
zip="build/$PLUGIN_SLUG.zip"
|
||||
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
||||
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repo="${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
# The tag message is the announcement, so it has to survive the checkout.
|
||||
#
|
||||
# actions/checkout fetches every tag correctly and then overwrites this one ref
|
||||
# with the commit SHA — `fetch-depth: 0` does not save you. The annotated tag
|
||||
# becomes a lightweight one pointing at the commit, and reading its message
|
||||
# returns the COMMIT message instead: on a merge commit, "Merge pull request
|
||||
# #12 from ...". Nothing fails. The release is simply published with the wrong
|
||||
# text, and only the page shows it.
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
|
||||
[ "$(git cat-file -t "$TAG")" = tag ] || {
|
||||
echo "$TAG is not an annotated tag. Its message is what gets published, so create it with 'git tag -a'." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
notes=$(git tag -l --format='%(contents)' "$TAG")
|
||||
first_line=$(printf '%s\n' "$notes" | head -1)
|
||||
|
||||
case "$first_line" in
|
||||
'Merge pull request'*|'Merge branch'*|'')
|
||||
echo "The release notes read '$first_line'." >&2
|
||||
echo "That is a commit message, not the tag message — the annotated tag did not survive the checkout." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci_step "release notes"
|
||||
printf '%s\n' "$notes" | sed 's/^/ /'
|
||||
|
||||
# The updater reads this line out of the release body, so wp-admin can show
|
||||
# what this version was tested against rather than what the last one claimed.
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
tested_wp=$([ -n "$readme" ] && sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
|
||||
body=$(printf '%s\n\n---\n\nTested up to: %s\nWC tested up to: %s\n' \
|
||||
"$notes" "${tested_wp:-unknown}" "${tested_wc:-unknown}")
|
||||
|
||||
api() { # method path [data]
|
||||
curl -fsS -X "$1" \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
${3:+-d "$3"} \
|
||||
"$2"
|
||||
}
|
||||
|
||||
if [ "${GITHUB_SERVER_URL:-}" = 'https://github.com' ]; then
|
||||
ci_step "publishing to GitHub"
|
||||
gh release create "$TAG" "$zip" --title "$TAG" --notes "$body" 2>/dev/null \
|
||||
|| gh release upload "$TAG" "$zip" --clobber
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG"
|
||||
else
|
||||
ci_step "publishing to $GITHUB_SERVER_URL"
|
||||
payload=$(printf '%s' "$body" | python3 -c '
|
||||
import json, sys, os
|
||||
print(json.dumps({
|
||||
"tag_name": os.environ["TAG"],
|
||||
"name": os.environ["TAG"],
|
||||
"body": sys.stdin.read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))')
|
||||
|
||||
# A re-run of a release must not fail on "already exists".
|
||||
existing=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG" 2>/dev/null || true)
|
||||
id=$(printf '%s' "$existing" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
created=$(api POST "$GITHUB_API_URL/repos/$owner/$repo/releases" "$payload")
|
||||
id=$(printf '%s' "$created" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "Could not create or find the release for $TAG." >&2; exit 1; }
|
||||
echo " release id $id"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/latest"
|
||||
fi
|
||||
|
||||
ci_step "the feed, read the way a shop reads it"
|
||||
# Deliberately no token on any request below. A "check" that sends credentials
|
||||
# the shops do not have is the false positive that hides exactly this bug.
|
||||
feed=$(curl -fsS --max-time 30 "$release_api" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$feed" ]; then
|
||||
echo
|
||||
ci_fail "$release_api cannot be read without credentials, so no shop will ever be offered this update."
|
||||
echo "Either the repository is still private, or the release was not created."
|
||||
echo "For Gitea: Settings → make the repository public. The update feed is a public read by design;"
|
||||
echo "that is the whole mechanism, and there is nothing secret in a GPL plugin zip anyway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package=$(printf '%s' "$feed" | tr ',' '\n' | sed -n -E 's/.*"browser_download_url":[[:space:]]*"([^"]*\.zip)".*/\1/p' | head -1)
|
||||
if [ -z "$package" ]; then
|
||||
echo
|
||||
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 60 "$package")
|
||||
echo " $package -> HTTP $code"
|
||||
[ "$code" = 200 ] || { ci_fail "The package URL is not anonymously downloadable."; exit 1; }
|
||||
|
||||
echo
|
||||
echo "Published $TAG. A shop running this plugin will be offered it within twelve hours,"
|
||||
echo "or immediately on Dashboard → Updates → Check again."
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stand up a throwaway WordPress + WooCommerce, install this plugin into it,
|
||||
# and run the site tests from ci/plugin.conf inside it.
|
||||
#
|
||||
# WP_VERSION=latest|nightly|6.8 WC_VERSION=latest|beta|11.1.0 PHP_VERSION=8.3
|
||||
#
|
||||
# The point of the version knobs is the question this whole directory exists to
|
||||
# answer: *will the shop still work after I press update?* Running the same
|
||||
# tests against `latest` and against `nightly`/`beta` answers it before the
|
||||
# update happens rather than after.
|
||||
#
|
||||
# Written for a Gitea Actions job, which runs INSIDE a container that talks to
|
||||
# the HOST's docker daemon. A bind mount of $PWD there mounts an empty
|
||||
# directory nobody created, and the symptom is "Could not open input file",
|
||||
# which reads like a typo in the path. So the plugin goes in over `docker cp`
|
||||
# and all state lives in named volumes. That also makes the script behave
|
||||
# identically on GitHub's runners and on a laptop.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
WP_VERSION="${WP_VERSION:-latest}"
|
||||
WC_VERSION="${WC_VERSION:-latest}"
|
||||
PHP_VERSION="${PHP_VERSION:-8.3}"
|
||||
|
||||
id="wpci-$$"
|
||||
net="$id-net"
|
||||
cleanup() {
|
||||
docker rm -f "$id-db" "$id-cli" >/dev/null 2>&1 || true
|
||||
docker volume rm -f "$id-wp" >/dev/null 2>&1 || true
|
||||
docker network rm "$net" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "WordPress=$WP_VERSION WooCommerce=$WC_VERSION PHP=$PHP_VERSION plugin=$PLUGIN_SLUG"
|
||||
|
||||
ci_step "database"
|
||||
docker network create "$net" >/dev/null
|
||||
docker volume create "$id-wp" >/dev/null
|
||||
docker run -d --name "$id-db" --network "$net" \
|
||||
-e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=wordpress \
|
||||
-e MARIADB_USER=wordpress -e MARIADB_PASSWORD=wordpress mariadb:lts >/dev/null
|
||||
|
||||
# wordpress:cli runs as www-data (33); a fresh named volume is root-owned, so
|
||||
# ownership is fixed from a root container before anything tries to write.
|
||||
docker run --rm -v "$id-wp:/var/www/html" alpine chown 33:33 /var/www/html
|
||||
docker run -d --name "$id-cli" --network "$net" -v "$id-wp:/var/www/html" \
|
||||
-e WORDPRESS_DB_HOST="$id-db" "wordpress:cli-php$PHP_VERSION" sleep infinity >/dev/null
|
||||
|
||||
# The image ships no php.ini, so PHP's 128M default applies and `wp core
|
||||
# download` dies unpacking the zip. WP_CLI_PHP_ARGS does not help here: the
|
||||
# phar is executed directly, not through the wp launcher.
|
||||
docker exec -u root "$id-cli" sh -c 'echo memory_limit=512M > /usr/local/etc/php/conf.d/zz-memory.ini'
|
||||
|
||||
wp() { docker exec "$id-cli" wp "$@"; }
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
docker exec "$id-db" mariadb -uwordpress -pwordpress -e 'select 1' wordpress >/dev/null 2>&1 && break
|
||||
if [ "$i" = 60 ]; then echo "MariaDB never came up." >&2; exit 1; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
ci_step "WordPress $WP_VERSION"
|
||||
if [ "$WP_VERSION" = latest ]; then
|
||||
wp core download --quiet
|
||||
else
|
||||
wp core download --version="$WP_VERSION" --quiet
|
||||
fi
|
||||
wp config create --dbname=wordpress --dbuser=wordpress --dbpass=wordpress \
|
||||
--dbhost="$id-db" --quiet
|
||||
# Deprecations are the way a WordPress or PHP upgrade actually breaks a plugin:
|
||||
# nothing fails, a notice appears, and six months later it is a fatal. They are
|
||||
# logged to a file (not the page) and checked at the end of this script.
|
||||
wp config set WP_DEBUG true --raw --quiet
|
||||
wp config set WP_DEBUG_LOG true --raw --quiet
|
||||
wp config set WP_DEBUG_DISPLAY false --raw --quiet
|
||||
wp core install --url=http://plugin.test --title="$PLUGIN_SLUG CI" \
|
||||
--admin_user=admin --admin_password=admin --admin_email=ci@example.com \
|
||||
--skip-email --quiet
|
||||
echo " installed $(wp core version)"
|
||||
|
||||
if [ "$NEEDS_WOOCOMMERCE" = yes ]; then
|
||||
ci_step "WooCommerce $WC_VERSION"
|
||||
case "$WC_VERSION" in
|
||||
latest)
|
||||
wp plugin install woocommerce --activate --quiet
|
||||
;;
|
||||
beta)
|
||||
# Resolved inside the container, which has PHP and network; the host
|
||||
# runner is not guaranteed either.
|
||||
target=$(docker exec -i -e SLUG=woocommerce "$id-cli" php < ci/latest-prerelease.php)
|
||||
if [ -n "$target" ]; then
|
||||
echo " prerelease available: $target"
|
||||
wp plugin install woocommerce --version="$target" --activate --quiet
|
||||
else
|
||||
echo " no prerelease newer than stable right now — using the stable release"
|
||||
wp plugin install woocommerce --activate --quiet
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
wp plugin install woocommerce --version="$WC_VERSION" --activate --quiet
|
||||
;;
|
||||
esac
|
||||
# HPOS, because that is what a shop set up in the last few years runs, and a
|
||||
# plugin that touches orders behaves differently under it.
|
||||
wp option update woocommerce_custom_orders_table_enabled yes >/dev/null
|
||||
echo " installed $(wp plugin get woocommerce --field=version)"
|
||||
fi
|
||||
|
||||
ci_step "$PLUGIN_SLUG"
|
||||
# Exactly the files the release zip will contain, plus the site tests. Testing
|
||||
# the working tree instead would test a plugin no shop ever receives: one
|
||||
# unanchored .gitignore line is enough for a file to be present locally,
|
||||
# absent from the release, and never missed until a customer installs it.
|
||||
{ ./ci/dist-files.sh; printf '%s\n' ${SITE_TESTS:-}; } | tr '\n' '\0' | tar --null -T - -cf - \
|
||||
| docker exec -i "$id-cli" sh -c \
|
||||
"mkdir -p /var/www/html/wp-content/plugins/$PLUGIN_SLUG \
|
||||
&& tar -xf - -C /var/www/html/wp-content/plugins/$PLUGIN_SLUG"
|
||||
wp plugin activate "$PLUGIN_SLUG" --quiet
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|
||||
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
|
||||
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
|
||||
|
||||
for f in ${SITE_TESTS:-}; do
|
||||
ci_step "$f"
|
||||
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
|
||||
done
|
||||
|
||||
ci_step "PHP notices and deprecations"
|
||||
# Only lines naming this plugin. WordPress core and WooCommerce deprecate their
|
||||
# own things on every release, and failing on those would make the build red
|
||||
# for something nobody here can fix — which is how a red build gets ignored.
|
||||
log=$(wp eval 'echo WP_CONTENT_DIR;')/debug.log
|
||||
if docker exec "$id-cli" test -f "$log"; then
|
||||
if docker exec "$id-cli" grep -F "/plugins/$PLUGIN_SLUG/" "$log"; then
|
||||
echo
|
||||
ci_fail "The plugin logged the notices above on WordPress $(wp core version). They are the shape an update breaks in: harmless now, fatal two releases later."
|
||||
exit 1
|
||||
fi
|
||||
echo " none from this plugin"
|
||||
else
|
||||
echo " nothing logged at all"
|
||||
fi
|
||||
|
||||
ci_step "done"
|
||||
echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that need no WordPress: syntax, plugin hygiene, version consistency,
|
||||
# and whether the plugin header still tells the truth about what it supports.
|
||||
#
|
||||
# Fast enough to run before every commit. `ci/site-test.sh` is the slow half.
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
|
||||
fails=0
|
||||
note_fail() { ci_fail "$1"; fails=$((fails + 1)); }
|
||||
|
||||
# Only files that actually reach a server. A tool under ci/ has no business
|
||||
# carrying an ABSPATH guard, and a test file is not shipped at all. Same list
|
||||
# the release zip is built from, so the two cannot drift.
|
||||
shipped_files() {
|
||||
./ci/dist-files.sh | grep '\.php$' || true
|
||||
}
|
||||
|
||||
ci_step "PHP syntax"
|
||||
# The header promises a minimum PHP version; without it in the loop a piece of
|
||||
# newer syntax passes here and fatals on the customer's server instead.
|
||||
min_php=$(ci_plugin_header 'Requires PHP')
|
||||
tarball=$(mktemp -t wpci-lint-XXXXXX.tar)
|
||||
git ls-files -z '*.php' | tar --null -T - -cf "$tarball"
|
||||
for v in "${min_php:-7.4}" 8.4; do
|
||||
echo " php $v"
|
||||
# docker cp of a tarball, not -v "$PWD":/src — a Gitea Actions job runs
|
||||
# inside a container talking to the HOST's docker daemon, so a host path
|
||||
# here mounts an empty directory nobody created and the lint passes by
|
||||
# linting nothing. Copying a directory needs its parent to already exist in
|
||||
# the container, which /src does not; a single file into /tmp always works.
|
||||
cid=$(docker create "php:$v-cli" sh -c '
|
||||
set -e
|
||||
mkdir -p /src && tar -xf /tmp/src.tar -C /src
|
||||
n=0
|
||||
for f in $(find /src -name "*.php"); do php -l "$f" >/dev/null; n=$((n + 1)); done
|
||||
echo " $n files parse"
|
||||
# Guards against the failure this step had on its first run: an empty /src
|
||||
# makes the loop a no-op and the whole check reports success.
|
||||
[ "$n" -gt 0 ] || { echo " no PHP files arrived in the container" >&2; exit 1; }')
|
||||
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
|
||||
code=0
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
[ "$code" = 0 ] || note_fail "PHP $v cannot parse this plugin."
|
||||
done
|
||||
rm -f "$tarball"
|
||||
|
||||
ci_step "protection against direct access"
|
||||
before=$fails
|
||||
# Without the guard a PHP file can be requested straight from the browser,
|
||||
# outside WordPress and therefore outside every capability check the code
|
||||
# assumes has already happened.
|
||||
while read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
grep -q "defined( 'ABSPATH' )\|defined( 'WP_UNINSTALL_PLUGIN' )" "$f" \
|
||||
|| { ci_annotate error "$f" 1 "Missing the guard against direct access: if ( ! defined( 'ABSPATH' ) ) { exit; }"; fails=$((fails + 1)); }
|
||||
done <<< "$(shipped_files)"
|
||||
[ "$fails" = "$before" ] && echo " every shipped file is guarded"
|
||||
|
||||
ci_step "no debug leftovers"
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
|
||||
note_fail "Debug functions must not ship."
|
||||
else
|
||||
echo " none"
|
||||
fi
|
||||
|
||||
ci_step "no short open tags, no closing ?>"
|
||||
before=$fails
|
||||
# With short_open_tag off the code is printed to the visitor as text instead of
|
||||
# being run; whitespace after a closing tag is sent to the browser and breaks
|
||||
# headers and redirects.
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
|
||||
note_fail "Use <?php, not short open tags."
|
||||
fi
|
||||
while read -r 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 '*.php')"
|
||||
[ "$fails" = "$before" ] && echo " clean"
|
||||
|
||||
ci_step "text domain"
|
||||
php ci/check-textdomain.php "$TEXT_DOMAIN" . || fails=$((fails + 1))
|
||||
|
||||
ci_step "every file the plugin loads is a file the release contains"
|
||||
# The bug this exists for: a new class file is written, required from the main
|
||||
# file, works perfectly in every local test because it is sitting right there
|
||||
# in the working tree — and is not committed, so the release zip does not have
|
||||
# it. This plugin degrades instead of fataling on a missing include, which is
|
||||
# good for a half-finished upgrade and terrible here: the shop would just
|
||||
# quietly lose a feature.
|
||||
shipped=$(./ci/dist-files.sh)
|
||||
referenced=$(printf '%s\n' "$shipped" | grep '\.php$' | xargs grep -ohE "'[A-Za-z0-9_./-]+\.php'" 2>/dev/null | tr -d "'" | sort -u)
|
||||
checked=0
|
||||
for path in $referenced; do
|
||||
# A plugin-relative include always names a directory of this plugin. That
|
||||
# rules out the look-alikes: "admin-ajax.php" and "admin.php" are admin URLs
|
||||
# and menu slugs, "woocommerce/woocommerce.php" is another plugin's basename,
|
||||
# and "wp-admin/includes/upgrade.php" is resolved against ABSPATH.
|
||||
case "$path" in */*) ;; *) continue ;; esac
|
||||
[ -d "${path%%/*}" ] || continue
|
||||
checked=$((checked + 1))
|
||||
printf '%s\n' "$shipped" | grep -qx "$path" \
|
||||
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
|
||||
done
|
||||
echo " $checked plugin-relative include(s) checked"
|
||||
|
||||
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 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."
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the plugin's version, as WordPress reads it: from the plugin header.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
ci_load_conf
|
||||
ci_plugin_version
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Guard the trap in .gitea/workflows.
|
||||
#
|
||||
# Gitea uses .gitea/workflows IF IT EXISTS, and falls back to .github/workflows
|
||||
# only when it does not. It never merges them. So a workflow that lives only in
|
||||
# .github/ simply does not run on Gitea — no error, no run to click on. It
|
||||
# looks exactly like a runner that has not picked the job up, so you wait, and
|
||||
# waiting never fixes it.
|
||||
#
|
||||
# Workflows that must behave identically on both forges are therefore kept
|
||||
# byte-identical in both roots, and this script fails when they drift or when
|
||||
# one appears in only one root without a stated reason.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Workflows that are deliberately single-forge, and why.
|
||||
declare -A only_on=(
|
||||
["auto-merge.yml"]="github: it drives the GitHub merge API. Gitea merges the owner's green PRs itself."
|
||||
["release.yml"]="both, but different: each publishes to the forge it runs on, and Gitea is where the update feed lives."
|
||||
)
|
||||
|
||||
fail=0
|
||||
|
||||
for root in .github .gitea; do
|
||||
[ -d "$root/workflows" ] || { echo "$root/workflows is missing."; fail=1; }
|
||||
done
|
||||
|
||||
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
|
||||
[ -e "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
gh=".github/workflows/$name"
|
||||
gt=".gitea/workflows/$name"
|
||||
|
||||
case "${only_on[$name]:-}" in
|
||||
github:*)
|
||||
[ -f "$gt" ] && { echo "$name is marked GitHub-only but exists in .gitea/workflows too."; fail=1; }
|
||||
continue
|
||||
;;
|
||||
both,*|"both, "*)
|
||||
# Present in both, intentionally different. Both copies must exist.
|
||||
{ [ -f "$gh" ] && [ -f "$gt" ]; } \
|
||||
|| { echo "$name is expected in both roots, but one copy is missing."; fail=1; }
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$gh" ] || [ ! -f "$gt" ]; then
|
||||
echo "$name exists in only one workflow root and is not listed in ci/workflows-in-sync.sh."
|
||||
echo " A workflow missing from .gitea/workflows does not run on Gitea, silently."
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! cmp -s "$gh" "$gt"; then
|
||||
echo "$name has drifted between the two roots:"
|
||||
diff -u "$gh" "$gt" || true
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Measured on Gitea 1.27.2: a workflow-level `concurrency:` block produced no
|
||||
# run at all — not a failed one, not a cancelled one. It may have been fixed
|
||||
# since, but the cost of keeping the rule is nil and the cost of rediscovering
|
||||
# it is an afternoon of blaming the runner.
|
||||
if grep -rn '^concurrency:' .gitea/workflows/ 2>/dev/null; then
|
||||
echo "A workflow-level concurrency: block can stop a Gitea workflow from running at all."
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# A workflow that does not parse produces no run — the same symptom as every
|
||||
# other trap in this file. ci.yml cannot catch its own syntax error this way,
|
||||
# but release.yml and compat.yml can be caught here rather than on the tag.
|
||||
if python3 -c 'import yaml' 2>/dev/null; then
|
||||
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
|
||||
[ -e "$f" ] || continue
|
||||
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" \
|
||||
|| { echo "$f is not valid YAML, so it will simply never run."; fail=1; }
|
||||
done
|
||||
else
|
||||
echo "(PyYAML is not installed here, so the workflow files were not parsed.)"
|
||||
fi
|
||||
|
||||
[ "$fail" = 0 ] || exit 1
|
||||
echo "Workflows: in sync."
|
||||
Reference in New Issue
Block a user