Files
holocron/scripts/check-skill-version-bump.sh
Defame1297 8cfd54f925 fix(gates): close the review findings in the gates and their docs
Two reproduced bugs in check-skill-version-bump:

- The origin/main-tip check fired even when the pushed skill was
  byte-identical to main's tip, so a cherry-pick or backport failed a
  push that ships nothing. The merge-base intersection ea119d8 added
  covers that only when some base carries the content, which a
  criss-cross history gives and a linear one does not. A new
  same_subtree compares tree object ids, so the exemption holds
  whatever route the history took.
- The failure line reported "baseline: none" when the skill was absent
  at every merge-base but present at the tip, and the Fix: line then
  named no version. The author writes the natural 1.0.0 and gets a
  second blocked push. It now falls back to the tip's version.

ADR-0022 is not amended: the documented behaviour does not change, and
ea119d8 set the precedent by fixing the same failure class script-only.

1614bce verified that executables.allow grants are version-blind and
corrected ADR-0019, gates.md and apm.yml, but missed the gate script's
own header and its operator-facing FAIL message, which still told the
reader deployment was silently broken, and gates.md's hook summary,
which still called it a silent-failure guard. All three now match.

Also: README's offline guarantee carries the populated-apm_modules
condition gates.md and AGENTS.md already state; the scripts/ layout row
drops "sync" for the three deleted sync scripts; the check-rtk-prefix
README rationale names the 12 subdirectory READMEs that survive rather
than the skill-root ones this branch deleted; gates.md re-cites its
three head -1 sites by enclosing function per its own :238 rule; and
deploy-manifest drops a pointer to a provider-manifest.sh that has
never existed on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 12:33:50 +00:00

