Both found by the subscriptions session in its copy of this kit, and both reproduce here. They share the shape everything else in ci/ has had: the failure is invisible on GitHub, which is where anyone would be looking. 1. ci/workflows-in-sync.sh never parsed the workflow files on Gitea, and printed "Workflows: in sync." anyway. It skipped with a note when PyYAML was missing — and PyYAML is missing in exactly one place. Measured here rather than taken on report: `import yaml` on gitea/runner-images:ubuntu-latest raises ModuleNotFoundError. So the check was switched off on the single forge where an unparseable workflow produces no run at all, no failed run and no error to click on, which looks exactly like a runner that has not picked the job up. ci_python_yaml in ci/lib.sh now falls back to a container the way ci_php already did — alpine + py3-yaml, no network install, one apk call. There is no skip path left: workflows that cannot be parsed are a failure, not a note. Both routes verified to give byte-identical output. 2. compat.yml ran its five-cell matrix on Gitea for every PR touching ci/ or tests/. The `schedule` was gated to GitHub for exactly this reason and `pull_request` was forgotten. The subscriptions repo measured six jobs dying together with exit code 137 — SIGKILL, runner out of memory, two seconds in, without one error message in the log. Latent here only because this repo merges through GitHub and never opens a Gitea PR; one Gitea PR touching ci/ would have found it. New ci/check-workflows.py enforces both, called through ci_python_yaml. It parses every workflow and fails when a Gitea job with more than one matrix cell is not gated, and it is fatal when it parsed no file at all. 🔴 Its first version asked whether the `if:` CONTAINED the server_url test, and passed the very condition it exists to catch: `!= 'schedule' || server_url == ...` contains the gate while letting pull_request through. Substring matching is adjacent to the signal rather than the signal. It now compares the whole normalised condition against an allowlist, and was checked both ways in one run — red against the unfixed compat.yml naming the job and the cell count, green after. Three self-test assertions added, 29 total: an unparseable workflow fails, an ungated multi-cell matrix fails, and parsing zero files fails. Two notes from building it, both the same trap this kit keeps setting. The container path could not find check-workflows.py because ci_in_container tars `git ls-files` and the file was untracked. And the ungated-matrix fixture used | as a sed delimiter while the gate itself contains ||, so sed silently changed nothing; the assertion reported "could not stage the fixture" instead of passing, which is the only reason it was noticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
4.7 KiB
Bash
127 lines
4.7 KiB
Bash
# Shared helpers for the ci/ scripts. Sourced, never executed.
|
|
#
|
|
# Nothing in here is specific to one plugin — ci/plugin.conf holds that.
|
|
|
|
# shellcheck shell=bash
|
|
|
|
ci_repo_root() {
|
|
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
|
|
}
|
|
|
|
# Load ci/plugin.conf and fail loudly on anything it forgot to set. A missing
|
|
# value would otherwise turn into an empty path and a test that passes by
|
|
# testing nothing.
|
|
ci_load_conf() {
|
|
local root
|
|
root="$(ci_repo_root)"
|
|
|
|
# shellcheck source=/dev/null
|
|
. "$root/ci/plugin.conf"
|
|
|
|
# MIN_* included on purpose: a port that copies this file but forgets them
|
|
# leaves the floors empty, every comparison becomes a no-op, and the hollow
|
|
# checks these were added to fix come back silently — the same shape of bug.
|
|
local missing=
|
|
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS; do
|
|
[ -n "${!var:-}" ] || missing="$missing $var"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "ci/plugin.conf is missing:$missing" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ ! -f "$root/$PLUGIN_MAIN" ]; then
|
|
echo "ci/plugin.conf points PLUGIN_MAIN at '$PLUGIN_MAIN', which does not exist." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# The version WordPress will compare against the update feed. Read from the
|
|
# plugin header, which is the only copy WordPress ever looks at.
|
|
ci_plugin_version() {
|
|
sed -n -E 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' \
|
|
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1
|
|
}
|
|
|
|
# Read any other plugin header field, e.g. ci_plugin_header 'Requires at least'.
|
|
ci_plugin_header() {
|
|
sed -n -E "s/^[[:space:]]*\*?[[:space:]]*$1:[[:space:]]*(.*)/\1/p" \
|
|
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
|
}
|
|
|
|
# Run a shell script against a checkout of this repository, inside a container.
|
|
#
|
|
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
|
# Gitea Actions job runs inside a container talking to the HOST's docker
|
|
# daemon, so a host path here lands on an empty directory nobody created and
|
|
# the check passes by checking nothing. The symptom reads like a typo in a path.
|
|
#
|
|
# Two details are load-bearing. The script is copied in as a FILE rather than
|
|
# interpolated into `sh -c`, so it can contain $, quotes and newlines without
|
|
# being mangled — which matters the moment it is more than one command. And
|
|
# --entrypoint sh is not decoration: some images (composer:2, for one) have an
|
|
# entrypoint that swallows its arguments.
|
|
#
|
|
# Taken from the subscriptions port, which needed it for composer.
|
|
ci_in_container() { # image script-text
|
|
local image="$1" script="$2"
|
|
local dir cid code=0
|
|
|
|
dir=$(mktemp -d -t wpci-XXXXXX)
|
|
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$dir/src.tar" )
|
|
printf '%s\n' "$script" > "$dir/run.sh"
|
|
|
|
cid=$(docker create --entrypoint sh -w /src "$image" \
|
|
-c 'mkdir -p /src && tar -xf /tmp/src.tar -C /src && exec sh /tmp/run.sh')
|
|
docker cp "$dir/src.tar" "$cid:/tmp/src.tar" >/dev/null
|
|
docker cp "$dir/run.sh" "$cid:/tmp/run.sh" >/dev/null
|
|
docker start -a "$cid" || code=$?
|
|
docker rm -f "$cid" >/dev/null
|
|
rm -rf "$dir"
|
|
return "$code"
|
|
}
|
|
|
|
# Run one of this repository's PHP scripts.
|
|
#
|
|
# Uses the local php when there is one — a laptop, GitHub's hosted runners —
|
|
# and a container otherwise. The Gitea runner image has no php, and losing a
|
|
# build to "php: command not found" after everything else passed is a silly way
|
|
# to spend an afternoon. Measured: it happened on the very first Gitea run.
|
|
ci_php() {
|
|
if command -v php >/dev/null 2>&1; then
|
|
php "$@"
|
|
return
|
|
fi
|
|
|
|
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
|
}
|
|
|
|
# Run one of this repository's python scripts, with PyYAML available.
|
|
#
|
|
# Same shape and the same reason as ci_php above, and it is not hypothetical:
|
|
# the Gitea runner image has NO PyYAML (measured — `import yaml` raises
|
|
# ModuleNotFoundError there), while every laptop and GitHub's hosted runners
|
|
# have it. So a check that merely skips when the import fails is a check that
|
|
# is switched off on exactly one forge — and Gitea is the forge where a
|
|
# workflow that does not parse produces no run and no error to click on.
|
|
#
|
|
# alpine + py3-yaml rather than a python image + pip: no network install, one
|
|
# apk call, the same pinned answer every time.
|
|
ci_python_yaml() {
|
|
if python3 -c 'import yaml' 2>/dev/null; then
|
|
python3 "$@"
|
|
return
|
|
fi
|
|
|
|
ci_in_container "alpine:3.20" \
|
|
"apk add --no-cache python3 py3-yaml >/dev/null && exec python3 $(printf '%q ' "$@")"
|
|
}
|
|
|
|
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
|
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
|
|
|
# GitHub and Gitea both render these as annotations on the file and line.
|
|
ci_annotate() { # level file line message
|
|
printf '::%s file=%s,line=%s::%s\n' "$1" "$2" "$3" "$4"
|
|
}
|