#!/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
`. 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//.apm/skills//, bin # included. Anything under /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