#!/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
`, where
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//.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 # 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 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 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. # # 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 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. # 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. if [[ "$PUSHED_REF" =~ ^0+$ ]]; then exit 0 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 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_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 [[ "$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 -d '' 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_FILE" [[ ${#SKILL_DIRS[@]} -eq 0 ]] && exit 0 # Reads SKILL.md bytes on stdin and prints exactly one line: `OK ` # 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.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 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") ' } # version_at : 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 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 # Directory absent at pushed commit: deleted or renamed-from. Exempt. [[ "$(git cat-file -t "$PUSHED_COMMIT:$dir" 2>/dev/null)" == "tree" ]] || continue 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})") 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