WP Plugin Starter 1.0.0: a fresh start

The parts every wpallstars plugin needs: settings screen with search and an empty General tab, Read Me tab, features as classes, replaced plugins, the shared GitHub updater, release and check scripts, CI and the shared rules. scripts/rename-plugin.sh starts a new plugin; scripts/sync-core.sh keeps plugins' core files in step with this starter. Built with aidevops (https://aidevops.sh).
This commit is contained in:
marcus committed 2026-10-03 03:30:42 +01:00
commit 2f91c3e6af
62 files changed
+10929

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Build the plugin's banner from its source, .wordpress-org/banner.svg.
#
# admin/images/banner.svg
# Shipped with the plugin, shown at the top of the Read Me tab (and of
# README.md on GitHub). Text is turned into shapes, so it looks the same
# without the Zilla Slab font.
# .wordpress-org/banner-772x250.png
# .wordpress-org/banner-1544x500.png
# For the WordPress.org SVN assets/ folder (not in the plugin zip).
#
# Usage: scripts/build-banner.sh
#
# Needs Inkscape 1.x (INKSCAPE=/path/to/inkscape to choose one) and the Zilla Slab
# font installed (wpallstars.com's heading font).
set -euo pipefail
SOURCE=".wordpress-org/banner.svg"
die() {
local message="$1"
printf 'build-banner: %s\n' "$message" >&2
exit 1
}
find_inkscape() {
if [[ -n "${INKSCAPE:-}" ]]; then
printf '%s\n' "$INKSCAPE"
return 0
fi
if command -v inkscape >/dev/null 2>&1; then
command -v inkscape
return 0
fi
if [[ -x /Applications/Inkscape.app/Contents/MacOS/inkscape ]]; then
printf '%s\n' /Applications/Inkscape.app/Contents/MacOS/inkscape
return 0
fi
return 1
}
export_png() {
local inkscape="$1"
local width="$2"
local height="$3"
"$inkscape" "$SOURCE" --export-type=png --export-background-opacity=1 \
--export-width="$width" --export-height="$height" \
--export-filename=".wordpress-org/banner-${width}x${height}.png"
return 0
}
main() {
cd "$(git rev-parse --show-toplevel)"
[[ -f "$SOURCE" ]] || die "missing $SOURCE"
local inkscape
inkscape="$(find_inkscape)" || die "Inkscape not found; set INKSCAPE=/path/to/inkscape"
if command -v fc-list >/dev/null 2>&1; then
local families
families="$(fc-list : family)"
grep -qi '^Zilla Slab' <<<"$families" || die "the Zilla Slab font is not installed"
fi
"$inkscape" "$SOURCE" --export-text-to-path --export-plain-svg \
--export-filename=admin/images/banner.svg
export_png "$inkscape" 1544 500
export_png "$inkscape" 772 250
ls -l admin/images/banner.svg .wordpress-org/banner-1544x500.png .wordpress-org/banner-772x250.png
return 0
}
main "$@"
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Build the release zips of the plugin from a Git ref.
#
# Two builds of the same version, both with one {slug}/ folder inside (the
# slug is the main file's name, scripts/lib/plugin.sh):
# {slug}-X.Y.Z.zip
# GitHub release asset: the files in Git, less .distignore.
# wordpress-org-{slug}-X.Y.Z.zip
# WordPress.org build: the same, less the files in .distignore-wporg and
# the GitHub updater header lines. Its name does not start with the
# slug, so no updater picks it even if it is attached to a GitHub
# release by mistake (it takes the first asset whose name starts with
# the plugin slug). Never attach it to a GitHub release.
# SHA256SUMS
#
# Files come from the Git ref (git archive), never from the working tree, so
# uncommitted changes and untracked files are never included.
#
# Usage: scripts/build-release.sh [--ref REF] [--out DIR] [--quiet]
# --ref REF Commit, branch or tag to build (default: HEAD).
# --out DIR Output folder (default: dist/ in the repository, gitignored).
# --quiet Print only the paths of the zips.
#
# Needs git, rsync, zip, unzip and shasum or sha256sum.
set -euo pipefail
umask 022
export TZ=UTC
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
readonly WPORG_IGNORE=".distignore-wporg"
# Header lines read only by GitHub updaters (ours and Git Updater); left out of the WordPress.org build.
readonly WPORG_STRIP_HEADERS='GitHub Plugin URI|Primary Branch|Release Asset'
TMP_DIR=""
SLUG=""
MAIN_FILE=""
die() {
local message="$1"
printf 'build-release: %s\n' "$message" >&2
exit 1
}
usage() {
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
need() {
local tool="$1"
command -v "$tool" >/dev/null 2>&1 || die "needs $tool"
return 0
}
# Version: header of the main file at a commit.
version_at() {
local sha="$1"
git show "$sha:$MAIN_FILE" | sed -n 's/^[[:space:]*]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -n 1
return 0
}
sha256_of() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | cut -d' ' -f1
else
sha256sum "$file" | cut -d' ' -f1
fi
return 0
}
# Zip <folder>/<SLUG> as <zip>, with the slug folder at the top. Fixed times,
# modes (umask) and entry order make the same ref give the same zip anywhere.
make_zip() {
local folder="$1"
local zip_path="$2"
local partial="$zip_path.partial"
rm -f "$zip_path" "$partial"
(cd "$folder" && find "$SLUG" -exec touch -h -t 202001010000 {} + && find "$SLUG" | LC_ALL=C sort | zip -qX "$partial" -@)
mv "$partial" "$zip_path"
return 0
}
# Remove the GitHub updater header lines from the main file of a build.
strip_updater_headers() {
local main="$1"
local stripped="$main.tmp"
grep -Ev "^[[:space:]*]*($WPORG_STRIP_HEADERS):" "$main" >"$stripped"
mv "$stripped" "$main"
return 0
}
main() {
local ref="HEAD"
local out=""
local quiet=0
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--out)
[ $# -ge 2 ] || die "--out needs a value"
out="$2"
shift
;;
--quiet) quiet=1 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local tool
for tool in git rsync zip unzip; do
need "$tool"
done
local root sha version
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
sha="$(git rev-parse --verify --quiet "$ref^{commit}")" || die "not a commit: $ref"
plugin_identity "$sha" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
MAIN_FILE="$PLUGIN_MAIN_FILE"
version="$(version_at "$sha")"
[ -n "$version" ] || die "no Version: header in $MAIN_FILE at $ref"
if [ "$ref" = "HEAD" ] && [ -n "$(git status --porcelain --untracked-files=no)" ] && [ "$quiet" -eq 0 ]; then
printf 'Note: uncommitted changes are not in the build (it is made from HEAD).\n'
fi
[ -n "$out" ] || out="$root/dist"
mkdir -p "$out"
out="$(cd "$out" && pwd)"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-build.XXXXXX")"
mkdir -p "$TMP_DIR/src" "$TMP_DIR/github/$SLUG" "$TMP_DIR/wporg/$SLUG"
git archive --format=tar "$sha" | tar -x -C "$TMP_DIR/src"
# .distignore lists itself and is export-ignored, so read it from Git.
git show "$sha:.distignore" >"$TMP_DIR/distignore" || die ".distignore missing at $ref"
rsync -a --exclude-from="$TMP_DIR/distignore" "$TMP_DIR/src/" "$TMP_DIR/github/$SLUG/"
[ -f "$TMP_DIR/github/$SLUG/$MAIN_FILE" ] || die "$MAIN_FILE is not in the build; check .distignore"
if git cat-file -e "$sha:$WPORG_IGNORE" 2>/dev/null; then
git show "$sha:$WPORG_IGNORE" >"$TMP_DIR/wporg-ignore"
else
: >"$TMP_DIR/wporg-ignore"
fi
rsync -a --exclude-from="$TMP_DIR/wporg-ignore" "$TMP_DIR/github/$SLUG/" "$TMP_DIR/wporg/$SLUG/"
strip_updater_headers "$TMP_DIR/wporg/$SLUG/$MAIN_FILE"
local github_zip="$out/$SLUG-$version.zip"
local wporg_zip="$out/wordpress-org-$SLUG-$version.zip"
make_zip "$TMP_DIR/github" "$github_zip"
make_zip "$TMP_DIR/wporg" "$wporg_zip"
{
printf '%s %s\n' "$(sha256_of "$github_zip")" "$(basename "$github_zip")"
printf '%s %s\n' "$(sha256_of "$wporg_zip")" "$(basename "$wporg_zip")"
} >"$out/SHA256SUMS"
if [ "$quiet" -eq 1 ]; then
printf '%s\n%s\n' "$github_zip" "$wporg_zip"
return 0
fi
printf '%s %s from %s (%s)\n' "$PLUGIN_NAME" "$version" "$ref" "${sha:0:12}"
printf ' GitHub release: %s (%s files)\n' "$github_zip" "$(unzip -Z1 "$github_zip" | grep -cv '/$')"
printf ' WordPress.org: %s (%s files)\n' "$wporg_zip" "$(unzip -Z1 "$wporg_zip" | grep -cv '/$')"
printf ' Checksums: %s\n' "$out/SHA256SUMS"
printf 'Check them with: scripts/preflight-release.sh --ref %s\n' "$ref"
return 0
}
main "$@"
+55
View File
@@ -0,0 +1,55 @@
# Core files: the same in every plugin made from the starter, apart from the
# names (STANDARDS.md → Structure). scripts/sync-core.sh copies them from
# the starter with the plugin's names, and --check lists any that differ.
# Paths use the starter's names; a trailing / means every file below.
# Change a core file in the starter first, then sync each plugin.
# Bootstrap, feature registry, base feature and settings store.
includes/class-wpstarter.php
includes/class-wpstarter-feature.php
includes/class-wpstarter-settings.php
# Shared GitHub updater: only the newest copy on a site runs.
includes/github-updater/
# Admin screen, Read Me tab and the Plugins screen notes for replaced plugins.
admin/settings.php
admin/data/readme.php
admin/includes/class-admin-manager.php
admin/includes/class-settings-manager.php
admin/includes/class-readme-manager.php
admin/includes/class-replaced-plugins.php
admin/css/wpstarter-admin.css
admin/js/wpstarter-admin.js
# Build, check, release and preview scripts.
scripts/build-banner.sh
scripts/build-release.sh
scripts/core-files.txt
scripts/lib/plugin.sh
scripts/lint.sh
scripts/phpstan-bootstrap.php
scripts/plugin-check.sh
scripts/preflight-release.sh
scripts/preview-site.sh
scripts/rename-plugin.sh
scripts/replaced-plugins.php
scripts/smoke-test.sh
scripts/sync-core.sh
# CI and tool configuration.
.github/
.distignore
.gitattributes
.gitignore
.gitleaks.toml
composer.json
phpcs.xml.dist
phpstan.neon.dist
# Shared docs.
STANDARDS.md
DEVELOPMENT.md
RELEASING.md
CONTRIBUTING.md
SECURITY.md
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# The PLUGIN_* variables are read by the scripts that source this file.
# shellcheck disable=SC2034
# Who the plugin is, read from its main file, so the scripts in scripts/ work
# unchanged in every plugin made from the starter. Source it, then call
# plugin_identity [REF] (default HEAD). It sets:
#
# PLUGIN_MAIN_FILE the PHP file at the top of the repository with a
# "Plugin Name:" header, for example wp-plugin-starter-template.php
# PLUGIN_SLUG its name without .php: the plugin folder and text domain
# PLUGIN_NAME the Plugin Name header, for example WP Plugin Starter
# PLUGIN_PACKAGE the @package tag, which is also the class prefix
# (WPStarter: WPStarter_Settings, WPStarter::features())
# PLUGIN_CONST the constant prefix, from define('<PREFIX>_VERSION', ...)
# (WPSTARTER)
# PLUGIN_PREFIX the option, hook and transient prefix: PLUGIN_CONST in
# lower case (wpstarter)
# PLUGIN_CSS the CSS class and data attribute prefix, from the admin
# screen's "wrap <css>-wrap" class (wps: .wps-card)
# PLUGIN_REPO the GitHub Plugin URI header, owner/repo (may be empty)
#
# Files come from the Git ref, never the working tree, like the release build.
PLUGIN_MAIN_FILE=""
PLUGIN_SLUG=""
PLUGIN_NAME=""
PLUGIN_PACKAGE=""
PLUGIN_CONST=""
PLUGIN_PREFIX=""
PLUGIN_CSS=""
PLUGIN_REPO=""
# Value of a "Key: value" header line in the text, case-insensitive.
plugin_header_field() {
local text="$1"
local key="$2"
printf '%s\n' "$text" | awk -v k="$key" '
BEGIN { k = tolower(k) }
{
line = $0
sub(/^[ \t*]*/, "", line)
i = index(line, ":")
if (i > 0 && tolower(substr(line, 1, i - 1)) == k) {
v = substr(line, i + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v
exit
}
}'
return 0
}
plugin_identity() {
local ref="${1:-HEAD}"
local file text found=""
while IFS= read -r file; do
case "$file" in
*.php) ;;
*) continue ;;
esac
# WordPress reads the header from the first 8 KB of the file.
text="$(git show "$ref:$file" 2>/dev/null || true)"
if [ -n "$(plugin_header_field "${text:0:8192}" "Plugin Name")" ]; then
[ -z "$found" ] || {
printf 'plugin: more than one main file at %s: %s and %s\n' "$ref" "$found" "$file" >&2
return 1
}
found="$file"
fi
done < <(git ls-tree --name-only "$ref" 2>/dev/null)
if [ -z "$found" ]; then
printf 'plugin: no PHP file with a Plugin Name: header at the top of %s\n' "$ref" >&2
return 1
fi
text="$(git show "$ref:$found")"
PLUGIN_MAIN_FILE="$found"
PLUGIN_SLUG="${found%.php}"
PLUGIN_NAME="$(plugin_header_field "${text:0:8192}" "Plugin Name")"
PLUGIN_PACKAGE="$(printf '%s\n' "$text" | sed -nE '/^[[:space:]*]*@package[[:space:]]+[A-Za-z0-9_]+/{s/^[[:space:]*]*@package[[:space:]]+([A-Za-z0-9_]+).*/\1/p;q;}')"
PLUGIN_CONST="$(printf '%s\n' "$text" | sed -nE "/define\([[:space:]]*['\"][A-Z0-9_]+_VERSION['\"]/{s/.*define\([[:space:]]*['\"]([A-Z0-9_]+)_VERSION['\"].*/\1/p;q;}")"
if [ -z "$PLUGIN_PACKAGE" ]; then
printf 'plugin: %s has no @package tag (the class prefix)\n' "$found" >&2
return 1
fi
if [ -z "$PLUGIN_CONST" ]; then
printf "plugin: %s does not define a <PREFIX>_VERSION constant\n" "$found" >&2
return 1
fi
PLUGIN_PREFIX="$(printf '%s' "$PLUGIN_CONST" | tr '[:upper:]' '[:lower:]')"
PLUGIN_REPO="$(plugin_header_field "${text:0:8192}" "GitHub Plugin URI")"
PLUGIN_CSS="$(git show "$ref:admin/includes/class-admin-manager.php" 2>/dev/null | sed -nE 's/.*class="wrap ([a-z0-9]+)-wrap.*/\1/p' | head -n 1)"
if [ -z "$PLUGIN_CSS" ]; then
printf 'plugin: no "wrap <css>-wrap" class in admin/includes/class-admin-manager.php at %s\n' "$ref" >&2
return 1
fi
return 0
}
# Copy the PLUGIN_* names into FROM_* (from) or TO_* (to), for plugin_map.
plugin_names_as() {
local side="$1"
local name
for name in SLUG NAME PACKAGE CONST PREFIX CSS REPO; do
eval "export ${side}_${name}=\"\$PLUGIN_${name}\""
done
return 0
}
# Rewrite text on stdin from one plugin's names (FROM_*) to another's (TO_*):
# GitHub repository, name, class prefix, constant prefix, slug, prefix and
# CSS prefix, in that order. When the source plugin's slug is also its prefix
# (myplugin), the slug is only the quoted word ('myplugin': text domain,
# admin page) and the main file name; everywhere else it is the prefix.
plugin_map() {
perl -pe '
BEGIN { %e = map { $_ => $ENV{$_} // "" } grep { /^(FROM|TO)_/ } keys %ENV; }
s{\Q$e{FROM_REPO}\E}{$e{TO_REPO}}g if $e{FROM_REPO} ne "" && $e{TO_REPO} ne "";
s{\Q$e{FROM_NAME}\E}{$e{TO_NAME}}g;
s{\Q$e{FROM_PACKAGE}\E}{$e{TO_PACKAGE}}g;
s{\Q$e{FROM_CONST}\E}{$e{TO_CONST}}g;
if ($e{FROM_SLUG} eq $e{FROM_PREFIX}) {
s{(["\x27])\Q$e{FROM_SLUG}\E\1}{${1}$e{TO_SLUG}${1}}g;
s{\b\Q$e{FROM_SLUG}\E\.php\b}{$e{TO_SLUG}.php}g;
} else {
s{\Q$e{FROM_SLUG}\E}{$e{TO_SLUG}}g;
}
s{\Q$e{FROM_PREFIX}\E}{$e{TO_PREFIX}}g;
s{\b\Q$e{FROM_CSS}\E(?=[-_A-Z])}{$e{TO_CSS}}g;
'
return 0
}
# Value of the environment variable <PLUGIN_CONST>_<NAME>, or the default.
# For example plugin_env PREVIEW_SITE reads WPSTARTER_PREVIEW_SITE.
plugin_env() {
local name="${PLUGIN_CONST}_$1"
local default="${2:-}"
printf '%s' "${!name:-$default}"
return 0
}
# Paths the WordPress.org build leaves out (.distignore-wporg at the ref),
# one per line, without the leading slash: the GitHub updater.
plugin_wporg_only() {
local ref="${1:-HEAD}"
git show "$ref:.distignore-wporg" 2>/dev/null | sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' -e 's|^/||' -e 's|/$||'
return 0
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Code checks for the plugin, the same ones CI runs on every pull request
# (.github/workflows/ci.yml). Changes nothing.
#
# Usage: scripts/lint.sh [CHECK]...
# php PHP syntax of every PHP file, with the local php.
# js JavaScript syntax of every JS file (node --check).
# shell ShellCheck on every shell script.
# workflows actionlint on .github/workflows (skipped if not installed).
# phpcs WordPress Coding Standards, security sniffs and PHP 7.4
# compatibility (phpcs.xml.dist).
# phpstan Static analysis (phpstan.neon.dist): no new findings beyond
# phpstan-baseline.neon.
# With no CHECK, runs them all. phpcs and phpstan need `composer install`.
#
# Exit status: 0 when every check passes, 1 when any fails, 2 on bad usage.
set -euo pipefail
readonly ALL_CHECKS="php js shell workflows phpcs phpstan"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ROOT
FAILED=""
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
say() {
local message="$1"
printf '\n== %s\n' "$message"
return 0
}
need_vendor() {
local tool="$1"
if [ ! -x "$ROOT/vendor/bin/$tool" ]; then
printf 'lint: vendor/bin/%s is missing; run: composer install\n' "$tool" >&2
return 1
fi
return 0
}
check_php() {
local file output status=0
while IFS= read -r -d '' file; do
if ! output="$(php -l "$file" 2>&1)"; then
printf '%s\n' "$output"
status=1
fi
done < <(git -C "$ROOT" ls-files -z '*.php')
return "$status"
}
check_js() {
local file status=0
while IFS= read -r -d '' file; do
node --check "$file" || status=1
done < <(git -C "$ROOT" ls-files -z '*.js')
return "$status"
}
check_shell() {
local files=()
local file
while IFS= read -r -d '' file; do
files+=("$file")
done < <(git -C "$ROOT" ls-files -z '*.sh')
[ "${#files[@]}" -eq 0 ] && return 0
# -x follows the scripts' `# shellcheck source=` lines (scripts/lib/).
shellcheck -x --severity=style "${files[@]}"
return $?
}
check_workflows() {
if [ ! -d "$ROOT/.github/workflows" ]; then
return 0
fi
if ! command -v actionlint >/dev/null 2>&1; then
printf 'actionlint is not installed; skipped (CI runs it)\n'
return 0
fi
actionlint
return $?
}
check_phpcs() {
need_vendor phpcs || return 1
vendor/bin/phpcs -q --report=full
return $?
}
check_phpstan() {
need_vendor phpstan || return 1
vendor/bin/phpstan analyse --memory-limit=4G --no-progress
return $?
}
main() {
local checks="$*"
local check
if [ -z "$checks" ]; then
checks="$ALL_CHECKS"
fi
for check in $checks; do
case " $ALL_CHECKS " in
*" $check "*) ;;
*)
usage >&2
return 2
;;
esac
done
cd "$ROOT"
for check in $checks; do
say "$check"
if "check_$check"; then
printf 'ok\n'
else
FAILED="$FAILED $check"
fi
done
if [ -n "$FAILED" ]; then
printf '\nlint: failed:%s\n' "$FAILED" >&2
return 1
fi
printf '\nlint: all passed (%s)\n' "$checks"
return 0
}
main "$@"
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Constants for PHPStan (phpstan.neon.dist). WordPress's functions and
* classes come from szepeviktor/phpstan-wordpress; this adds the constants
* WordPress defines while it loads (wp-settings.php, wp-config.php) and
* those wp-plugin-starter-template.php defines.
*
* @package WPStarter
*/
define('WPINC', 'wp-includes');
define('COOKIEPATH', '/');
define('SITECOOKIEPATH', '/');
define('DB_NAME', 'wordpress');
define('WPSTARTER_VERSION', '0.0.0');
define('WPSTARTER_FILE', dirname(__DIR__) . '/wp-plugin-starter-template.php');
define('WPSTARTER_DIR', dirname(__DIR__) . '/');
define('WPSTARTER_URL', 'https://example.com/wp-content/plugins/wp-plugin-starter-template/');
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# Run Plugin Check (the WordPress.org review tool) on the release zips, in a
# disposable WordPress in Docker. Nothing is kept: the containers, volume and
# network are removed when it ends, and no other site is touched.
#
# Usage: scripts/plugin-check.sh [--ref REF] [--zip FILE]... [--keep-output DIR]
# --ref REF Build both zips from REF (default: HEAD) and check them.
# --zip FILE Check this zip instead (repeatable); it must hold one
# {slug}/ folder.
# --keep-output DIR Save each full report as JSON in DIR.
#
# Exit status: 0 when no zip has Plugin Check errors. Warnings are listed;
# review them before a WordPress.org submission. Updater findings in the
# GitHub zip's updater files (.distignore-wporg) are expected and not counted.
# Needs Docker and internet access (WordPress and Plugin Check are downloaded).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set in main() from the plugin's main file.
SLUG=""
UPDATER_FILES=""
CLI_IMAGE=""
DB_IMAGE=""
readonly DB_PASSWORD="plugincheck"
NAME=""
TMP_DIR=""
STARTED=0
die() {
local message="$1"
printf 'plugin-check: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ "$STARTED" -eq 1 ]; then
local attempt id
# wp-cli containers still stopping (after Ctrl-C) keep the volume and
# network in use, so remove them first and retry.
for attempt in 1 2 3; do
docker ps -aq --filter "volume=$NAME-wp" | while IFS= read -r id; do
docker rm -f "$id" >/dev/null 2>&1 || true
done
docker rm -f "$NAME-db" >/dev/null 2>&1 || true
docker volume rm "$NAME-wp" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
if ! docker volume inspect "$NAME-wp" >/dev/null 2>&1 && ! docker network inspect "$NAME" >/dev/null 2>&1; then
break
fi
[ "$attempt" -eq 3 ] && printf 'plugin-check: could not remove %s-wp or network %s\n' "$NAME" "$NAME" >&2
sleep 2
done
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
# wp-cli in the disposable site; zips are mounted at /zips. The image's
# 128 MB PHP memory limit is too small to unpack WordPress.
wp_cli() {
docker run --rm --network "$NAME" -v "$NAME-wp:/var/www/html" -v "$TMP_DIR/zips:/zips:ro" \
--user 33:33 "$CLI_IMAGE" php -d memory_limit=1G /usr/local/bin/wp "$@"
return $?
}
start_site() {
printf 'Starting a disposable WordPress (%s)...\n' "$NAME"
STARTED=1
docker network create "$NAME" >/dev/null
docker volume create "$NAME-wp" >/dev/null
docker run -d --name "$NAME-db" --network "$NAME" \
-e MARIADB_ROOT_PASSWORD="$DB_PASSWORD" -e MARIADB_DATABASE=wordpress "$DB_IMAGE" >/dev/null
# The volume starts owned by root; let www-data (33) write to it.
docker run --rm -v "$NAME-wp:/var/www/html" --user 0:0 "$CLI_IMAGE" chown 33:33 /var/www/html
# Ping over TCP: the image's first, socket-only server answers on the
# socket before the real server is listening.
local waited=0
until docker exec "$NAME-db" mariadb-admin ping -h127.0.0.1 -uroot -p"$DB_PASSWORD" --silent >/dev/null 2>&1; do
[ "$waited" -lt 90 ] || die "the database did not start"
sleep 2
waited=$((waited + 2))
done
wp_cli core download --quiet
wp_cli config create --dbname=wordpress --dbuser=root --dbpass="$DB_PASSWORD" --dbhost="$NAME-db" --skip-check --quiet
wp_cli core install --url=http://localhost --title=PluginCheck --admin_user=admin --admin_password="$DB_PASSWORD" \
--admin_email=admin@example.com --skip-email --quiet
wp_cli plugin install plugin-check --activate --quiet
printf 'WordPress %s, Plugin Check %s\n' "$(wp_cli core version)" "$(wp_cli plugin get plugin-check --field=version)"
return 0
}
# Check one zip; returns 1 when Plugin Check reports errors.
check_zip() {
local zip_name="$1"
local keep="$2"
local report errors warnings
printf '\n== %s ==\n' "$zip_name"
if ! wp_cli plugin install "/zips/$zip_name" --force --quiet; then
printf 'Could not install %s.\n' "$zip_name"
return 1
fi
report="$(wp_cli plugin check "$SLUG" --format=json 2>&1 || true)"
if [ -n "$keep" ]; then
printf '%s\n' "$report" >"$keep/${zip_name%.zip}-plugin-check.json"
fi
if ! printf '%s\n' "$report" | grep -Eq '^(FILE: |Success: )'; then
# Neither findings nor the success line: Plugin Check did not run.
printf 'Plugin Check did not run:\n%s\n' "$report"
wp_cli plugin delete "$SLUG" --quiet || true
return 1
fi
errors="$( (printf '%s\n' "$report" | grep -o '"type":"ERROR"' || true) | wc -l | tr -d ' ')"
warnings="$( (printf '%s\n' "$report" | grep -o '"type":"WARNING"' || true) | wc -l | tr -d ' ')"
# The GitHub zip carries Updates from GitHub on purpose; Plugin Check
# reports it as an updater. Those findings are expected there (and only
# in those files); in the WordPress.org zip they stay errors. So are
# prefix warnings there: the shared updater's names (wpallstars_) are the
# same in every plugin, so that one copy can stand in for the others.
local expected=0
local expected_warnings=0
local counts
case "$zip_name" in
"$SLUG"-*)
# The file list goes through the environment: awk -v cannot hold newlines.
counts="$(printf '%s\n' "$report" | FILES="$UPDATER_FILES" awk '
BEGIN { nfiles = split(ENVIRON["FILES"], list, "\n") }
# A listed file, or a file inside a listed folder.
function updater(path, i) {
for (i = 1; i <= nfiles; i++) {
if (list[i] != "" && (path == list[i] || index(path, list[i] "/") == 1)) { return 1 }
}
return 0
}
/^FILE: / { current = substr($0, 7); next }
/^\[/ && updater(current) {
n = split($0, items, "},{")
for (i = 1; i <= n; i++) {
if (items[i] ~ /"type":"ERROR"/ && items[i] ~ /"code":"(plugin_updater_detected|update_modification_detected|PluginCheck\.CodeAnalysis\.Offloading\.OffloadedContent)"/) { count++ }
if (items[i] ~ /"type":"WARNING"/ && items[i] ~ /"code":"WordPress\.NamingConventions\.PrefixAllGlobals\./) { prefix++ }
}
}
END { print count + 0, prefix + 0 }')"
expected="${counts% *}"
expected_warnings="${counts#* }"
;;
esac
if [ "$expected" -gt 0 ] || [ "$expected_warnings" -gt 0 ]; then
printf '%s updater error(s) and %s prefix warning(s) in %s are expected in the GitHub zip.\n' "$expected" "$expected_warnings" "$(printf '%s' "$UPDATER_FILES" | tr '\n' ' ')"
errors=$((errors - expected))
warnings=$((warnings - expected_warnings))
fi
# Readable summary: one line per finding (type, code, file:line).
printf '%s\n' "$report" | awk '
/^FILE: / { file = substr($0, 7); next }
/^\[/ {
n = split($0, items, "},{")
for (i = 1; i <= n; i++) {
t = items[i]; c = items[i]; l = items[i]
sub(/.*"type":"/, "", t); sub(/".*/, "", t)
sub(/.*"code":"/, "", c); sub(/".*/, "", c)
sub(/.*"line":/, "", l); sub(/[^0-9].*/, "", l)
printf " %-7s %s %s:%s\n", t, c, file, l
}
}'
printf '%s error(s), %s warning(s)\n' "$errors" "$warnings"
wp_cli plugin delete "$SLUG" --quiet || true
[ "$errors" -eq 0 ] || return 1
return 0
}
main() {
local ref="HEAD"
local keep=""
local zips=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--zip)
[ $# -ge 2 ] || die "--zip needs a file"
[ -f "$2" ] || die "no such zip: $2"
zips="$zips
$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
shift
;;
--keep-output)
[ $# -ge 2 ] || die "--keep-output needs a folder"
mkdir -p "$2"
keep="$(cd "$2" && pwd)"
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
command -v docker >/dev/null 2>&1 || die "needs Docker"
docker info >/dev/null 2>&1 || die "Docker is not running"
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity "$ref" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
UPDATER_FILES="$(plugin_wporg_only "$ref")"
CLI_IMAGE="$(plugin_env CLI_IMAGE wordpress:cli-php8.3)"
DB_IMAGE="$(plugin_env DB_IMAGE mariadb:10.6)"
NAME="$SLUG-plugincheck-$$"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-plugincheck.XXXXXX")"
mkdir -p "$TMP_DIR/zips"
chmod 755 "$TMP_DIR" "$TMP_DIR/zips"
local zip_path
if [ -z "$zips" ]; then
"$root/scripts/build-release.sh" --ref "$ref" --out "$TMP_DIR/zips" --quiet >/dev/null || die "build failed"
else
while IFS= read -r zip_path; do
[ -n "$zip_path" ] && cp "$zip_path" "$TMP_DIR/zips/"
done <<EOF
$zips
EOF
fi
chmod 644 "$TMP_DIR"/zips/*.zip
start_site
local failed=0 zip_name
for zip_path in "$TMP_DIR"/zips/*.zip; do
zip_name="$(basename "$zip_path")"
check_zip "$zip_name" "$keep" || failed=1
done
printf '\n'
if [ "$failed" -eq 1 ]; then
printf 'Plugin Check found errors.\n'
return 1
fi
printf 'Plugin Check: no errors.\n'
return 0
}
main "$@"
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env bash
# Check a version of the plugin before it is released on GitHub or
# submitted to WordPress.org. Changes nothing: it builds both zips into a
# temporary folder (scripts/build-release.sh) and reads them and the Git ref.
#
# Errors stop a release. Warnings need a look and a decision; most matter only
# for WordPress.org. Notes are for information.
#
# Usage: scripts/preflight-release.sh [--ref REF] [--offline] [--strict] [--no-docker]
# --ref REF Commit, branch or tag to check (default: HEAD).
# --offline Skip checks that ask WordPress.org (latest WordPress version,
# slug, contributor profiles).
# --strict Fail on warnings too (use before a WordPress.org submission).
# --no-docker Lint PHP with the local php instead of PHP 7.4 in Docker.
#
# Exit status: 0 when there are no errors (and, with --strict, no warnings).
# Plugin Check runs separately: scripts/plugin-check.sh.
set -euo pipefail
readonly UPDATER_HEADERS='GitHub Plugin URI|Primary Branch|Release Asset'
# Development files that must never be in a release zip (paths inside the slug folder).
readonly DEV_FILES='^[^/]+/(\.git|\.agents|\.wordpress-org|\.distignore|\.distignore-wporg|\.gitattributes|\.gitignore|\.woodpecker\.yml|\.github|\.editorconfig|\.gitleaks\.toml|\.aidevops\.json|composer\.(json|lock)|phpcs\.xml(\.dist)?|phpstan(-baseline|-plugin)?\.neon(\.dist)?|vendor|AGENTS\.md|CONTRIBUTING\.md|DEVELOPMENT\.md|LAUNCH\.md|SECURITY\.md|STANDARDS\.md|RELEASING\.md|ROADMAP\.md|STABILITY\.md|TESTING\.md|scripts|dist|node_modules|reference-plugins|project-documents)(/|$)|(^|/)(\.DS_Store|__MACOSX|Thumbs\.db)(/|$)|\.(bak|log|orig|swp)$'
readonly README_MAX_BYTES=10240
readonly SHORT_DESC_MAX=150
readonly MAX_TAGS=5
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set from the main file at the ref (plugin_identity).
SLUG=""
MAIN_FILE=""
VERSION_CONSTANT=""
# Paths only in the GitHub build (.distignore-wporg): the GitHub updater.
UPDATER_FILES=""
ERRORS=0
WARNINGS=0
TMP_DIR=""
OFFLINE=0
USE_DOCKER=1
CHANGELOG_TXT=""
die() {
local message="$1"
printf 'preflight: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
ok() {
local message="$1"
printf ' ok %s\n' "$message"
return 0
}
err() {
local message="$1"
printf ' ERROR %s\n' "$message"
ERRORS=$((ERRORS + 1))
return 0
}
warn() {
local message="$1"
printf ' warn %s\n' "$message"
WARNINGS=$((WARNINGS + 1))
return 0
}
note() {
local message="$1"
printf ' note %s\n' "$message"
return 0
}
section() {
local title="$1"
printf '\n%s\n' "$title"
return 0
}
# Value of "Key: value" in a header (plugin file comment or readme), case-insensitive.
field() {
local text="$1"
local key="$2"
printf '%s\n' "$text" | awk -v k="$key" '
BEGIN { k = tolower(k) }
{
line = $0
sub(/^[ \t*]*/, "", line)
i = index(line, ":")
if (i > 0 && tolower(substr(line, 1, i - 1)) == k) {
v = substr(line, i + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v
exit
}
}'
return 0
}
# 1 if version a < b (numeric parts), else 0.
version_lt() {
local a="$1"
local b="$2"
awk -v a="$a" -v b="$b" 'BEGIN {
na = split(a, x, "."); nb = split(b, y, "."); n = (na > nb) ? na : nb
for (i = 1; i <= n; i++) { if ((x[i] + 0) < (y[i] + 0)) { print 1; exit } if ((x[i] + 0) > (y[i] + 0)) { print 0; exit } }
print 0 }'
return 0
}
# HTTP status of a URL (000 when offline or unreachable).
http_status() {
local url="$1"
local code
# curl prints 000 itself when it cannot connect, and exits non-zero.
code="$(curl -sL -o /dev/null -m 20 -w '%{http_code}' "$url" || true)"
printf '%s' "${code:-000}"
return 0
}
check_versions() {
local plugin_header="$1"
local readme="$2"
local main_php="$3"
section "Versions and headers"
local version constant stable
version="$(field "$plugin_header" "Version")"
constant="$(printf '%s\n' "$main_php" | sed -nE "/define\([[:space:]]*['\"]${VERSION_CONSTANT}['\"]/{s/.*,[[:space:]]*['\"]([^'\"]+)['\"].*/\1/p;q;}")"
stable="$(field "$readme" "Stable tag")"
if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
ok "Version: $version (numbers only, so GitHub updates and WordPress.org treat it as stable)"
else
err "Version: '$version' is not X.Y.Z; pre-release versions on main are offered to sites still using Git Updater"
fi
if [ "$constant" = "$version" ]; then ok "version constant matches ($constant)"; else err "version constant is '$constant', Version: is '$version'"; fi
if [ "$stable" = "$version" ]; then ok "readme.txt Stable tag matches"; else err "readme.txt Stable tag is '$stable', Version: is '$version'"; fi
if [ "$(printf '%s' "$stable" | tr '[:upper:]' '[:lower:]')" = "trunk" ]; then err "Stable tag: trunk is not allowed for new plugins"; fi
local key header_value readme_value
for key in "Requires at least" "Requires PHP"; do
header_value="$(field "$plugin_header" "$key")"
readme_value="$(field "$readme" "$key")"
if [ -z "$header_value" ]; then
err "$MAIN_FILE has no '$key' header (WordPress reads it from the plugin file)"
elif [ -n "$readme_value" ] && [ "$header_value" != "$readme_value" ]; then
err "$key: $header_value in $MAIN_FILE, $readme_value in readme.txt"
else
ok "$key: $header_value"
fi
done
if printf '%s\n' "$main_php" | grep -Eiq '^[[:space:]*]*Update URI:'; then
err "Update URI header found: WordPress.org rejects it and Plugin Check reports an updater"
else
ok "no Update URI header"
fi
local domain license plugin_uri author_uri
domain="$(field "$plugin_header" "Text Domain")"
if [ "$domain" = "$SLUG" ]; then ok "Text Domain: $domain"; else err "Text Domain is '$domain'; it must be the slug ($SLUG) for language packs"; fi
license="$(field "$plugin_header" "License")"
if printf '%s' "$license" | grep -Eiq 'GPL'; then ok "License: $license"; else err "License '$license' is not GPL-compatible as written"; fi
plugin_uri="$(field "$plugin_header" "Plugin URI")"
author_uri="$(field "$plugin_header" "Author URI")"
if [ -n "$plugin_uri" ] && [ "$plugin_uri" = "$author_uri" ]; then
warn "Plugin URI and Author URI are the same ($plugin_uri); WordPress.org asks for a Plugin URI unique to the plugin, or none"
fi
VERSION="$version"
return 0
}
# Changelog text (a readme section or changelog.txt): an entry for the version,
# and no Unreleased section left.
check_changelog() {
local label="$1"
local text="$2"
local version="$3"
if printf '%s\n' "$text" | grep -Eq "^= *v?$version *="; then
ok "$label changelog has $version"
else
warn "$label changelog has no '= $version =' entry"
fi
if printf '%s\n' "$text" | grep -Eiq '^= *unreleased *='; then
warn "$label changelog still has an Unreleased section; name it $version when releasing"
fi
return 0
}
check_readme() {
local readme="$1"
local plugin_header="$2"
local version="$3"
section "readme.txt"
local bytes
bytes="$(printf '%s' "$readme" | wc -c | tr -d ' ')"
if [ "$bytes" -le "$README_MAX_BYTES" ]; then
ok "size ${bytes} bytes ($((README_MAX_BYTES - bytes)) left)"
else
warn "size ${bytes} bytes; WordPress.org says over 10 KB may cause errors (keep the current changelog, move older entries to changelog.txt, trim the description)"
fi
local name readme_name
name="$(field "$plugin_header" "Plugin Name")"
readme_name="$(printf '%s\n' "$readme" | sed -n '1s/^===[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*===.*/\1/p')"
if [ "$readme_name" = "$name" ]; then ok "name matches the plugin header ($name)"; else warn "readme name '$readme_name' differs from Plugin Name '$name'"; fi
local short
short="$(printf '%s\n' "$readme" | awk 'NR == 1 { next } !h && /^[ \t]*$/ { h = 1; next } h && /^==/ { exit } h && !/^[ \t]*$/ { print; exit }')"
if [ -z "$short" ]; then
err "no short description (the line after the header)"
elif [ "${#short}" -le "$SHORT_DESC_MAX" ]; then
ok "short description ${#short} characters"
else
warn "short description ${#short} characters; WordPress.org cuts it at $SHORT_DESC_MAX"
fi
local tags tag_count
tags="$(field "$readme" "Tags")"
tag_count="$(printf '%s\n' "$tags" | awk -F',' '{ print ($0 == "") ? 0 : NF }')"
if [ "$tag_count" -ge 1 ] && [ "$tag_count" -le "$MAX_TAGS" ]; then ok "$tag_count tags"; else warn "$tag_count tags; WordPress.org shows at most $MAX_TAGS"; fi
local tested
tested="$(field "$readme" "Tested up to")"
if printf '%s' "$tested" | grep -Eq '^[0-9]+\.[0-9]+$'; then
ok "Tested up to: $tested"
else
err "Tested up to '$tested' should be a major version such as 6.8"
fi
if [ "$OFFLINE" -eq 0 ]; then
local latest latest_major
# The first offer is the latest release; later ones are older branches.
latest="$(curl -s -m 20 'https://api.wordpress.org/core/version-check/1.7/' | grep -o '"current":"[0-9.]*"' | head -n 1 | cut -d'"' -f4 || true)"
latest_major="$(printf '%s' "$latest" | cut -d. -f1-2)"
if [ -z "$latest_major" ]; then
note "could not read the latest WordPress version"
elif [ "$(version_lt "$tested" "$latest_major")" = "1" ]; then
warn "Tested up to $tested, latest WordPress is $latest; test and raise it"
else
ok "Tested up to is the latest WordPress ($latest)"
fi
fi
check_changelog "readme.txt" "$(printf '%s\n' "$readme" | awk '/^== *[Cc]hangelog *==/ { c = 1; next } c && /^== / { exit } c')" "$version"
if [ -n "$CHANGELOG_TXT" ]; then
check_changelog "changelog.txt" "$CHANGELOG_TXT" "$version"
fi
if printf '%s\n' "$readme" | grep -Eiq '^== *upgrade notice *=='; then
if printf '%s\n' "$readme" | awk '/^== *[Uu]pgrade [Nn]otice *==/ { u = 1; next } u && /^== / { exit } u' | grep -Eq "^= *v?$version *="; then
ok "upgrade notice has $version"
else
note "no upgrade notice for $version (optional)"
fi
fi
return 0
}
check_wporg() {
local readme="$1"
local plugin_header="$2"
section "WordPress.org listing"
local name derived
name="$(field "$plugin_header" "Plugin Name")"
derived="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')"
if [ "$derived" = "$SLUG" ]; then
ok "the slug WordPress.org makes from '$name' is $SLUG"
else
warn "WordPress.org makes the slug from Plugin Name: '$name' becomes '$derived', not '$SLUG'. Ask for '$SLUG' in the submission notes (it can change only before approval), or the text domain will not match"
fi
if printf '%s' "$name" | grep -Eiq '^(wordpress|wp|woocommerce|woo|gutenberg)([^a-z]|$)'; then
warn "Plugin Name starts with a trademark ('$name'); WordPress.org does not allow that"
fi
if [ "$OFFLINE" -eq 1 ]; then
note "offline: slug and contributor profiles not checked"
return 0
fi
local info
info="$(curl -s -m 20 "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=$SLUG&request%5Bfields%5D%5Bsections%5D=0" || true)"
if printf '%s' "$info" | grep -q '"error"'; then
note "slug $SLUG is not listed on WordPress.org yet"
elif [ -n "$info" ]; then
note "slug $SLUG is already listed on WordPress.org; check it is this plugin"
fi
local contributors user status
contributors="$(field "$readme" "Contributors")"
if [ -z "$contributors" ]; then
err "readme.txt has no Contributors"
fi
for user in $(printf '%s' "$contributors" | tr ',' ' '); do
status="$(http_status "https://profiles.wordpress.org/$user/")"
case "$status" in
200) ok "contributor $user has a WordPress.org profile" ;;
000) note "could not check contributor $user" ;;
*) warn "contributor '$user' has no WordPress.org profile (HTTP $status); Contributors must be WordPress.org usernames, case-sensitive" ;;
esac
done
return 0
}
# Lint PHP (PHP 7.4 in Docker when possible) and JS in an unpacked build.
check_syntax() {
local dir="$1"
local label="$2"
local out php_label
local expected
expected="$(find "$dir" -name '*.php' | wc -l | tr -d ' ')"
if [ "$USE_DOCKER" -eq 1 ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
local image="php:7.4-cli"
if docker image inspect wordpress:php7.4-apache >/dev/null 2>&1; then image="wordpress:php7.4-apache"; fi
out="$(docker run --rm -v "$dir:/src:ro" "$image" sh -c 'find /src -name "*.php" -exec php -l {} \;' 2>&1 || true)"
php_label="PHP 7.4 syntax ($image)"
elif command -v php >/dev/null 2>&1; then
out="$(find "$dir" -name '*.php' -exec php -l {} \; 2>&1 || true)"
php_label="PHP syntax ($(php -r 'echo PHP_VERSION;'); not 7.4)"
else
out=""
php_label=""
warn "$label: no PHP to lint with"
fi
if [ -n "$php_label" ]; then
# Count the files php -l passed, so a lint that never ran cannot pass.
local passed problems
passed="$(printf '%s\n' "$out" | grep -c '^No syntax errors' || true)"
problems="$(printf '%s\n' "$out" | grep -v '^No syntax errors' | grep -v '^[[:space:]]*$' || true)"
if [ -z "$problems" ] && [ "$passed" -eq "$expected" ] && [ "$expected" -gt 0 ]; then
ok "$label: $php_label, $passed files"
elif [ -z "$problems" ]; then
err "$label: PHP lint checked $passed of $expected files"
else
err "$label: $php_label errors:"
printf '%s\n' "$problems" | sed 's/^/ /'
fi
fi
if command -v node >/dev/null 2>&1; then
out="$(find "$dir" -name '*.js' -exec node --check {} \; 2>&1 || true)"
if [ -z "$out" ]; then ok "$label: JS syntax"; else err "$label: JS syntax errors:"; printf '%s\n' "$out" | sed 's/^/ /'; fi
else
warn "$label: node not found, JS not checked"
fi
return 0
}
# Shared checks for a release zip; prints its unpacked folder in UNPACKED.
check_zip() {
local zip_path="$1"
local label="$2"
local dir="$TMP_DIR/unpacked-$label"
local list tops dev size
list="$(unzip -Z1 "$zip_path")"
tops="$(printf '%s\n' "$list" | cut -d/ -f1 | sort -u | tr '\n' ' ')"
if [ "$tops" = "$SLUG " ]; then ok "$label: one $SLUG/ folder"; else err "$label: top level is '$tops', must be only $SLUG/"; fi
if printf '%s\n' "$list" | grep -qx "$SLUG/$MAIN_FILE"; then ok "$label: $SLUG/$MAIN_FILE present"; else err "$label: $SLUG/$MAIN_FILE missing"; fi
dev="$(printf '%s\n' "$list" | grep -E "$DEV_FILES" || true)"
if [ -z "$dev" ]; then ok "$label: no development files"; else err "$label: development files: $(printf '%s' "$dev" | tr '\n' ' ')"; fi
size="$(wc -c <"$zip_path" | tr -d ' ')"
note "$label: $(basename "$zip_path"), $((size / 1024)) KB, $(printf '%s\n' "$list" | grep -cv '/$') files"
mkdir -p "$dir"
unzip -q "$zip_path" -d "$dir"
check_syntax "$dir" "$label"
UNPACKED="$dir/$SLUG"
return 0
}
check_builds() {
local github_zip="$1"
local wporg_zip="$2"
local version="$3"
section "Release zips"
if [ "$(basename "$github_zip")" = "$SLUG-$version.zip" ]; then
ok "GitHub asset name $SLUG-$version.zip (sites pick assets starting with $SLUG)"
else
err "GitHub asset is $(basename "$github_zip"), expected $SLUG-$version.zip"
fi
case "$(basename "$wporg_zip")" in
"$SLUG"*) err "WordPress.org zip name starts with $SLUG; sites could install it from GitHub" ;;
*) ok "WordPress.org zip name does not start with $SLUG" ;;
esac
UNPACKED=""
check_zip "$github_zip" "github"
local github_dir="$UNPACKED" path
if [ -z "$UPDATER_FILES" ]; then
note "no .distignore-wporg: both builds hold the same files"
fi
for path in $UPDATER_FILES; do
if [ -e "$github_dir/$path" ]; then ok "github: has $path"; else err "github: $path missing (listed in .distignore-wporg)"; fi
done
if grep -Eq "^[[:space:]*]*GitHub Plugin URI:" "$github_dir/$MAIN_FILE"; then ok "github: has the GitHub Plugin URI header"; else err "github: no GitHub Plugin URI header, sites cannot update it from GitHub"; fi
check_zip "$wporg_zip" "wporg"
local wporg_dir="$UNPACKED"
for path in $UPDATER_FILES; do
if [ -e "$wporg_dir/$path" ]; then err "wporg: $path must not be in the WordPress.org build"; else ok "wporg: no $path"; fi
done
if grep -Eq "^[[:space:]*]*($UPDATER_HEADERS):" "$wporg_dir/$MAIN_FILE"; then err "wporg: GitHub updater header lines still in $MAIN_FILE"; else ok "wporg: no GitHub updater header lines"; fi
local hits
hits="$(grep -rEl --include='*.php' --include='*.js' 'gu_override_dot_org|api\.github\.com/repos|Plugin_Upgrader|Theme_Upgrader|site_transient_update_plugins|auto_update_(plugin|theme)' "$wporg_dir" 2>/dev/null | sed "s|^$wporg_dir/||" || true)"
if [ -z "$hits" ]; then
ok "wporg: no code that installs or updates plugins from elsewhere"
else
warn "wporg: check these install or update code (guideline 8 allows only WordPress.org sources): $(printf '%s' "$hits" | tr '\n' ' ')"
fi
section "Remote assets and services (WordPress.org build)"
hits="$(grep -rEn "(wp_(enqueue|register)_(script|style)|<script|<link)[^;]*['\"](https?:)?//" "$wporg_dir" --include='*.php' 2>/dev/null | sed "s|^$wporg_dir/||" | cut -c1-160 || true)"
if [ -z "$hits" ]; then ok "no scripts or styles loaded from other sites"; else warn "scripts or styles from other sites (guideline 8: ship them in the plugin unless they are part of a service):"; printf '%s\n' "$hits" | sed 's/^/ /'; fi
local readme_text hosts host missing=""
readme_text="$(cat "$wporg_dir/readme.txt")"
hosts="$(grep -rEoh 'https?://[A-Za-z0-9.-]+\.[a-z]{2,}' "$wporg_dir/includes" "$wporg_dir/blocks" 2>/dev/null | sed -E 's|https?://||; s|^www\.||' | sort -u || true)"
for host in $hosts; do
case "$host" in
w3.org | gnu.org | wordpress.org | *.wordpress.org | w.org | *.w.org | wp.org | *.wp.org | example.com | *.example.com) continue ;;
esac
if ! printf '%s' "$readme_text" | grep -Fqi "$host"; then missing="$missing $host"; fi
done
if [ -z "$missing" ]; then
ok "every host in includes/ and blocks/ is named in readme.txt"
else
note "hosts in code not named in readme.txt (fine if they are only links; services the plugin contacts need an External services entry):$missing"
fi
# Checks for parts only some plugins have; each runs when its files exist.
# A plugin's own checks: scripts/preflight-plugin.sh defines plugin_preflight,
# which gets the unpacked GitHub build and can use section, ok, warn and err.
if [ -f "$SCRIPT_DIR/preflight-plugin.sh" ]; then
# shellcheck source=/dev/null
. "$SCRIPT_DIR/preflight-plugin.sh"
plugin_preflight "$github_dir"
fi
if [ -f "$SCRIPT_DIR/replaced-plugins.php" ]; then
check_replaced_count "$github_dir"
fi
return 0
}
# README.md's "<Plugin Name> replaces **N plugins**" line matches the
# 'replaces' entries in the code (scripts/replaced-plugins.php).
check_replaced_count() {
local dir="$1"
section "Replaced plugins"
if ! command -v php >/dev/null 2>&1; then
warn "php not found, the replaced plugins count in README.md was not checked"
return 0
fi
local out
if out="$(php "$SCRIPT_DIR/replaced-plugins.php" --check "$dir" 2>&1)"; then
ok "$out"
else
err "$out"
fi
return 0
}
# Core files match the starter's (scripts/sync-core.sh --check), when a
# checkout of the starter is at hand. A warning, not an error: the starter's
# checkout may be behind, and only a person can tell which side is right.
check_core_files() {
section "Core files"
local out status=0
out="$(bash "$SCRIPT_DIR/sync-core.sh" --check 2>&1)" || status=$?
case "$status" in
0) ok "$(printf '%s\n' "$out" | tail -n 1)" ;;
1) warn "$(printf '%s\n' "$out" | sed -n '2,$p' | tr '\n' ' ')" ;;
*) note "not compared: ${out#sync-core: }" ;;
esac
return 0
}
check_git() {
local ref="$1"
local sha="$2"
local version="$3"
section "Git"
local tag_sha
if tag_sha="$(git rev-parse --verify --quiet "refs/tags/v$version^{commit}")"; then
if [ "$tag_sha" = "$sha" ]; then ok "tag v$version is this commit"; else warn "tag v$version exists on another commit (${tag_sha:0:12}); bump the version"; fi
else
note "no tag v$version yet"
fi
local main_sha
if main_sha="$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}")"; then
if git merge-base --is-ancestor "$sha" "$main_sha"; then ok "$ref is on origin/main"; else note "$ref is not on origin/main yet; release only from main"; fi
fi
if [ "$ref" = "HEAD" ] && [ -n "$(git status --porcelain --untracked-files=no)" ]; then
note "uncommitted changes are not checked (the build comes from HEAD)"
fi
return 0
}
main() {
local ref="HEAD"
local strict=0
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--offline) OFFLINE=1 ;;
--strict) strict=1 ;;
--no-docker) USE_DOCKER=0 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local root sha
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
sha="$(git rev-parse --verify --quiet "$ref^{commit}")" || die "not a commit: $ref"
plugin_identity "$sha" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
MAIN_FILE="$PLUGIN_MAIN_FILE"
VERSION_CONSTANT="${PLUGIN_CONST}_VERSION"
UPDATER_FILES="$(plugin_wporg_only "$sha")"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-preflight.XXXXXX")"
local main_php readme plugin_header
main_php="$(git show "$sha:$MAIN_FILE")"
readme="$(git show "$sha:readme.txt")" || die "readme.txt missing at $ref"
if git cat-file -e "$sha:changelog.txt" 2>/dev/null; then
CHANGELOG_TXT="$(git show "$sha:changelog.txt")"
fi
plugin_header="$(printf '%s\n' "$main_php" | sed -n '1,/\*\//p')"
printf '%s preflight: %s (%s)\n' "$PLUGIN_NAME" "$ref" "${sha:0:12}"
VERSION=""
check_versions "$plugin_header" "$readme" "$main_php"
check_readme "$readme" "$plugin_header" "$VERSION"
check_wporg "$readme" "$plugin_header"
local zips github_zip wporg_zip
zips="$("$root/scripts/build-release.sh" --ref "$sha" --out "$TMP_DIR/dist" --quiet)" || die "build failed"
github_zip="$(printf '%s\n' "$zips" | sed -n 1p)"
wporg_zip="$(printf '%s\n' "$zips" | sed -n 2p)"
check_builds "$github_zip" "$wporg_zip" "$VERSION"
if [ -f "$SCRIPT_DIR/sync-core.sh" ]; then
check_core_files
fi
check_git "$ref" "$sha" "$VERSION"
printf '\n%s error(s), %s warning(s).\n' "$ERRORS" "$WARNINGS"
if [ "$ERRORS" -gt 0 ]; then
printf 'Not ready: fix the errors.\n'
return 1
fi
if [ "$strict" -eq 1 ] && [ "$WARNINGS" -gt 0 ]; then
printf 'Not ready (--strict): resolve or accept the warnings.\n'
return 1
fi
printf 'Next: scripts/plugin-check.sh (Plugin Check on both zips).\n'
return 0
}
main "$@"
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env bash
# Copy a combined preview of the plugin to the shared local test site:
# origin/main plus every open pull request from this repository, merged in
# PR order. Branches that conflict are left out and reported, except when
# the only conflicts are in the changelogs (DOC_FILES): every PR adds lines
# at the top of the same lists, so after each merge to main the open PRs
# conflict there. For the preview those files keep both sides' lines (git's
# union merge) and the PR is included, with a note; real merges are not
# affected.
#
# Nothing is checked out, committed to a branch or pushed: merges are worked
# out with `git merge-tree` and exported with `git archive`, so any worktree
# can run it, and every run includes everyone's pushed work. The copy holds
# what a release build contains (.distignore applied). A checkout whose copy
# of this script differs from origin/main's runs origin/main's, so an old
# worktree cannot leave PRs out (<PREFIX>_PREVIEW_OWN=1 runs its own; the
# prefix is the plugin's constant prefix, such as WPSTARTER).
#
# Usage: scripts/preview-site.sh [--dry-run] [<site>]
# <site> WordPress folder of the test site (the one with wp-load.php).
# Remembered for every worktree of this clone after first use;
# <PREFIX>_PREVIEW_SITE overrides it.
# --dry-run Report what would be included; copy nothing.
#
# Needs git 2.38+ (merge-tree --write-tree), gh (signed in) and rsync.
set -euo pipefail
readonly LOCK_WAIT_SECONDS=180
readonly LOCK_STALE_MINUTES=10
# Files whose conflicts do not keep a PR out of the preview.
readonly DOC_FILES="changelog.txt readme.txt README.md"
LOCK_DIR=""
TMP_DIR=""
UNION_ATTRIBUTES=""
# Set in main() from the plugin's main file (scripts/lib/plugin.sh).
STAMP_NAME=""
# Results, set by the functions below (Bash 3.2 has no namerefs).
SITE=""
MAIN_SHA=""
PREVIEW_COMMIT=""
INCLUDED=""
SKIPPED=""
die() {
local message="$1"
printf 'preview-site: %s\n' "$message" >&2
exit 1
}
usage() {
sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
# Source scripts/lib/plugin.sh: the copy beside this script, or origin/main's
# when this runs as origin/main's copy from a temporary file and an older
# checkout started it (run_latest) without the library beside it.
load_plugin_lib() {
local dir lib
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -r "$dir/lib/plugin.sh" ]; then
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$dir/lib/plugin.sh"
return 0
fi
lib="$(git show origin/main:scripts/lib/plugin.sh)" || die "scripts/lib/plugin.sh not found beside the script or on origin/main"
eval "$lib"
return 0
}
cleanup() {
if [ -n "$LOCK_DIR" ] && [ -f "$LOCK_DIR/pid" ] && [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ]; then
rm -rf "$LOCK_DIR"
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
# One run at a time across all worktrees; a lock left by a dead run is taken over.
acquire_lock() {
local lock="$1"
local waited=0
local owner=""
while ! mkdir "$lock" 2>/dev/null; do
owner="$(cat "$lock/pid" 2>/dev/null || true)"
if { [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; } || [ -n "$(find "$lock" -maxdepth 0 -mmin +"$LOCK_STALE_MINUTES" 2>/dev/null)" ]; then
rm -rf "$lock"
continue
fi
if [ "$waited" -ge "$LOCK_WAIT_SECONDS" ]; then
die "another preview run is still going (lock: $lock)"
fi
[ "$waited" -eq 0 ] && printf 'Waiting for another preview run to finish...\n'
sleep 3
waited=$((waited + 3))
done
LOCK_DIR="$lock"
printf '%s\n' "$$" >"$lock/pid"
return 0
}
# owner/repo of the GitHub remote "origin".
origin_slug() {
local url
url="$(git remote get-url origin)"
url="${url%.git}"
case "$url" in
*github.com[:/]*) printf '%s\n' "${url#*github.com[:/]}" ;;
*) die "origin is not a GitHub remote: $url" ;;
esac
return 0
}
# Sets SITE: the argument, then the environment, then the one remembered for
# this clone. A new argument is remembered unless this is a dry run.
resolve_site() {
local site_arg="$1"
local saved="$2"
local remember="$3"
SITE="$(plugin_env PREVIEW_SITE)"
[ -n "$site_arg" ] && SITE="$site_arg"
if [ -z "$SITE" ] && [ -f "$saved" ]; then
SITE="$(cat "$saved")"
fi
[ -n "$SITE" ] || die "which site? Run: scripts/preview-site.sh \"<WordPress folder>\" (remembered after that)"
SITE="${SITE%/}"
if [ ! -f "$SITE/wp-load.php" ] || [ ! -d "$SITE/wp-content/plugins" ]; then
die "not a WordPress folder: $SITE"
fi
if [ -n "$site_arg" ] && [ "$remember" -eq 1 ]; then
printf '%s\n' "$SITE" >"$saved"
fi
return 0
}
# Open same-repository PRs as "number<TAB>branch<TAB>draft|ready<TAB>title", oldest first.
open_prs() {
local slug
slug="$(origin_slug)"
gh pr list --repo "$slug" --state open --limit 100 \
--json number,headRefName,isDraft,isCrossRepository,title \
--jq 'sort_by(.number) | .[] | select(.isCrossRepository | not) | [.number, .headRefName, (if .isDraft then "draft" else "ready" end), .title] | @tsv' ||
return 1
return 0
}
# Merge one PR branch onto PREVIEW_COMMIT, or record why it is left out.
merge_pr() {
local num="$1"
local ref="$2"
local state="$3"
local title="$4"
local sha out tree files
local note=""
local rc=0
if ! sha="$(git rev-parse --verify --quiet "refs/remotes/origin/$ref^{commit}")"; then
SKIPPED="${SKIPPED} #$num $ref: branch not found on origin
"
return 0
fi
if git merge-base --is-ancestor "$sha" "$PREVIEW_COMMIT"; then
INCLUDED="${INCLUDED} #$num $ref ${sha:0:7} ($state, nothing new) $title
"
return 0
fi
out="$(git merge-tree --write-tree --name-only --no-messages "$PREVIEW_COMMIT" "$sha")" || rc=$?
if [ "$rc" -eq 1 ]; then
files="$(printf '%s\n' "$out" | sed 1d | sort -u | tr '\n' ' ')"
files="${files% }"
if only_doc_files "$files"; then
rc=0
out="$(git -c core.attributesFile="$UNION_ATTRIBUTES" merge-tree --write-tree --name-only --no-messages "$PREVIEW_COMMIT" "$sha")" || rc=$?
[ "$rc" -eq 0 ] && note="; both sides' lines kept in $files"
fi
fi
if [ "$rc" -eq 0 ]; then
tree="${out%%$'\n'*}"
PREVIEW_COMMIT="$(git -c user.name=preview -c user.email=preview@localhost commit-tree "$tree" -p "$PREVIEW_COMMIT" -p "$sha" -m "preview: merge #$num $ref")"
INCLUDED="${INCLUDED} #$num $ref ${sha:0:7} ($state$note) $title
"
elif [ "$rc" -eq 1 ]; then
SKIPPED="${SKIPPED} #$num $ref ${sha:0:7}: conflicts with main or an earlier PR in $files
"
else
die "git merge-tree failed for #$num $ref"
fi
return 0
}
# Whether every file in the space-separated list is one of DOC_FILES.
only_doc_files() {
local files="$1"
local file
[ -n "$files" ] || return 1
for file in $files; do
case " $DOC_FILES " in
*" $file "*) ;;
*) return 1 ;;
esac
done
return 0
}
# Write the attributes file that makes DOC_FILES merge as a union.
write_union_attributes() {
local file
: >"$UNION_ATTRIBUTES"
for file in $DOC_FILES; do
printf '/%s merge=union\n' "$file" >>"$UNION_ATTRIBUTES"
done
return 0
}
# Sets MAIN_SHA, PREVIEW_COMMIT, INCLUDED and SKIPPED.
build_preview() {
local prs num ref state title
prs="$(open_prs)" ||
die "could not list open pull requests with gh; nothing copied (a copy without them would hide work in progress)"
MAIN_SHA="$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
PREVIEW_COMMIT="$MAIN_SHA"
while IFS=$'\t' read -r num ref state title; do
[ -n "$num" ] || continue
merge_pr "$num" "$ref" "$state" "$title"
done <<EOF
$prs
EOF
return 0
}
# A note when the branch this runs from is not in the preview as it is here.
branch_note() {
local current="$1"
local merged changed
if [ -z "$current" ] || [ "$current" = "main" ]; then
return 0
fi
# Nothing to add: its work is already in the preview, through main (after
# a squash merge its commits are not ancestors of main) or its open PR.
# Changelog lines can sit in another order there (other PRs' lines are
# kept beside them), so differences only in DOC_FILES do not count.
if merged="$(git -c core.attributesFile="$UNION_ATTRIBUTES" merge-tree --write-tree --no-messages "$PREVIEW_COMMIT" HEAD 2>/dev/null)"; then
changed="$(git diff --name-only "$PREVIEW_COMMIT" "$merged" | tr '\n' ' ')"
changed="${changed% }"
if [ -z "$changed" ] || only_doc_files "$changed"; then
return 0
fi
fi
if ! printf '%s' "$INCLUDED" | grep -q " $current "; then
printf 'Your branch %s is not in the preview: push it and open a draft PR, or see skipped above.\n' "$current"
elif [ "$(git rev-parse HEAD)" != "$(git rev-parse --verify --quiet "refs/remotes/origin/$current" || true)" ]; then
printf 'Your branch %s is in the preview as pushed; local commits not pushed are not.\n' "$current"
fi
return 0
}
# The stamp text; it ends with a newline.
report() {
local root="$1"
local current="$2"
printf 'Combined preview: origin/main plus open pull requests, made by scripts/preview-site.sh.\n'
printf 'time: %s\n' "$(date '+%Y-%m-%d %H:%M %Z')"
printf 'run from: %s (%s)\n' "$root" "${current:-detached}"
printf 'main: %s\n' "$(git log -1 --format='%h %s' "$MAIN_SHA")"
printf 'preview commit: %s (local only)\n' "${PREVIEW_COMMIT:0:12}"
printf 'included:\n%s' "${INCLUDED:- none
}"
printf 'skipped:\n%s' "${SKIPPED:- none
}"
return 0
}
# Copy PREVIEW_COMMIT to the site as a release build would contain it.
copy_to_site() {
local destination="$SITE/wp-content/plugins/$PLUGIN_SLUG"
[ -n "$PLUGIN_SLUG" ] || die "no plugin slug"
mkdir "$TMP_DIR/plugin"
git archive --format=tar "$PREVIEW_COMMIT" | tar -x -C "$TMP_DIR/plugin"
git show "$PREVIEW_COMMIT:.distignore" >"$TMP_DIR/distignore"
mkdir -p "$destination"
rsync -a --delete --delete-excluded --exclude-from="$TMP_DIR/distignore" "$TMP_DIR/plugin/" "$destination/"
return 0
}
# Run origin/main's copy of this script when this checkout's differs, so a
# worktree made before a fix to the script cannot leave open PRs out of the
# shared preview. <PREFIX>_PREVIEW_OWN=1 runs this copy instead (to try a
# change to the script itself). The copy goes in a temporary folder with
# origin/main's scripts/lib/plugin.sh beside it.
run_latest() {
local dry_run="$1"
local site_arg="$2"
local root="$3"
local latest_var="${PLUGIN_CONST}_PREVIEW_LATEST"
local latest
latest="$(plugin_env PREVIEW_LATEST)"
if [ -n "$latest" ]; then
# Already main's copy: bash has the file open and the library is
# loaded, so the folder can go now.
rm -rf "$latest"
return 0
fi
if [ "$(plugin_env PREVIEW_OWN)" = "1" ]; then
return 0
fi
git fetch --quiet --prune origin || return 0
latest="$(mktemp -d "${TMPDIR:-/tmp}/$PLUGIN_SLUG-preview-latest.XXXXXX")"
mkdir -p "$latest/lib"
if ! git show origin/main:scripts/preview-site.sh >"$latest/preview-site.sh" 2>/dev/null ||
! git show origin/main:scripts/lib/plugin.sh >"$latest/lib/plugin.sh" 2>/dev/null ||
{ cmp -s "$latest/preview-site.sh" "$root/scripts/preview-site.sh" && cmp -s "$latest/lib/plugin.sh" "$root/scripts/lib/plugin.sh"; }; then
rm -rf "$latest"
return 0
fi
printf 'preview-site: this checkout'\''s copy of the script differs from origin/main; running origin/main'\''s.\n' >&2
if [ "$dry_run" -eq 1 ]; then
exec env "$latest_var=$latest" bash "$latest/preview-site.sh" --dry-run ${site_arg:+"$site_arg"}
fi
exec env "$latest_var=$latest" bash "$latest/preview-site.sh" ${site_arg:+"$site_arg"}
}
main() {
local dry_run=0
local site_arg=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--dry-run) dry_run=1 ;;
-h | --help)
usage
return 0
;;
-*) die "unknown option: $arg" ;;
*) site_arg="$arg" ;;
esac
shift
done
local root common current text note
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
common="$(git rev-parse --path-format=absolute --git-common-dir)"
cd "$root"
load_plugin_lib
plugin_identity HEAD || die "cannot tell which plugin this is"
STAMP_NAME="$PLUGIN_SLUG-synced-from.txt"
run_latest "$dry_run" "$site_arg" "$root"
resolve_site "$site_arg" "$common/$PLUGIN_SLUG-preview-site" "$((1 - dry_run))"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$PLUGIN_SLUG-preview.XXXXXX")"
UNION_ATTRIBUTES="$TMP_DIR/union-attributes"
write_union_attributes
if [ "$dry_run" -eq 0 ]; then
acquire_lock "$common/$PLUGIN_SLUG-preview.lock"
fi
git fetch --quiet --prune origin
build_preview
current="$(git symbolic-ref --short -q HEAD || true)"
text="$(report "$root" "$current")"
note="$(branch_note "$current")"
if [ "$dry_run" -eq 0 ]; then
copy_to_site
printf '%s\n' "$text" >"$SITE/wp-content/$STAMP_NAME"
fi
printf '%s\n' "$text"
[ -n "$note" ] && printf '\n%s\n' "$note"
if [ "$dry_run" -eq 1 ]; then
printf '\nDry run: nothing copied to %s\n' "$SITE"
else
printf '\nCopied to %s/wp-content/plugins/%s\n' "$SITE" "$PLUGIN_SLUG"
fi
return 0
}
main "$@"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Give this plugin new names: run it once in a fresh copy of the starter to
# start a new plugin. Renames every tracked file's contents and paths, then
# leaves the changes for you to review and commit.
#
# Usage: scripts/rename-plugin.sh --slug SLUG --name NAME --prefix Prefix
# [--const PREFIX] [--css CSS] [--repo OWNER/REPO]
# --slug Plugin folder, main file and text domain (my-plugin).
# --name Plugin Name (My Plugin).
# --prefix Class prefix (MyPlugin: MyPlugin_Settings).
# --const Constant prefix (default: --prefix in capitals, MYPLUGIN). In
# lower case it is the option, hook and function prefix (myplugin).
# --css CSS class and data attribute prefix (default: the lower-case
# prefix; a short one such as mp keeps the markup readable).
# --repo GitHub repository, owner/repo (default: wpallstars/SLUG).
#
# Needs a clean working tree. Then update README.md, readme.txt,
# changelog.txt, AGENTS.md and the banner (STANDARDS.md and DEVELOPMENT.md say how).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
die() {
local message="$1"
printf 'rename-plugin: %s\n' "$message" >&2
exit 2
}
TMP_FILE=""
cleanup() {
[ -z "$TMP_FILE" ] || rm -f "$TMP_FILE"
return 0
}
usage() {
sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
need() {
local flag="$1"
local value="$2"
local pattern="$3"
[ -n "$value" ] || die "$flag is needed (see --help)"
printf '%s' "$value" | grep -Eq "$pattern" || die "$flag '$value' must match $pattern"
return 0
}
main() {
local slug="" name="" prefix="" const="" css="" repo=""
while [ $# -gt 0 ]; do
local arg="$1"
local value="${2:-}"
case "$arg" in
--slug | --name | --prefix | --const | --css | --repo)
[ $# -ge 2 ] || die "$arg needs a value"
case "$arg" in
--slug) slug="$value" ;;
--name) name="$value" ;;
--prefix) prefix="$value" ;;
--const) const="$value" ;;
--css) css="$value" ;;
--repo) repo="$value" ;;
esac
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
[ -n "$const" ] || const="$(printf '%s' "$prefix" | tr '[:lower:]' '[:upper:]')"
[ -n "$css" ] || css="$(printf '%s' "$const" | tr '[:upper:]' '[:lower:]')"
[ -n "$repo" ] || repo="wpallstars/$slug"
need --slug "$slug" '^[a-z][a-z0-9-]*[a-z0-9]$'
need --name "$name" '^[^/\\]+$'
need --prefix "$prefix" '^[A-Z][A-Za-z0-9]*$'
need --const "$const" '^[A-Z][A-Z0-9]*$'
need --css "$css" '^[a-z][a-z0-9]*$'
need --repo "$repo" '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
[ -z "$(git status --porcelain)" ] || die "commit or put away your changes first"
plugin_identity HEAD || die "cannot tell which plugin this is"
plugin_names_as FROM
export TO_SLUG="$slug" TO_NAME="$name" TO_PACKAGE="$prefix" TO_CONST="$const"
export TO_PREFIX
TO_PREFIX="$(printf '%s' "$const" | tr '[:upper:]' '[:lower:]')"
export TO_CSS="$css" TO_REPO="$repo"
[ "$TO_SLUG" != "$FROM_SLUG" ] || die "the slug is already $slug"
printf 'Renaming %s to %s: %s / %s / %s / %s / %s / %s\n' "$FROM_NAME" "$name" "$slug" "$TO_PREFIX" "$prefix" "$const" "$css" "$repo"
local file target changed=0 moved=0
trap cleanup EXIT
TMP_FILE="$(mktemp "${TMPDIR:-/tmp}/rename-plugin.XXXXXX")"
local tmp="$TMP_FILE"
while IFS= read -r file; do
[ -f "$file" ] || continue
# Text files only (pictures and other binaries keep their bytes).
if grep -Iq . "$file"; then
plugin_map <"$file" >"$tmp"
if ! cmp -s "$tmp" "$file"; then
cat "$tmp" >"$file"
changed=$((changed + 1))
fi
fi
target="$(printf '%s' "$file" | plugin_map)"
if [ "$target" != "$file" ]; then
mkdir -p "$(dirname "$target")"
git mv "$file" "$target"
moved=$((moved + 1))
fi
done < <(git ls-files)
printf '%d files changed, %d renamed. Run composer update --lock (the package name changed), review with git diff and git status, then commit.\n' "$changed" "$moved"
return 0
}
main "$@"
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* Count the plugins WP Plugin Starter replaces and keep README.md's count right.
*
* Reads every 'replaces' entry in includes/ (the same lists the Plugins screen
* uses), without loading WordPress: array literals, class constants
* (self::X, Class::X), variables assigned in the same file and + unions.
* Anything else stops the script, so it never counts wrong in silence.
*
* Usage: php scripts/replaced-plugins.php [--check | --write] [DIR]
* (none) List the replaced plugins (slug and name) and the count.
* --check Exit 1 when README.md's "WP Plugin Starter replaces **N plugins**"
* line has another count. Offline; scripts/preflight-release.sh
* runs it on the release build.
* --write Rewrite that line with the count and download sizes: each
* plugin's zip from WordPress.org (or its GitHub release, see
* GITHUB_SOURCES) against WP Plugin Starter's GitHub release zip,
* built from HEAD with scripts/build-release.sh. Needs network.
* DIR Plugin folder to read (default: this checkout).
*
* Exit status: 0 when fine, 1 when --check finds a different count, 2 on errors.
*/
// Replaced plugins that are not on WordPress.org but have GitHub releases.
const GITHUB_SOURCES = array('git-updater' => 'afragen/git-updater');
const README_LINE = '/^WP Plugin Starter replaces \*\*(\d+) plugins\*\*.*$/m';
/**
* Stop with a message.
*
* @param string $message What went wrong.
*/
function fail($message) {
fwrite(STDERR, "replaced-plugins: $message\n");
exit(2);
}
/**
* Tokens of a PHP file without whitespace and comments, as [id, text, line].
*
* @param string $file Path.
* @return array
*/
function tokens($file) {
$out = array();
$line = 1;
foreach (token_get_all(file_get_contents($file)) as $token) {
if (is_array($token)) {
$line = $token[2];
if (in_array($token[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) {
continue;
}
$out[] = array($token[0], $token[1], $line);
} else {
$out[] = array($token, $token, $line);
}
}
return $out;
}
/**
* Small evaluator for the expressions used in 'replaces'.
*/
final class Replaces_Reader {
/** @var array "Class::NAME" => array(file, tokens, start, class). */
private $consts = array();
/** @var array Slug => name. */
public $plugins = array();
/** @var array Slug => list of "file:line". */
public $where = array();
/** @var array Current file's tokens. */
private $t;
/** @var int Current position. */
private $i;
/** @var string Current file. */
private $file;
/** @var string Current class. */
private $class;
/** @var array Variable name => position of its value in $t. */
private $vars;
/**
* Read every PHP file under $dir/includes.
*
* @param string $dir Plugin folder.
*/
public function read($dir) {
$files = array();
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir . '/includes', FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if ('php' === $f->getExtension()) {
$files[] = $f->getPathname();
}
}
sort($files);
$parsed = array();
foreach ($files as $file) {
$parsed[$file] = tokens($file);
$this->collect_consts($file, $parsed[$file]);
}
foreach ($parsed as $file => $t) {
$this->collect_replaces($file, $t, $dir);
}
ksort($this->plugins);
}
private function collect_consts($file, array $t) {
$class = '';
foreach ($t as $i => $tok) {
if (T_CLASS === $tok[0] && isset($t[$i + 1]) && T_STRING === $t[$i + 1][0]) {
$class = $t[$i + 1][1];
}
if (T_CONST === $tok[0] && '' !== $class && isset($t[$i + 2]) && '=' === $t[$i + 2][0]) {
$this->consts[$class . '::' . $t[$i + 1][1]] = array($file, $t, $i + 3, $class);
}
}
}
private function collect_replaces($file, array $t, $dir) {
$this->file = $file;
$this->t = $t;
$this->class = '';
$this->vars = array();
$n = count($t);
for ($i = 0; $i < $n; $i++) {
$tok = $t[$i];
if (T_CLASS === $tok[0] && isset($t[$i + 1]) && T_STRING === $t[$i + 1][0]) {
$this->class = $t[$i + 1][1];
} elseif (T_VARIABLE === $tok[0] && isset($t[$i + 1]) && '=' === $t[$i + 1][0]) {
$this->vars[$tok[1]] = $i + 2;
} elseif (T_CONSTANT_ENCAPSED_STRING === $tok[0] && "'replaces'" === $tok[1] && isset($t[$i + 1]) && T_DOUBLE_ARROW === $t[$i + 1][0]) {
$this->i = $i + 2;
$value = $this->expr();
if (!is_array($value)) {
$this->error('replaces is not an array');
}
foreach ($value as $slug => $name) {
if (!is_string($name)) {
$this->error("replaces[$slug] is not a name");
}
if (!isset($this->plugins[$slug])) {
$this->plugins[$slug] = $name;
}
$this->where[$slug][] = substr($file, strlen($dir) + 1) . ':' . $tok[2];
}
}
}
}
private function error($message) {
$line = isset($this->t[$this->i]) ? $this->t[$this->i][2] : '?';
fail("{$this->file}:$line: $message");
}
private function peek() {
return isset($this->t[$this->i]) ? $this->t[$this->i] : array(null, '', 0);
}
private function expect($id) {
$tok = $this->peek();
if ($tok[0] !== $id) {
$this->error("expected $id, found '{$tok[1]}'");
}
$this->i++;
}
/** expr := term ('+' term)* */
private function expr() {
$value = $this->term();
while ('+' === $this->peek()[0]) {
$this->i++;
$right = $this->term();
if (!is_array($value) || !is_array($right)) {
$this->error('+ on something that is not an array');
}
$value = $value + $right;
}
return $value;
}
private function term() {
$tok = $this->peek();
if (T_ARRAY === $tok[0]) {
$this->i++;
$this->expect('(');
return $this->elements(')');
}
if ('[' === $tok[0]) {
$this->i++;
return $this->elements(']');
}
if (T_CONSTANT_ENCAPSED_STRING === $tok[0]) {
$this->i++;
return $this->string($tok[1]);
}
if (T_STRING === $tok[0] && isset($this->t[$this->i + 1]) && T_DOUBLE_COLON === $this->t[$this->i + 1][0]) {
$class = in_array(strtolower($tok[1]), array('self', 'static'), true) ? $this->class : $tok[1];
$name = $this->t[$this->i + 2][1];
$this->i += 3;
return $this->constant($class . '::' . $name);
}
if (T_STATIC === $tok[0] && isset($this->t[$this->i + 1]) && T_DOUBLE_COLON === $this->t[$this->i + 1][0]) {
$name = $this->t[$this->i + 2][1];
$this->i += 3;
return $this->constant($this->class . '::' . $name);
}
if (T_VARIABLE === $tok[0]) {
if (!isset($this->vars[$tok[1]])) {
$this->error("{$tok[1]} is not assigned before it is used");
}
$back = $this->i + 1;
$this->i = $this->vars[$tok[1]];
$value = $this->expr();
$this->i = $back;
return $value;
}
$this->error("cannot read '{$tok[1]}'");
}
private function elements($close) {
$out = array();
while ($close !== $this->peek()[0]) {
$key = $this->expr();
$this->expect(T_DOUBLE_ARROW);
$out[$key] = $this->expr();
if (',' === $this->peek()[0]) {
$this->i++;
} elseif ($close !== $this->peek()[0]) {
$this->error("expected , or $close");
}
}
$this->i++;
return $out;
}
private function string($text) {
if ("'" !== $text[0]) {
$this->error("only single-quoted strings are read: $text");
}
return strtr(substr($text, 1, -1), array("\\'" => "'", '\\\\' => '\\'));
}
private function constant($key) {
if (!isset($this->consts[$key])) {
$this->error("unknown constant $key");
}
list($file, $t, $start, $class) = $this->consts[$key];
$saved = array($this->file, $this->t, $this->i, $this->class);
list($this->file, $this->t, $this->i, $this->class) = array($file, $t, $start, $class);
$value = $this->expr();
list($this->file, $this->t, $this->i, $this->class) = $saved;
return $value;
}
}
/**
* GET a URL; the body, or null on a network error.
*
* @param string $url URL.
* @param bool $head Only the headers (returns the Content-Length).
* @return string|int|null
*/
function fetch($url, $head = false) {
$args = array('-sSL', '-m', '30', '-A', 'wpstarter-replaced-plugins');
if ($head) {
$args = array_merge($args, array('-o', '/dev/null', '-w', '%{http_code} %{size_download}'));
}
$cmd = 'curl ' . implode(' ', array_map('escapeshellarg', $args)) . ' ' . escapeshellarg($url);
exec($cmd, $lines, $status);
if (0 !== $status) {
return null;
}
$out = implode("\n", $lines);
if ($head) {
list($code, $bytes) = explode(' ', $out) + array('', '0');
return '200' === $code ? (int) $bytes : null;
}
return $out;
}
/**
* Size of a plugin's current zip: WordPress.org first, then GITHUB_SOURCES.
*
* @param string $slug Plugin folder.
* @return int|null Bytes, or null when it cannot be downloaded.
*/
function zip_size($slug) {
$info = fetch('https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=' . rawurlencode($slug) . '&request%5Bfields%5D%5Bsections%5D=0');
if (null === $info) {
fail("could not reach WordPress.org for $slug");
}
$data = json_decode($info, true);
if (is_array($data) && !empty($data['download_link'])) {
$bytes = fetch($data['download_link'], true);
if (null === $bytes) {
fail("could not download {$data['download_link']}");
}
return $bytes;
}
if (isset(GITHUB_SOURCES[$slug])) {
$json = fetch('https://api.github.com/repos/' . GITHUB_SOURCES[$slug] . '/releases/latest');
$data = null === $json ? null : json_decode($json, true);
foreach (is_array($data) && isset($data['assets']) ? $data['assets'] : array() as $asset) {
if (0 === strpos($asset['name'], $slug) && '.zip' === substr($asset['name'], -4)) {
return (int) $asset['size'];
}
}
fail('no release zip for ' . GITHUB_SOURCES[$slug]);
}
return null; // Pro editions and plugins closed on WordPress.org.
}
/**
* Bytes as "1.2 MB" or "870 KB".
*
* @param int $bytes Bytes.
* @return string
*/
function size_label($bytes) {
if ($bytes >= 1024 * 1024) {
return number_format($bytes / (1024 * 1024), 1) . ' MB';
}
return number_format($bytes / 1024) . ' KB';
}
$mode = '';
$dir = dirname(__DIR__);
foreach (array_slice($argv, 1) as $arg) {
if ('--check' === $arg || '--write' === $arg) {
$mode = $arg;
} elseif ('-h' === $arg || '--help' === $arg) {
echo "Usage: php scripts/replaced-plugins.php [--check | --write] [DIR]\n";
exit(0);
} elseif ('-' !== $arg[0]) {
$dir = rtrim($arg, '/');
} else {
fail("unknown argument: $arg");
}
}
if (!is_dir($dir . '/includes')) {
fail("$dir has no includes folder");
}
$reader = new Replaces_Reader();
$reader->read($dir);
$count = count($reader->plugins);
$readme_file = $dir . '/README.md';
$readme = is_file($readme_file) ? file_get_contents($readme_file) : '';
if (0 === $count) {
// A plugin that replaces none has no count line in README.md. With the
// line there, finding none means the reader no longer understands the code.
if (preg_match(README_LINE, $readme)) {
fail('no replaced plugins found; the reader is out of date');
}
echo "No replaced plugins, and README.md has no count line\n";
exit(0);
}
if ('' === $mode) {
foreach ($reader->plugins as $slug => $name) {
printf("%-42s %s\n", $slug, $name);
}
printf("%d plugins\n", $count);
exit(0);
}
if ('--check' === $mode) {
if (!preg_match(README_LINE, $readme, $m)) {
fwrite(STDERR, "README.md has no \"WP Plugin Starter replaces **N plugins**\" line\n");
exit(1);
}
if ((int) $m[1] !== $count) {
fwrite(STDERR, "README.md says {$m[1]} replaced plugins, the code has $count; run php scripts/replaced-plugins.php --write\n");
exit(1);
}
echo "README.md says $count replaced plugins, as the code does\n";
exit(0);
}
// --write: sizes of every replaced plugin that can be downloaded, and ours.
$total = 0;
$sized = 0;
$no_size = array();
foreach ($reader->plugins as $slug => $name) {
$bytes = zip_size($slug);
if (null === $bytes) {
$no_size[] = $name;
continue;
}
$total += $bytes;
$sized++;
printf("%-42s %10s\n", $slug, size_label($bytes));
}
$out = sys_get_temp_dir() . '/wpstarter-replaced-' . getmypid();
exec('cd ' . escapeshellarg($dir) . ' && scripts/build-release.sh --ref HEAD --out ' . escapeshellarg($out) . ' --quiet', $zips, $status);
if (0 !== $status || empty($zips[0]) || !is_file($zips[0])) {
fail('scripts/build-release.sh failed');
}
$ours = filesize($zips[0]);
array_map('unlink', glob($out . '/*.zip'));
@rmdir($out);
$line = sprintf(
'WP Plugin Starter replaces **%d plugins**, some of them in part, with free and Pro editions counted separately. The %d that can be downloaded come to %s zipped; WP Plugin Starter is %s.',
$count,
$sized,
size_label($total),
size_label($ours)
);
printf("%d plugins; %d downloadable, %s; WP Plugin Starter %s\n", $count, $sized, size_label($total), size_label($ours));
if ($no_size) {
echo 'Not downloadable (Pro or closed): ' . implode(', ', $no_size) . "\n";
}
if (!preg_match(README_LINE, $readme)) {
fail('README.md has no "WP Plugin Starter replaces **N plugins**" line to update; add one above the Features table');
}
file_put_contents($readme_file, preg_replace(README_LINE, str_replace(array('\\', '$'), array('\\\\', '\\$'), $line), $readme, 1));
echo "Updated README.md\n";
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Smoke test: install the release zip on a disposable WordPress in Docker and
# load the site and admin screens, first with the default settings and then
# with every feature switched on. Then uninstall it and check it left nothing
# behind. Nothing is kept and no other site is touched.
#
# Usage: scripts/smoke-test.sh [--wp VERSION] [--php VERSION] [--ref REF]
# [--zip FILE] [--wporg] [--keep-log FILE]
# --wp VERSION WordPress version (default: latest). The minimum is 6.2.
# --php VERSION PHP version of the Docker images (default: 8.3). The
# minimum is 7.4.
# --ref REF Build the zip from REF (default: HEAD).
# --zip FILE Test this zip instead of building one.
# --wporg Test the WordPress.org build instead of the GitHub one.
# --keep-log FILE Save the site's debug.log to FILE.
#
# Fails on any PHP error, warning, notice or deprecation in debug.log (from
# the pages loaded, WP-CLI and cron), a page that fails to load (HTTP 500 or
# WordPress's critical error page), or settings left after uninstalling.
# Needs Docker, curl and internet access (WordPress is downloaded).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set in main() from the plugin's main file.
SLUG=""
DB_IMAGE=""
NAME=""
TMP_DIR=""
STARTED=0
WP_VERSION="latest"
PHP_VERSION="8.3"
BASE_URL=""
DB_PASSWORD=""
ADMIN_PASSWORD=""
FAILED=0
die() {
local message="$1"
printf 'smoke-test: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
fail() {
local message="$1"
printf ' FAIL %s\n' "$message"
FAILED=1
return 0
}
cleanup() {
if [ "$STARTED" -eq 1 ]; then
docker rm -f "$NAME-web" "$NAME-db" >/dev/null 2>&1 || true
docker volume rm "$NAME-wp" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
random_password() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 24
return 0
}
# wp-cli in the disposable site; the zip is mounted at /zips.
wp_cli() {
docker run --rm --network "$NAME" -v "$NAME-wp:/var/www/html" -v "$TMP_DIR/zips:/zips:ro" \
--user 33:33 "wordpress:cli-php$PHP_VERSION" php -d memory_limit=1G /usr/local/bin/wp "$@"
return $?
}
start_site() {
printf 'Starting a disposable WordPress %s on PHP %s (%s)...\n' "$WP_VERSION" "$PHP_VERSION" "$NAME"
STARTED=1
DB_PASSWORD="$(random_password)"
ADMIN_PASSWORD="$(random_password)"
docker network create "$NAME" >/dev/null
docker volume create "$NAME-wp" >/dev/null
docker run -d --name "$NAME-db" --network "$NAME" \
-e MARIADB_ROOT_PASSWORD="$DB_PASSWORD" -e MARIADB_DATABASE=wordpress "$DB_IMAGE" >/dev/null
docker run --rm -v "$NAME-wp:/var/www/html" --user 0:0 "wordpress:cli-php$PHP_VERSION" chown 33:33 /var/www/html
local waited=0
until docker exec "$NAME-db" mariadb-admin ping -h127.0.0.1 -uroot -p"$DB_PASSWORD" --silent >/dev/null 2>&1; do
[ "$waited" -lt 90 ] || die "the database did not start"
sleep 2
waited=$((waited + 2))
done
# Core first, so the web image finds WordPress and does not copy its own.
wp_cli core download --version="$WP_VERSION" --quiet
docker run -d --name "$NAME-web" --network "$NAME" -v "$NAME-wp:/var/www/html" \
-p 127.0.0.1::80 "wordpress:php$PHP_VERSION-apache" >/dev/null
BASE_URL="http://$(docker port "$NAME-web" 80 | head -n 1)"
wp_cli config create --dbname=wordpress --dbuser=root --dbpass="$DB_PASSWORD" --dbhost="$NAME-db" --skip-check --quiet
wp_cli config set WP_DEBUG true --raw --quiet
wp_cli config set WP_DEBUG_LOG true --raw --quiet
wp_cli config set WP_DEBUG_DISPLAY false --raw --quiet
wp_cli config set DISABLE_WP_CRON true --raw --quiet
wp_cli core install --url="$BASE_URL" --title=SmokeTest --admin_user=admin --admin_password="$ADMIN_PASSWORD" \
--admin_email=admin@example.com --skip-email --quiet
# WP-CLI cannot see Apache's mod_rewrite, so write core's rules itself.
wp_cli rewrite structure '/%postname%/' --quiet
docker exec -u www-data "$NAME-web" sh -c 'printf "%s\n" "# BEGIN WordPress" "RewriteEngine On" "RewriteBase /" \
"RewriteRule ^index\.php$ - [L]" "RewriteCond %{REQUEST_FILENAME} !-f" "RewriteCond %{REQUEST_FILENAME} !-d" \
"RewriteRule . /index.php [L]" "# END WordPress" >/var/www/html/.htaccess'
printf 'WordPress %s at %s\n' "$(wp_cli core version)" "$BASE_URL"
return 0
}
log_in() {
curl -sS -o /dev/null -c "$TMP_DIR/cookies" -b 'wordpress_test_cookie=WP%20Cookie%20check' \
--data-urlencode 'log=admin' --data-urlencode "pwd=$ADMIN_PASSWORD" \
--data-urlencode 'testcookie=1' --data-urlencode "redirect_to=$BASE_URL/wp-admin/" \
"$BASE_URL/wp-login.php"
local status
status="$(curl -sS -o /dev/null -w '%{http_code}' -b "$TMP_DIR/cookies" "$BASE_URL/wp-admin/")"
[ "$status" = "200" ] || die "could not log in (wp-admin answered $status)"
return 0
}
# Load one page; $2 is "admin" to send the login cookie. Fails on 5xx or
# WordPress's critical error page; other statuses are listed.
fetch() {
local path="$1"
local who="$2"
local status
local args=(-sS -o "$TMP_DIR/page" -w '%{http_code}' --max-time 60)
[ "$who" = "admin" ] && args+=(-b "$TMP_DIR/cookies")
status="$(curl "${args[@]}" "$BASE_URL$path" || printf '000')"
if grep -q 'There has been a critical error' "$TMP_DIR/page"; then
fail "$status $who $path: critical error page"
elif [ "$status" -ge 500 ] || [ "$status" = "000" ]; then
fail "$status $who $path"
else
printf ' %s %s %s\n' "$status" "$who" "$path"
fi
return 0
}
load_pages() {
local path
local visitor_pages=(
'/' '/hello-world/' '/sample-page/' '/?s=hello' '/category/uncategorized/' '/feed/'
'/wp-json/' '/wp-json/wp/v2/posts' '/wp-sitemap.xml' '/no-such-page/' '/wp-login.php'
)
local admin_pages=(
'/' '/hello-world/' '/wp-admin/' '/wp-admin/plugins.php' "/wp-admin/options-general.php?page=$SLUG"
'/wp-admin/edit.php' '/wp-admin/edit.php?post_type=page' '/wp-admin/post-new.php'
'/wp-admin/post.php?post=1&action=edit' '/wp-admin/upload.php' '/wp-admin/edit-comments.php'
'/wp-admin/themes.php' '/wp-admin/users.php' '/wp-admin/profile.php' '/wp-admin/tools.php'
'/wp-admin/site-health.php' '/wp-admin/options-general.php' '/wp-admin/options-permalink.php'
)
for path in "${visitor_pages[@]}"; do
fetch "$path" visitor
done
for path in "${admin_pages[@]}"; do
fetch "$path" admin
done
wp_cli cron event run --due-now --quiet || fail "wp cron event run"
return 0
}
# Switch on every feature that has an on/off setting, except maintenance
# mode, which would answer every visitor page with its 503 notice.
switch_all_on() {
# The class prefix is the plugin's @package (scripts/lib/plugin.sh).
# shellcheck disable=SC2016 # PHP code: its $variables are PHP's, not the shell's.
wp_cli eval '
$plugin = "'"$PLUGIN_PACKAGE"'";
$settings = $plugin . "_Settings";
$schema = $settings::schema();
$on = 0;
foreach ($plugin::features() as $class) {
$key = defined($class . "::KEY") ? $class::KEY : "";
if ("" !== $key && "maintenance" !== $key && isset($schema[$key]["type"]) && in_array($schema[$key]["type"], array("bool", "boolean"), true)) {
$settings::set($key, true);
$on++;
}
}
echo "$on features switched on\n";'
return $?
}
check_uninstall() {
wp_cli plugin deactivate "$SLUG" --quiet || fail "deactivate"
wp_cli plugin uninstall "$SLUG" --quiet || fail "uninstall"
local left
left="$(wp_cli option list --search="*$PLUGIN_PREFIX*" --field=option_name 2>/dev/null || true)"
[ -z "$left" ] || fail "options left after uninstalling: $(printf '%s' "$left" | tr '\n' ' ')"
left="$(wp_cli cron event list --field=hook 2>/dev/null | grep -i "$PLUGIN_PREFIX" || true)"
[ -z "$left" ] || fail "cron events left after uninstalling: $(printf '%s' "$left" | tr '\n' ' ')"
return 0
}
# A must-use plugin that logs a notice for /?smoke-canary, so the test can
# prove that page loads reach debug.log (an empty log proves nothing otherwise).
add_canary() {
docker exec -u www-data "$NAME-web" sh -c 'mkdir -p /var/www/html/wp-content/mu-plugins && printf "%s\n" "<?php" \
"if (isset(\$_GET[\"smoke-canary\"])) { trigger_error(\"smoke-test canary\", E_USER_NOTICE); }" \
>/var/www/html/wp-content/mu-plugins/smoke-canary.php'
return $?
}
check_debug_log() {
local keep="$1"
local log
fetch '/?smoke-canary' visitor
log="$(docker exec "$NAME-web" sh -c 'cat /var/www/html/wp-content/debug.log 2>/dev/null' || true)"
if [ -n "$keep" ]; then
printf '%s\n' "$log" >"$keep"
fi
if ! printf '%s\n' "$log" | grep -q 'smoke-test canary'; then
fail "debug.log does not work: the canary notice is missing"
return 0
fi
log="$(printf '%s\n' "$log" | grep -v 'smoke-test canary' || true)"
if [ -n "$log" ]; then
printf '\ndebug.log:\n%s\n' "$log"
fail "PHP messages in debug.log"
else
printf '\ndebug.log: no PHP messages (the canary notice arrived)\n'
fi
return 0
}
main() {
local ref="HEAD"
local zip=""
local wporg=0
local keep=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--wp | --php | --ref | --zip | --keep-log)
[ $# -ge 2 ] || die "$arg needs a value"
case "$arg" in
--wp) WP_VERSION="$2" ;;
--php) PHP_VERSION="$2" ;;
--ref) ref="$2" ;;
--zip)
[ -f "$2" ] || die "no such zip: $2"
zip="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
;;
--keep-log)
case "$2" in
/*) keep="$2" ;;
*) keep="$PWD/$2" ;;
esac
;;
esac
shift
;;
--wporg) wporg=1 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
command -v docker >/dev/null 2>&1 || die "needs Docker"
command -v curl >/dev/null 2>&1 || die "needs curl"
docker info >/dev/null 2>&1 || die "Docker is not running"
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity "$ref" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
DB_IMAGE="$(plugin_env DB_IMAGE mariadb:10.6)"
NAME="$SLUG-smoke-$$"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-smoke.XXXXXX")"
mkdir -p "$TMP_DIR/zips"
chmod 755 "$TMP_DIR" "$TMP_DIR/zips"
if [ -z "$zip" ]; then
"$root/scripts/build-release.sh" --ref "$ref" --out "$TMP_DIR/build" --quiet >/dev/null || die "build failed"
local built=()
if [ "$wporg" -eq 1 ]; then
built=("$TMP_DIR"/build/wordpress-org-"$SLUG"-*.zip)
else
built=("$TMP_DIR"/build/"$SLUG"-*.zip)
fi
[ -f "${built[0]}" ] || die "the build made no zip"
zip="${built[0]}"
fi
cp "$zip" "$TMP_DIR/zips/$SLUG.zip"
chmod 644 "$TMP_DIR/zips/$SLUG.zip"
start_site
add_canary || die "could not add the debug.log canary"
printf '\nInstalling %s\n' "$(basename "$zip")"
wp_cli plugin install "/zips/$SLUG.zip" --activate --quiet || die "could not install and activate the plugin"
log_in
printf '\nDefault settings:\n'
load_pages
printf '\nEvery feature on:\n'
switch_all_on || fail "switching features on"
load_pages
printf '\nUninstall:\n'
check_uninstall
check_debug_log "$keep"
printf '\n'
if [ "$FAILED" -eq 1 ]; then
printf 'Smoke test failed (WordPress %s, PHP %s).\n' "$WP_VERSION" "$PHP_VERSION"
return 1
fi
printf 'Smoke test passed (WordPress %s, PHP %s).\n' "$WP_VERSION" "$PHP_VERSION"
return 0
}
main "$@"
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Copy the core files (scripts/core-files.txt) from the starter plugin into
# this plugin, with this plugin's names. Core files hold no code for one
# plugin, so after a sync they match the starter apart from the names.
#
# Usage: scripts/sync-core.sh [--check] [--from DIR] [--ref REF]
# --check Change nothing; list core files that differ from the starter's
# and exit 1 if any do (scripts/preflight-release.sh warns).
# --from DIR A checkout of the starter (default: <PREFIX>_STARTER_DIR, then
# wp-plugin-starter-template-for-ai-coding next to this repository).
# --ref REF Starter commit, branch or tag to copy from (default: HEAD of
# that checkout; use origin/main after a git fetch there).
#
# The list of core files and their contents come from the starter at REF;
# the plugin's names come from this repository's main file at HEAD.
# Review the changes with git diff, then commit them.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
readonly STARTER_REPO_DIR="wp-plugin-starter-template-for-ai-coding"
die() {
local message="$1"
printf 'sync-core: %s\n' "$message" >&2
exit 2
}
TMP_FILE=""
cleanup() {
[ -z "$TMP_FILE" ] || rm -f "$TMP_FILE"
return 0
}
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
# Core paths at the starter ref, one per line, with directories expanded.
core_paths() {
local from="$1"
local ref="$2"
local line
git -C "$from" show "$ref:scripts/core-files.txt" | sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' | while IFS= read -r line; do
case "$line" in
*/) git -C "$from" ls-tree -r --name-only "$ref" -- "$line" ;;
*) printf '%s\n' "$line" ;;
esac
done
return 0
}
main() {
local check=0 from="" ref="HEAD"
while [ $# -gt 0 ]; do
local arg="$1"
local value="${2:-}"
case "$arg" in
--check) check=1 ;;
--from)
[ $# -ge 2 ] || die "--from needs a value"
from="$value"
shift
;;
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$value"
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity HEAD || die "cannot tell which plugin this is"
plugin_names_as TO
if [ -z "$from" ]; then
from="$(plugin_env STARTER_DIR "$(dirname "$root")/$STARTER_REPO_DIR")"
fi
[ -d "$from" ] || die "no starter checkout at $from; clone it there or pass --from DIR"
from="$(cd "$from" && pwd)"
[ "$from" != "$root" ] || die "this is the starter; run it in a plugin made from the starter"
git -C "$from" rev-parse --verify --quiet "$ref^{commit}" >/dev/null || die "not a commit in the starter: $ref"
cd "$from"
plugin_identity "$ref" || die "cannot read the starter's names at $ref"
plugin_names_as FROM
cd "$root"
printf 'Core files from %s (%s) into %s, as %s / %s / %s / %s / %s\n' \
"$FROM_NAME" "$(git -C "$from" rev-parse --short "$ref")" "$TO_NAME" \
"$TO_SLUG" "$TO_PREFIX" "$TO_PACKAGE" "$TO_CONST" "$TO_CSS"
local path target mode differ=0 count=0
trap cleanup EXIT
TMP_FILE="$(mktemp "${TMPDIR:-/tmp}/sync-core.XXXXXX")"
local tmp="$TMP_FILE"
while IFS= read -r path; do
target="$(printf '%s' "$path" | plugin_map)"
git -C "$from" show "$ref:$path" | plugin_map >"$tmp"
count=$((count + 1))
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
continue
fi
differ=$((differ + 1))
if [ "$check" -eq 1 ]; then
if [ -f "$target" ]; then
printf ' differs %s\n' "$target"
else
printf ' missing %s\n' "$target"
fi
continue
fi
mkdir -p "$(dirname "$target")"
cp "$tmp" "$target"
mode="$(git -C "$from" ls-tree "$ref" -- "$path" | awk '{ print $1 }')"
if [ "$mode" = "100755" ]; then chmod +x "$target"; else chmod -x "$target"; fi
printf ' updated %s\n' "$target"
done < <(core_paths "$from" "$ref")
if [ "$check" -eq 1 ]; then
if [ "$differ" -eq 0 ]; then
printf 'All %d core files match the starter.\n' "$count"
return 0
fi
printf '%d of %d core files differ from the starter. Run scripts/sync-core.sh, or change the starter first.\n' "$differ" "$count"
return 1
fi
printf '%d of %d core files updated. Review with git diff, then commit.\n' "$differ" "$count"
return 0
}
main "$@"