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>
279 lines
12 KiB
Bash
Executable File
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
|