fix(gates): hold skill versions above main's tip as well as the merge-base
Why: two branches that both bump a skill 1.0.0 -> 1.0.1 with different content merge without a conflict, and each passed the gate against its own merge-base, so main could ship two changes under one version. Implementation Notes: - check-skill-version-bump requires the pushed version to exceed both the merge-base and the main tip; failures name the baseline they missed. - Presence is read from the tree, so a blob missing from a partial clone is a read failure instead of a silently exempt "new" skill. - A leading UTF-8 BOM no longer reads as a missing version. - Version parts reject leading zeros in all three validators (check-skill-version-bump, skill-size-check, factory-audit). - New tests cover equal bumps, moved files, major/minor ordering, bad refs, unreadable blobs, mode-only changes, symlinks and tag peeling. Impact: ADR-0022 amended (reverses "not main's current tip"); gates.md updated to match, including pre-commit 4.6.1's exact ref selection. ADR: 0022 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,14 @@ set -euo pipefail
|
||||
# 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
|
||||
@@ -31,36 +39,44 @@ set -euo pipefail
|
||||
# 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 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 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
|
||||
# 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, 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.
|
||||
# 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 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
|
||||
# - 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 (the same
|
||||
@@ -76,7 +92,10 @@ export LC_ALL=C
|
||||
# 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.
|
||||
# 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
|
||||
@@ -160,7 +179,7 @@ done < "$CHANGED_FILE"
|
||||
read_version() {
|
||||
python3 -c '
|
||||
import re, sys, yaml
|
||||
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
||||
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:
|
||||
@@ -171,7 +190,7 @@ if m:
|
||||
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):
|
||||
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")
|
||||
@@ -192,31 +211,47 @@ version_at() {
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 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 (( 10#${a[i]} > 10#${b[i]} )); then return 0; fi
|
||||
if (( 10#${a[i]} < 10#${b[i]} )); then return 1; fi
|
||||
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
|
||||
# Absent at baseline: new, renamed-to, or merged-into. Exempt.
|
||||
git cat-file -e "$BASELINE:$dir/SKILL.md" 2>/dev/null || continue
|
||||
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
|
||||
|
||||
version_at "$BASELINE" "$dir/SKILL.md"
|
||||
base_ver="$VERSION"
|
||||
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 ! git cat-file -e "$PUSHED_COMMIT:$dir/SKILL.md" 2>/dev/null; then
|
||||
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
|
||||
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${base_ver:-none})")
|
||||
continue
|
||||
fi
|
||||
@@ -225,14 +260,19 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
|
||||
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")
|
||||
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 bump (ADR-0022):" >&2
|
||||
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 — bump PATCH at minimum." >&2
|
||||
echo " Fix: raise metadata.version in each SKILL.md above the baseline named — bump PATCH at minimum." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1374,7 +1374,10 @@ for path in files:
|
||||
if version_val is None:
|
||||
error("%s: metadata.version field is missing (required frontmatter "
|
||||
"field, e.g. \"1.0.0\")." % path)
|
||||
elif not re.match(r'^\d+\.\d+\.\d+$', str(version_val).strip().strip('\'"')):
|
||||
# Same shape as check-skill-version-bump.sh: ASCII digits, at most nine per
|
||||
# part (bash arithmetic), no leading zero (semver 2.0.0 item 2).
|
||||
elif not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',
|
||||
str(version_val).strip().strip('\'"')):
|
||||
error("%s: metadata.version is malformed (%r) -- expected a "
|
||||
"three-part semver, e.g. \"1.0.0\"." % (path, version_val))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user