fix(gates): close the review findings in check-skill-version-bump
- Read changed paths NUL-delimited so non-ASCII paths are no longer silently skipped. - Fail closed when only local main resolves and the pushed commit is the merge-base, instead of passing on an empty diff. - Accept ASCII-only versions with at most nine digits per part. - Check for python3/PyYAML up front, and report read failures as such rather than as a missing version; name a missing SKILL.md. - Document that pre-commit gates only the first ref of a multi-ref push. Tests grow to 29 cases covering each fix plus annotated tags, CRLF frontmatter, unrelated histories and pushing main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,42 +6,74 @@ set -euo pipefail
|
||||
# is the gate that holds the rule, since skill-size-check only checks presence
|
||||
# and shape.
|
||||
#
|
||||
# Baseline: `git merge-base <main> <pushed ref>`. Readers install skills from
|
||||
# main, so "changed" means changed relative to what main ships, not relative to
|
||||
# the remote branch's current tip. Diffing from PRE_COMMIT_FROM_REF would let
|
||||
# the second push of a feature branch excuse a change the first push already
|
||||
# carried unbumped. Unlike check-release-needed this runs on EVERY push, not
|
||||
# only pushes to main: a missing bump is cheapest to fix on the branch, before
|
||||
# review, and a push to main is then already covered.
|
||||
# Baseline: `git merge-base <main> <pushed commit>`, where <main> is origin/main
|
||||
# when it resolves and the local `main` branch otherwise. Readers install
|
||||
# skills from main, so "changed" means changed relative to what main ships, not
|
||||
# relative to the remote branch's current tip. Diffing from PRE_COMMIT_FROM_REF
|
||||
# would let the second push of a feature branch excuse a change the first push
|
||||
# already carried unbumped. The check runs on every push whatever the target
|
||||
# branch — nothing here reads PRE_COMMIT_REMOTE_BRANCH — so it also runs under
|
||||
# a manual `pre-commit run --hook-stage pre-push` (against HEAD, since no
|
||||
# PRE_COMMIT_TO_REF is set). A missing bump is cheapest to fix on the branch,
|
||||
# before review.
|
||||
#
|
||||
# Pushing main itself: with origin/main as the baseline, a push of main diffs
|
||||
# the new commits against what the remote already has, so it is covered. A
|
||||
# pushed commit that is already an ancestor of origin/main (merge-base equals
|
||||
# the pushed commit) changes nothing relative to main and passes. With only the
|
||||
# local `main` fallback, pushing main makes the merge-base the pushed commit
|
||||
# itself — the diff is empty by construction, not because nothing changed — so
|
||||
# that combination FAILS closed rather than passing unchecked.
|
||||
#
|
||||
# Scope: every skill directory plugins/<plugin>/.apm/skills/<skill>/, bin
|
||||
# included. Anything under <skill>/tests/ is ignored — no agent ever loads it,
|
||||
# so a test-only change ships nothing to a reader. A skill counts as changed
|
||||
# when any other file under its directory differs between baseline and pushed
|
||||
# ref. Renames are diffed as delete + add (--no-renames), so:
|
||||
# commit. Paths are read NUL-delimited (`git diff -z`), so core.quotePath never
|
||||
# hides a non-ASCII path. Renames are diffed as delete + add (--no-renames), so:
|
||||
# - a skill absent at the baseline (new, renamed-to, merged-into) is exempt;
|
||||
# it has no prior version to exceed.
|
||||
# - a skill absent at the pushed ref (deleted, renamed-from) is exempt;
|
||||
# there is nothing left to version.
|
||||
# - a skill directory absent at the pushed commit (deleted, renamed-from) is
|
||||
# exempt; there is nothing left to version. A directory that survives
|
||||
# without its SKILL.md is NOT exempt: it fails as "SKILL.md missing".
|
||||
# A changed skill present at both refs must carry a three-part semver
|
||||
# `metadata.version` at the pushed ref (same SEMVER_RE as skill-size-check.sh,
|
||||
# so pre-release suffixes are rejected here as they are there) that is
|
||||
# numerically greater than the baseline's. A baseline with no parseable
|
||||
# version (a skill predating ADR-0022) accepts any valid version. Versions are
|
||||
# read from git objects, never the working tree.
|
||||
# `metadata.version` at the pushed commit that is numerically greater than the
|
||||
# baseline's. The shape rule follows skill-size-check.sh (str()-coerce, strip
|
||||
# whitespace and quotes, three dot-separated numbers, so `1.0` and `1.0.0-rc1`
|
||||
# are rejected) but is deliberately stricter: ASCII digits only (Python's `\d`
|
||||
# also matches e.g. U+FF11) and at most 9 digits per part, so every part fits
|
||||
# bash arithmetic. A baseline with no valid version (a skill predating
|
||||
# ADR-0022) accepts any valid version. Versions are read from git objects,
|
||||
# never the working tree.
|
||||
#
|
||||
# Missing baseline: if neither origin/main nor main resolves, or no merge-base
|
||||
# exists (shallow clone, unrelated history), the gate FAILS closed — the same
|
||||
# choice check-release-needed makes for an unreadable tag. Passing would make
|
||||
# a fresh clone without main the one place the rule is silently off.
|
||||
# Fails closed: if neither origin/main nor main resolves, if no merge-base
|
||||
# exists (shallow clone, unrelated history), if the pushed ref does not resolve
|
||||
# to a commit, if python3 or PyYAML is unavailable, or if a SKILL.md cannot be
|
||||
# read. Passing in any of those would make that environment the one place the
|
||||
# rule is silently off.
|
||||
#
|
||||
# Known gap: this only fires on a local `git push` through pre-commit's
|
||||
# pre-push hook (or a manual `pre-commit run --hook-stage pre-push`). A PR
|
||||
# merged via Gitea's merge button runs no local hook at all — closing that
|
||||
# requires a server-side CI job, which this repo does not have yet.
|
||||
# Known gaps:
|
||||
# - Only the first pushed ref is gated. pre-commit (4.x, hook_impl.py
|
||||
# `_pre_push_ns`) consumes the pre-push stdin itself and builds the
|
||||
# environment from the first ref line that is not a delete and has commits
|
||||
# the remote lacks; every later ref in the same `git push` (e.g.
|
||||
# `git push origin a b`, `--all`, `--tags`) is never seen. When that first
|
||||
# ref's unpushed history reaches a root commit, pre-commit runs with
|
||||
# all_files and sets no PRE_COMMIT_TO_REF at all, so this script checks
|
||||
# HEAD — which is the pushed ref only if it happens to be checked out. The
|
||||
# script cannot recover either case: the ref list is gone by the time it
|
||||
# runs. Push refs one at a time to be sure each is checked.
|
||||
# - A PR merged via Gitea's merge button runs no local hook at all (the same
|
||||
# gap check-release-needed has). Closing it requires a server-side CI job,
|
||||
# which this repo does not have yet.
|
||||
|
||||
# See check-release-needed.sh: PRE_COMMIT_TO_REF is the local sha actually
|
||||
# being pushed, which is only HEAD for the common case.
|
||||
# Byte-wise regex matching and messages: path bytes are matched against
|
||||
# SKILL_PATH_RE below and must not depend on the caller's locale.
|
||||
export LC_ALL=C
|
||||
|
||||
# PRE_COMMIT_TO_REF is the local object actually being pushed, which is only
|
||||
# HEAD for the common case. For a tag push it is the tag object, so it is
|
||||
# peeled to a commit below before use.
|
||||
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
||||
|
||||
# All-zeros sha: the push deletes a branch. Nothing ships; bail out.
|
||||
@@ -52,6 +84,23 @@ fi
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v python3 > /dev/null 2>&1; then
|
||||
echo "FAIL: python3 is required to read SKILL.md metadata.version but was not found on PATH." >&2
|
||||
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
||||
echo "FAIL: PyYAML is required to read SKILL.md metadata.version but is not importable by python3." >&2
|
||||
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! PUSHED_COMMIT="$(git rev-parse --verify -q "$PUSHED_REF^{commit}")"; then
|
||||
echo "FAIL: pushed ref $PUSHED_REF does not resolve to a commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAIN_REF=""
|
||||
for candidate in origin/main main; do
|
||||
if git rev-parse --verify -q "$candidate^{commit}" > /dev/null; then
|
||||
@@ -66,20 +115,29 @@ if [[ -z "$MAIN_REF" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! BASELINE="$(git merge-base "$MAIN_REF" "$PUSHED_REF" 2>/dev/null)"; then
|
||||
if ! BASELINE="$(git merge-base "$MAIN_REF" "$PUSHED_COMMIT" 2>/dev/null)"; then
|
||||
echo "FAIL: no merge-base between $MAIN_REF and $PUSHED_REF, so there is no baseline to compare skill versions against." >&2
|
||||
echo " Fix: ensure full history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! CHANGED="$(git diff --no-renames --name-only "$BASELINE" "$PUSHED_REF" -- plugins)"; then
|
||||
if [[ "$MAIN_REF" == "main" && "$BASELINE" == "$PUSHED_COMMIT" ]]; then
|
||||
echo "FAIL: origin/main does not resolve and $PUSHED_REF is already contained in local main, so local main cannot serve as an independent baseline — the diff would be empty by construction." >&2
|
||||
echo " Fix: git fetch origin main and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED_FILE="$(mktemp)"
|
||||
trap 'rm -f "$CHANGED_FILE"' EXIT
|
||||
|
||||
if ! git diff -z --no-renames --name-only "$BASELINE" "$PUSHED_COMMIT" -- plugins > "$CHANGED_FILE"; then
|
||||
echo "FAIL: could not diff $BASELINE..$PUSHED_REF (see git error above)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_PATH_RE='^(plugins/[^/]+/\.apm/skills/[^/]+)/(.+)$'
|
||||
SKILL_DIRS=()
|
||||
while IFS= read -r path; do
|
||||
while IFS= read -r -d '' path; do
|
||||
[[ "$path" =~ $SKILL_PATH_RE ]] || continue
|
||||
[[ "${BASH_REMATCH[2]}" == tests/* ]] && continue
|
||||
dir="${BASH_REMATCH[1]}"
|
||||
@@ -88,37 +146,54 @@ while IFS= read -r path; do
|
||||
[[ "$existing" == "$dir" ]] && { seen=true; break; }
|
||||
done
|
||||
$seen || SKILL_DIRS+=("$dir")
|
||||
done <<< "$CHANGED"
|
||||
done < "$CHANGED_FILE"
|
||||
|
||||
[[ ${#SKILL_DIRS[@]} -eq 0 ]] && exit 0
|
||||
|
||||
# Prints the three-part semver metadata.version from SKILL.md on stdin, or
|
||||
# nothing when it is missing or malformed. Same acceptance rule as
|
||||
# skill-size-check.sh: str()-coerce, strip whitespace and quotes, then
|
||||
# ^\d+\.\d+\.\d+$ — so `1.0` (a YAML float) and `1.0.0-rc1` both print nothing.
|
||||
# Reads SKILL.md bytes on stdin and prints exactly one line: `OK <version>`
|
||||
# when metadata.version is a valid three-part semver, `INVALID` when it is
|
||||
# missing or malformed (including unparseable frontmatter). Any other outcome —
|
||||
# python3 crashing, PyYAML failing to import — is a non-zero exit with no OK /
|
||||
# INVALID line, which the caller reports as a read failure, never as a missing
|
||||
# version. Bytes are decoded explicitly so the caller's locale cannot turn a
|
||||
# non-ASCII SKILL.md into a crash; `\s*` before each `\n` absorbs CRLF.
|
||||
read_version() {
|
||||
python3 -c '
|
||||
import re, sys, yaml
|
||||
text = sys.stdin.read()
|
||||
m = re.match(r"^---\s*\n(.*?)\n---\s*(\n|$)", text, re.S)
|
||||
if not m:
|
||||
sys.exit(0)
|
||||
try:
|
||||
data = yaml.safe_load(m.group(1))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
||||
m = re.match(r"---[ \t\r]*\n(.*?)\n---[ \t\r]*(\n|\Z)", text, re.S)
|
||||
data = None
|
||||
if m:
|
||||
try:
|
||||
data = yaml.safe_load(m.group(1))
|
||||
except yaml.YAMLError:
|
||||
data = None
|
||||
meta = data.get("metadata") if isinstance(data, dict) else None
|
||||
ver = meta.get("version") if isinstance(meta, dict) else None
|
||||
if ver is None:
|
||||
sys.exit(0)
|
||||
ver = str(ver).strip().strip("\x27\"")
|
||||
if re.match(r"^\d+\.\d+\.\d+$", ver):
|
||||
print(ver)
|
||||
ver = None if ver is None else str(ver).strip().strip("\x27\"")
|
||||
if ver is not None and re.fullmatch(r"[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}", ver):
|
||||
print("OK " + ver)
|
||||
else:
|
||||
print("INVALID")
|
||||
'
|
||||
}
|
||||
|
||||
# Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH, compared numerically so
|
||||
# 1.0.10 > 1.0.9. 10# forces base 10 on a leading zero.
|
||||
# version_at <commit> <path>: sets VERSION to the valid version or "" when
|
||||
# invalid. Exits the script on a read failure.
|
||||
version_at() {
|
||||
local out
|
||||
if ! out="$(git show "$1:$2" | read_version)" || [[ "$out" != OK\ * && "$out" != INVALID ]]; then
|
||||
echo "FAIL: could not read metadata.version from $1:$2 (see error above)." >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION=""
|
||||
[[ "$out" == OK\ * ]] && VERSION="${out#OK }"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH with parts of at most 9 ASCII
|
||||
# digits, compared numerically so 1.0.10 > 1.0.9. 10# forces base 10 on a
|
||||
# leading zero.
|
||||
semver_gt() {
|
||||
local -a a b
|
||||
local i
|
||||
@@ -135,11 +210,18 @@ OFFENDERS=()
|
||||
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
# Absent at baseline: new, renamed-to, or merged-into. Exempt.
|
||||
git cat-file -e "$BASELINE:$dir/SKILL.md" 2>/dev/null || continue
|
||||
# Absent at pushed ref: deleted or renamed-from. Exempt.
|
||||
[[ "$(git cat-file -t "$PUSHED_REF:$dir" 2>/dev/null)" == "tree" ]] || continue
|
||||
# Directory absent at pushed commit: deleted or renamed-from. Exempt.
|
||||
[[ "$(git cat-file -t "$PUSHED_COMMIT:$dir" 2>/dev/null)" == "tree" ]] || continue
|
||||
|
||||
base_ver="$(git show "$BASELINE:$dir/SKILL.md" | read_version)"
|
||||
cur_ver="$(git show "$PUSHED_REF:$dir/SKILL.md" 2>/dev/null | read_version || true)"
|
||||
version_at "$BASELINE" "$dir/SKILL.md"
|
||||
base_ver="$VERSION"
|
||||
|
||||
if ! git cat-file -e "$PUSHED_COMMIT:$dir/SKILL.md" 2>/dev/null; then
|
||||
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${base_ver:-none})")
|
||||
continue
|
||||
fi
|
||||
version_at "$PUSHED_COMMIT" "$dir/SKILL.md"
|
||||
cur_ver="$VERSION"
|
||||
|
||||
if [[ -z "$cur_ver" ]]; then
|
||||
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${base_ver:-none})")
|
||||
|
||||
Reference in New Issue
Block a user