388 lines
18 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 --all <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.
#
# `--all`, not the single base git would otherwise pick for it. A criss-cross
# history — main merges a branch while that branch merges a main commit — has
# TWO merge bases, and which one `git merge-base` prints is an implementation
# detail. Picking one made the verdict a coin flip: a skill byte-identical to
# main's tip was still reported "not above merge-base" whenever the losing base
# happened to be chosen, so an already-merged bump failed the push it should
# have passed. So a skill counts as CHANGED only when it differs from EVERY
# base — differing from none of them, or from only some, means one base already
# carries the pushed content — and a changed skill's version must exceed the
# version at every base it exists at. Both directions are conservative: the
# intersection cannot exempt a skill that genuinely changed since all of main's
# reachable history, and requiring every base keeps the ratchet.
#
# 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. A skill whose directory at the pushed commit is the SAME TREE
# OBJECT as at the tip skips the tip comparison: it ships exactly what main
# ships, so there is nothing to announce. The merge-base intersection catches
# that only when some base carries the content — true of the criss-cross shape
# above, false of a branch that cherry-picks a fix main already has.
#
# 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
BASES=()
while IFS= read -r base; do
[[ -n "$base" ]] && BASES+=("$base")
done < <(git merge-base --all "$MAIN_REF" "$PUSHED_COMMIT" 2>/dev/null || true)
if [[ ${#BASES[@]} -eq 0 ]]; 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
# A pushed commit that is an ancestor of <main> is itself the only merge-base,
# so this fires on exactly the case it always did.
if [[ "$MAIN_REF" == "main" ]]; then
for base in ${BASES[@]+"${BASES[@]}"}; do
if [[ "$base" == "$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
done
fi
CHANGED_FILE="$(mktemp)"
trap 'rm -f "$CHANGED_FILE"' EXIT
SKILL_PATH_RE='^(plugins/[^/]+/\.apm/skills/[^/]+)/(.+)$'
# skill_dirs_at <base>: sets SKILL_DIRS_ONE to the skill directories differing
# between <base> and the pushed commit.
skill_dirs_at() {
local path dir existing seen
SKILL_DIRS_ONE=()
if ! git diff -z --no-renames --name-only "$1" "$PUSHED_COMMIT" -- plugins > "$CHANGED_FILE"; then
echo "FAIL: could not diff $1..$PUSHED_REF (see git error above)." >&2
exit 1
fi
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_ONE[@]+"${SKILL_DIRS_ONE[@]}"}; do
[[ "$existing" == "$dir" ]] && { seen=true; break; }
done
$seen || SKILL_DIRS_ONE+=("$dir")
done < "$CHANGED_FILE"
}
# Intersected across every base: a skill matching any ONE base is already
# shipped by that base and has nothing left to bump.
skill_dirs_at "${BASES[0]}"
SKILL_DIRS=(${SKILL_DIRS_ONE[@]+"${SKILL_DIRS_ONE[@]}"})
for ((i = 1; i < ${#BASES[@]}; i++)); do
[[ ${#SKILL_DIRS[@]} -eq 0 ]] && break
skill_dirs_at "${BASES[i]}"
KEPT=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
for existing in ${SKILL_DIRS_ONE[@]+"${SKILL_DIRS_ONE[@]}"}; do
[[ "$existing" == "$dir" ]] && { KEPT+=("$dir"); break; }
done
done
SKILL_DIRS=(${KEPT[@]+"${KEPT[@]}"})
done
[[ ${#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; `[ \t\r]*` before each `\n` absorbs CRLF.
# Whitespace before the opening `---` is accepted, matching FRONTMATTER_RE in
# factory-audit's lib-boundary-resolver.sh, which skill-size-check applies.
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]*---[ \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
}
# same_subtree <commit-a> <commit-b> <path>: both name <path> with the same
# object, so the two commits ship byte-identical content there. Compared as
# object ids rather than by diffing: a tree id is the content, whatever route
# the history took to it. A path missing on either side is not a match.
same_subtree() {
local a b
a="$(git rev-parse --verify -q "$1:$3")" || return 1
b="$(git rev-parse --verify -q "$2:$3")" || return 1
[[ "$a" == "$b" ]]
}
OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
# Bases the skill exists at, in merge-base order, with the version read at
# each. Index-matched arrays rather than one map: bash 3.2 has no `declare -A`.
base_at=()
base_vers=()
first_base_ver=""
for base in ${BASES[@]+"${BASES[@]}"}; do
in_tree "$base" "$dir/SKILL.md" || continue
version_at "$base" "$dir/SKILL.md"
base_at+=("$base")
base_vers+=("$VERSION")
[[ -n "$first_base_ver" ]] || first_base_ver="$VERSION"
done
# When main has not moved since a merge-base, the tip IS that baseline and the
# skill is checked once.
at_tip=false
tip_is_base=false
for base in ${BASES[@]+"${BASES[@]}"}; do
if [[ "$MAIN_TIP" == "$base" ]]; then
tip_is_base=true
break
fi
done
# A skill byte-identical to the tip's copy of it is already what main ships,
# so there is nothing left for a bump to announce. The merge-base rule alone
# would already have exempted it, but only when a base carries that same
# content — which a criss-cross history gives and a linear one does not. A
# branch cut before a fix landed on main and then cherry-picking that fix has
# one merge-base, predating the fix, so the skill counts as changed against
# it and reaches the tip comparison carrying exactly the tip's version. Same
# content, same version, and the only escapes would be a spurious bump —
# leaving main carrying two versions of identical content — or a rebase the
# push does not otherwise need.
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" \
&& ! same_subtree "$MAIN_TIP" "$PUSHED_COMMIT" "$dir"; then
at_tip=true
fi
# Absent at every baseline: new, renamed-to, or merged-into. Exempt.
[[ ${#base_at[@]} -gt 0 ]] || $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
tip_ver=""
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
# The baseline the two messages below name. A skill added on main after the
# branch was cut is absent at every merge-base, so no base names a version
# while the tip does — and the tip's is the version the push is actually held
# to. Reporting "none" there sends the author to the natural 1.0.0 and costs
# them a second blocked push on the same mistake.
report_ver="${first_base_ver:-$tip_ver}"
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${report_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: ${report_ver:-none})")
continue
fi
# Named by sha only when there is more than one base to tell apart; a
# criss-cross history is the only case where "which merge-base" is a question
# the reader cannot answer from the branch alone.
for ((i = 0; i < ${#base_at[@]}; i++)); do
base_ver="${base_vers[i]}"
[[ -n "$base_ver" ]] || continue
semver_gt "$cur_ver" "$base_ver" && continue
if [[ ${#BASES[@]} -gt 1 ]]; then
OFFENDERS+=("$dir: $base_ver -> $cur_ver (not above merge-base ${base_at[i]})")
else
OFFENDERS+=("$dir: $base_ver -> $cur_ver (not above merge-base)")
fi
done
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