Files
holocron/scripts/check-skill-version-bump.sh
Defame1297 4de5b6b355 chore(gates): retire the external pre-commit hook contract
Why: .pre-commit-hooks.yaml and its release-tag gate served external
consumers that do not exist. No repo on the Gitea instance pins these
hooks, and the README names apm as the only supported install path. The
mechanism was also already failing: skill-size-check.sh changed after
v2.0.1 with no tag cut, and the gate cannot fire through Gitea's merge
button. (Simplification audit finding 36.)

Implementation Notes:
- Delete .pre-commit-hooks.yaml, scripts/check-release-needed.sh,
  tests/test-check-release-needed.sh and tests/test-vale-hooks-consumer.sh,
  and remove the check-release-needed pre-push hook. The repo: local
  skill-size-check and vale-audit-prefilter-* hooks are unchanged.
- ADR-0014 is amended, not retired: its runtime decision to bundle Vale
  inside factory-audit stands. The amendment keeps the entry[0]-only
  constraint (LESSONS.md:101,105) in case the export returns. ADR-0025
  gets a pointer.
- test-vale-wrap.sh: drop case 33 (the cross-manifest drift check) and
  case 28's hook-scope half, which read the published manifest. Case 32
  now also requires each hook to select every tracked file of its class,
  which keeps case 33's one-plugin-narrowing guard, with a mutation test.
- test-skill-size-check.sh and test-adr0020-contract.sh now assert the
  hook contract and verbose: true on .pre-commit-config.yaml only.
- gates.md: pre-push count goes from 9 to 8 authored hooks (11 to 10
  reported), and the Release table, the External consumers section and
  the two-manifest scope table are removed. README and script/test
  comments no longer describe the export as live. The resolver comment
  is edited identically in both copies.
- The v1.0.0/v2.0.0/v2.0.1 tags are left in place; they are inert.

ADR: 0014
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:52:14 +00:00

279 lines
12 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 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.
#
# Second baseline: the tip of that same <main> ref. A changed skill's pushed
# version must exceed its version there too (ADR-0022, second 2026-09-16
# amendment). Two branches bumping 1.0.0 -> 1.0.1 with different content merge
# without a conflict, so the merge-base alone would let main ship both under
# one version. When <main> has not moved since the merge-base, the two
# baselines are one commit and the skill is checked once. The tip is read as
# last fetched.
#
# 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
# 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 both baselines (new, renamed-to, merged-into) is
# exempt; it has no prior version to exceed. Absent at the tip only
# (deleted on main since): the merge-base rule alone applies.
# - a file moved from one skill to another changes both.
# - a skill directory absent at the pushed commit (deleted, renamed-from) is
# exempt; there is nothing left to version. A directory replaced by a
# symlink is no longer a tree, so it counts as deleted (apm drops symlinks
# under .apm/, ADR-0017). A directory that survives without its SKILL.md
# is NOT exempt: it fails as "SKILL.md missing".
# Presence is read from the tree (rev-parse <commit>:<path>), not from the
# blob, so a blob missing from a corrupt or partial clone is a read failure,
# never a skill that looks new.
# A changed skill must carry a three-part semver `metadata.version` at the
# pushed commit that is numerically greater than each baseline's. The shape
# rule matches skill-size-check.sh (str()-coerce, strip whitespace and quotes,
# so `1.0` and `1.0.0-rc1` are rejected): each part is ASCII digits (Python's
# `\d` also matches e.g. U+FF11), at most 9 of them so it fits bash
# arithmetic, with no leading zero so bash never reads it as octal. A leading
# UTF-8 BOM is ignored. 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 (an unknown sha, a tag on a tree), if python3 or PyYAML is
# unavailable, or if a SKILL.md the tree names cannot be read. Passing in any
# of those would make that environment the one place the rule is silently off.
#
# Known gaps:
# - Only one pushed ref is gated. pre-commit (4.6.1, hook_impl.py
# `_pre_push_ns`) consumes the pre-push stdin itself and walks the ref
# lines in order: it skips deletes, returns on a ref whose remote sha is
# non-zero and exists locally, and otherwise returns on the ref only if it
# has commits no remote-tracking ref of that remote has. Every other ref in
# the same `git push` (e.g. `git push origin a b`, `--all`, `--tags`) is
# never seen. When the selected 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 — the pushed ref only if 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.
# 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, so nothing ships. Defensive only:
# pre-commit 4.6.1's `_pre_push_ns` already skips delete lines and never passes
# one here. Kept so a different caller cannot turn a delete into a rev-parse
# failure.
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 <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.buffer.read().decode("utf-8-sig", 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|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})", ver):
print("OK " + ver)
else:
print("INVALID")
'
}
# 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 and no leading zero (so bash never reads a part as octal), compared
# numerically so 1.0.10 > 1.0.9.
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 (( a[i] > b[i] )); then return 0; fi
if (( a[i] < b[i] )); then return 1; fi
done
return 1
}
MAIN_TIP="$(git rev-parse --verify -q "$MAIN_REF^{commit}")"
# in_tree <commit> <path>: the tree names <path>. Unlike `git cat-file -e`, it
# does not need the blob itself, so a blob a corrupt or partial clone lacks is a
# read failure in version_at, not a skill that silently looks absent.
in_tree() {
git rev-parse --verify -q "$1:$2" > /dev/null
}
OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
at_base=false
at_tip=false
in_tree "$BASELINE" "$dir/SKILL.md" && at_base=true
# When main has not moved since the merge-base, the tip is the same baseline.
[[ "$MAIN_TIP" != "$BASELINE" ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" && at_tip=true
# Absent at both baselines: new, renamed-to, or merged-into. Exempt.
$at_base || $at_tip || 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=""
tip_ver=""
if $at_base; then version_at "$BASELINE" "$dir/SKILL.md"; base_ver="$VERSION"; fi
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; 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})")
continue
fi
if [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then
OFFENDERS+=("$dir: $base_ver -> $cur_ver (not above merge-base)")
fi
if [[ -n "$tip_ver" ]] && ! semver_gt "$cur_ver" "$tip_ver"; then
OFFENDERS+=("$dir: $tip_ver -> $cur_ver (not above $MAIN_REF tip)")
fi
done
if [[ ${#OFFENDERS[@]} -gt 0 ]]; then
echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version above both that merge-base and the $MAIN_REF tip (ADR-0022):" >&2
printf ' %s\n' ${OFFENDERS[@]+"${OFFENDERS[@]}"} >&2
echo " Fix: raise metadata.version in each SKILL.md above the baseline named — bump PATCH at minimum." >&2
exit 1
fi