check-skill-version-bump fails a push when a skill directory changed against its merge-base with main (tests/ excluded) without a strictly higher metadata.version than main. New, renamed and deleted skills are exempt; every plugin is covered. Recorded as a dated section in ADR-0022 and documented in gates.md. Patch-bumps the 17 skills that changed on this branch without a bump, so the branch passes its own gate. Simplification audit finding 33. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.3 KiB
Bash
Executable File
157 lines
6.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Fails a push when a skill changed without its SKILL.md `metadata.version`
|
|
# being bumped. ADR-0022 makes the field mandatory and the bump the rule; this
|
|
# 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.
|
|
#
|
|
# 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:
|
|
# - 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 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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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.
|
|
|
|
# See check-release-needed.sh: PRE_COMMIT_TO_REF is the local sha actually
|
|
# being pushed, which is only HEAD for the common case.
|
|
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
|
|
|
# All-zeros sha: the push deletes a branch. Nothing ships; bail out.
|
|
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
cd "$REPO_ROOT"
|
|
|
|
MAIN_REF=""
|
|
for candidate in origin/main main; do
|
|
if git rev-parse --verify -q "$candidate^{commit}" > /dev/null; then
|
|
MAIN_REF="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -z "$MAIN_REF" ]]; then
|
|
echo "FAIL: neither origin/main nor main resolves, so there is no baseline to compare skill versions against." >&2
|
|
echo " Fix: git fetch origin main (or create a local main) and retry." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! BASELINE="$(git merge-base "$MAIN_REF" "$PUSHED_REF" 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
|
|
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
|
|
[[ "$path" =~ $SKILL_PATH_RE ]] || continue
|
|
[[ "${BASH_REMATCH[2]}" == tests/* ]] && continue
|
|
dir="${BASH_REMATCH[1]}"
|
|
seen=false
|
|
for existing in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
|
[[ "$existing" == "$dir" ]] && { seen=true; break; }
|
|
done
|
|
$seen || SKILL_DIRS+=("$dir")
|
|
done <<< "$CHANGED"
|
|
|
|
[[ ${#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.
|
|
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)
|
|
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)
|
|
'
|
|
}
|
|
|
|
# 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.
|
|
semver_gt() {
|
|
local -a a b
|
|
local i
|
|
IFS=. read -ra a <<< "$1"
|
|
IFS=. read -ra b <<< "$2"
|
|
for i in 0 1 2; do
|
|
if (( 10#${a[i]} > 10#${b[i]} )); then return 0; fi
|
|
if (( 10#${a[i]} < 10#${b[i]} )); then return 1; fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
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
|
|
|
|
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)"
|
|
|
|
if [[ -z "$cur_ver" ]]; then
|
|
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${base_ver:-none})")
|
|
elif [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then
|
|
OFFENDERS+=("$dir: $base_ver -> $cur_ver")
|
|
fi
|
|
done
|
|
|
|
if [[ ${#OFFENDERS[@]} -gt 0 ]]; then
|
|
echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version bump (ADR-0022):" >&2
|
|
printf ' %s\n' ${OFFENDERS[@]+"${OFFENDERS[@]}"} >&2
|
|
echo " Fix: raise metadata.version in each SKILL.md above the baseline — bump PATCH at minimum." >&2
|
|
exit 1
|
|
fi
|