Files
holocron/tests/test-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

710 lines
31 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/check-skill-version-bump.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# The gate reads versions with python3 + PyYAML, the same hard requirement
# skill-size-check carries. Without them this suite cannot run: exit 77 so
# run-tests reports SKIPPED (and --strict fails the push).
if ! python3 -c 'import yaml' 2>/dev/null; then
echo "SKIP: python3 with PyYAML is required" >&2
exit 77
fi
# Output assertions use here-strings, never `echo | grep -q` (pipefail race;
# see docs/spec/gates.md, Tests).
CLEANUP_DIRS=()
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
# write_skill <repo> <plugin> <skill> <version-line|""> [body]
# An empty version line writes a SKILL.md with no metadata block at all.
write_skill() {
local repo="$1" plugin="$2" skill="$3" vline="$4" body="${5:-body}"
local d="$repo/plugins/$plugin/.apm/skills/$skill"
mkdir -p "$d"
{
echo "---"
echo "name: $skill"
echo "description: Use when testing."
if [[ -n "$vline" ]]; then
echo "metadata:"
echo " $vline"
fi
echo "---"
echo "$body"
} > "$d/SKILL.md"
}
commit() { (cd "$1" && git add -A && git commit -q -m "${2:-change}"); }
# A fixture with skills alpha (1.0.0) and beta (1.0.9) on main, then checked out
# onto a feature branch.
make_fixture() {
local dir
dir="$(mktemp -d)"
CLEANUP_DIRS+=("$dir")
(cd "$dir" && git init -q -b main && git config user.email t@t.t && git config user.name t)
write_skill "$dir" demo alpha 'version: "1.0.0"'
write_skill "$dir" demo beta "version: 1.0.9"
commit "$dir" initial
(cd "$dir" && git checkout -q -b feature)
echo "$dir"
}
# Every PRE_COMMIT_* input comes from here: a value inherited from the pre-push
# hook running this suite names a sha of the real repo, not the fixture.
run_check() {
local dir="$1"
if [[ $# -ge 2 ]]; then
(cd "$dir" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_REMOTE_BRANCH \
&& PRE_COMMIT_TO_REF="$2" bash "$SCRIPT" 2>&1)
else
(cd "$dir" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
&& bash "$SCRIPT" 2>&1)
fi
}
# expect_pass <desc> <dir> [to-ref]
expect_pass() {
local desc="$1"; shift
local out
if out="$(run_check "$@")" && [[ -z "$out" ]]; then
pass "$desc"
else
fail "$desc — expected silent exit 0, got: $out"
fi
}
# expect_fail <desc> <pattern> <dir> [to-ref]
expect_fail() {
local desc="$1" pattern="$2"; shift 2
local out
if out="$(run_check "$@")"; then
fail "$desc — expected non-zero exit, got 0"
elif grep -qE "$pattern" <<< "$out"; then
pass "$desc"
else
fail "$desc — output did not match /$pattern/: $out"
fi
}
echo ""
echo "--- 1. unchanged skill passes ---"
F="$(make_fixture)"
echo "unrelated" > "$F/README.md"; commit "$F"
expect_pass "no skill change passes silently" "$F"
echo ""
echo "--- 2. changed without bump fails ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "new body"; commit "$F"
expect_fail "unbumped change fails and names baseline and current" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
echo ""
echo "--- 3. changed with patch / minor / major bump passes ---"
for v in 1.0.1 1.1.0 2.0.0; do
F="$(make_fixture)"
write_skill "$F" demo alpha "version: \"$v\"" "new body"; commit "$F"
expect_pass "bump to $v passes" "$F"
done
echo ""
echo "--- 4. version decreased fails ---"
F="$(make_fixture)"
write_skill "$F" demo beta "version: 1.0.8" "new body"; commit "$F"
expect_fail "decrease fails" "beta: 1\.0\.9 -> 1\.0\.8" "$F"
echo ""
echo "--- 5. change to a non-SKILL.md file still counts ---"
F="$(make_fixture)"
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/x.md"; commit "$F"
expect_fail "references/ change without bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
echo ""
echo "--- 6. tests/-only change passes without bump ---"
F="$(make_fixture)"
mkdir -p "$F/plugins/demo/.apm/skills/alpha/tests"
echo "t" > "$F/plugins/demo/.apm/skills/alpha/tests/test-x.sh"; commit "$F"
expect_pass "tests/-only change is exempt" "$F"
echo ""
echo "--- 7. new skill exempt ---"
F="$(make_fixture)"
write_skill "$F" demo gamma 'version: "0.1.0"'; commit "$F"
expect_pass "new skill passes" "$F"
echo ""
echo "--- 8. deleted skill exempt ---"
F="$(make_fixture)"
rm -rf "$F/plugins/demo/.apm/skills/alpha"; commit "$F"
expect_pass "deleted skill passes" "$F"
echo ""
echo "--- 9. renamed skill exempt ---"
F="$(make_fixture)"
(cd "$F" && git mv plugins/demo/.apm/skills/alpha plugins/demo/.apm/skills/alpha2)
commit "$F"
expect_pass "renamed skill passes (old absent at pushed, new absent at baseline)" "$F"
echo ""
echo "--- 10. missing version on changed skill fails ---"
F="$(make_fixture)"
write_skill "$F" demo alpha "" "new body"; commit "$F"
expect_fail "missing version fails" "alpha: metadata\.version missing" "$F"
echo ""
echo "--- 11. malformed / prerelease version fails ---"
for v in 'version: 1.1' 'version: "1.0.1-rc1"'; do
F="$(make_fixture)"
write_skill "$F" demo alpha "$v" "new body"; commit "$F"
expect_fail "'$v' is rejected like skill-size-check rejects it" "alpha: metadata\.version missing or not" "$F"
done
echo ""
echo "--- 12. multiple offenders all reported ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "x"
write_skill "$F" demo beta "version: 1.0.9" "x"
commit "$F"
OUT="$(run_check "$F" || true)"
if grep -q "alpha: 1.0.0 -> 1.0.0" <<< "$OUT" && grep -q "beta: 1.0.9 -> 1.0.9" <<< "$OUT" \
&& grep -q "bump PATCH at minimum" <<< "$OUT"; then
pass "both offenders and the fix are reported"
else
fail "not every offender reported: $OUT"
fi
echo ""
echo "--- 13. PRE_COMMIT_TO_REF respected over HEAD ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
BAD="$(cd "$F" && git rev-parse HEAD)"
(cd "$F" && git checkout -q -b clean main)
echo "unrelated" > "$F/README.md"; commit "$F"
CLEAN="$(cd "$F" && git rev-parse HEAD)"
expect_fail "unbumped TO_REF fails while HEAD is clean" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$BAD"
(cd "$F" && git checkout -q "$BAD")
expect_pass "clean TO_REF passes while HEAD is unbumped" "$F" "$CLEAN"
echo ""
echo "--- 14. all-zeros delete sha no-ops ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
expect_pass "40-zero sha exits 0" "$F" "0000000000000000000000000000000000000000"
expect_pass "64-zero sha exits 0" "$F" "$(printf '0%.0s' {1..64})"
echo ""
echo "--- 15. bin plugin covered ---"
F="$(make_fixture)"
write_skill "$F" bin tool 'version: "1.0.0"'
(cd "$F" && git checkout -q main); commit "$F" "add bin skill"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" bin tool 'version: "1.0.0"' "changed"; commit "$F"
expect_fail "bin skill change without bump fails" "plugins/bin/\.apm/skills/tool: 1\.0\.0 -> 1\.0\.0" "$F"
echo ""
echo "--- 16. multi-digit semver compare ---"
F="$(make_fixture)"
write_skill "$F" demo beta "version: 1.0.10" "x"; commit "$F"
expect_pass "1.0.10 > 1.0.9 passes" "$F"
echo ""
echo "--- 17. pushed version must also exceed main's tip ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
(cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "1.0.5"' "main moved on"; commit "$F"
(cd "$F" && git checkout -q feature)
expect_fail "bump over the merge-base fails when main's tip is higher" \
"alpha: 1\.0\.5 -> 1\.0\.1 \(not above main tip\)" "$F"
OUT="$(run_check "$F" || true)"
if grep -q "not above merge-base" <<< "$OUT"; then
fail "merge-base reported as failed although 1.0.1 > 1.0.0: $OUT"
else
pass "only the baseline actually failed is named"
fi
write_skill "$F" demo alpha 'version: "1.0.6"' "branch change 2"; commit "$F"
expect_pass "bump above both the merge-base and main's tip passes" "$F"
echo ""
echo "--- 18. origin/main preferred over local main ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "x"; commit "$F"
# A stale local main pointing at the feature tip would make the diff empty;
# origin/main at the original commit must win and still see the change.
(cd "$F" && git update-ref refs/remotes/origin/main main && git branch -f main feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "y"; commit "$F"
if OUT="$(run_check "$F")" && [[ -z "$OUT" ]]; then
pass "origin/main used as baseline (1.0.0 -> 1.0.1 counted as a bump)"
else
fail "unexpected output: $OUT"
fi
(cd "$F" && git update-ref refs/remotes/origin/main feature~1)
expect_fail "origin/main at the bumped commit flags the further unbumped change" \
"merge-base with origin/main" "$F"
expect_fail "the report names alpha and the versions read from origin/main's merge-base" \
"demo/\.apm/skills/alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base\)" "$F"
echo ""
echo "--- 19. no main ref fails closed ---"
F="$(mktemp -d)"; CLEANUP_DIRS+=("$F")
(cd "$F" && git init -q -b trunk && git config user.email t@t.t && git config user.name t)
write_skill "$F" demo alpha 'version: "1.0.0"'; commit "$F"
expect_fail "missing main fails with a clear message" "neither origin/main nor main resolves" "$F"
echo ""
echo "--- 20. non-ASCII paths are not hidden by core.quotePath ---"
F="$(make_fixture)"
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/résumé.md"; commit "$F"
expect_fail "non-ASCII file under references/ without bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo "café" 'version: "2.0.0"'; commit "$F" "add café"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo "café" 'version: "2.0.0"' "changed"; commit "$F"
expect_fail "non-ASCII skill dir without bump fails" "skills/café: 2\.0\.0 -> 2\.0\.0" "$F"
write_skill "$F" demo "café" 'version: "2.0.1"' "changed again"; commit "$F"
expect_pass "non-ASCII skill dir with bump passes" "$F"
echo ""
echo "--- 21. local main fallback: pushed commit already in main fails closed ---"
F="$(make_fixture)"
MAIN_SHA="$(cd "$F" && git rev-parse main)"
expect_fail "pushing main's sha without origin/main fails" \
"origin/main does not resolve.*already contained in local main" "$F" "$MAIN_SHA"
(cd "$F" && git checkout -q main)
expect_fail "HEAD on main without origin/main fails" \
"origin/main does not resolve and HEAD is already contained in local main" "$F"
echo ""
echo "--- 22. pushing main itself with origin/main present ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main && git update-ref refs/remotes/origin/main main)
expect_pass "main equal to origin/main passes (nothing changed vs main)" "$F"
write_skill "$F" demo alpha 'version: "1.0.1"' "x"; commit "$F"
expect_pass "main ahead of origin/main with a bump passes" "$F"
write_skill "$F" demo beta "version: 1.0.9" "x"; commit "$F"
expect_fail "main ahead of origin/main without a bump fails" "beta: 1\.0\.9 -> 1\.0\.9" "$F"
(cd "$F" && git update-ref refs/remotes/origin/main main)
expect_pass "already-merged content (merge-base == pushed) passes against origin/main" "$F" \
"$(cd "$F" && git rev-parse main~1)"
echo ""
echo "--- 23. version shape is ASCII-only, bounded, and has no leading zeros ---"
for v in 'version: "1.0.1"' 'version: "1.0.1"' 'version: "1.0.9999999999"' \
'version: "99999999999999999999.0.0"' 'version: "1.0.08"' 'version: "01.0.1"' \
'version: "1.00.1"'; do
F="$(make_fixture)"
write_skill "$F" demo alpha "$v" "new body"; commit "$F"
OUT="$(run_check "$F" || true)"
if grep -q "alpha: metadata\.version missing or not" <<< "$OUT" \
&& ! grep -qiE "integer|syntax error|value too great" <<< "$OUT"; then
pass "'$v' is rejected as invalid without a bash arithmetic error"
else
fail "'$v' not cleanly rejected: $OUT"
fi
done
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.999999999"' "new body"; commit "$F"
expect_pass "nine-digit part is accepted and compared" "$F"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.10"' "new body"; commit "$F"
expect_pass "a zero inside a part (1.0.10) is not a leading zero" "$F"
echo ""
echo "--- 24. python3 / PyYAML failures are never reported as a missing version ---"
REAL_PYTHON="$(command -v python3)"
SHIM_ROOT="$(mktemp -d)"; CLEANUP_DIRS+=("$SHIM_ROOT")
mkdir -p "$SHIM_ROOT/shadow" "$SHIM_ROOT/noyaml" "$SHIM_ROOT/crash"
printf 'raise ImportError("PyYAML deliberately unavailable in this fixture")\n' \
> "$SHIM_ROOT/shadow/yaml.py"
cat > "$SHIM_ROOT/noyaml/python3" <<EOF
#!/bin/sh
PYTHONPATH="$SHIM_ROOT/shadow\${PYTHONPATH:+:\$PYTHONPATH}" exec "$REAL_PYTHON" "\$@"
EOF
# Passes the up-front import probe, crashes on the real read.
cat > "$SHIM_ROOT/crash/python3" <<EOF
#!/bin/sh
[ "\$1" = "-c" ] && [ "\$2" = "import yaml" ] && exec "$REAL_PYTHON" "\$@"
echo "Traceback: simulated interpreter failure" >&2
exit 1
EOF
chmod +x "$SHIM_ROOT/noyaml/python3" "$SHIM_ROOT/crash/python3"
if PATH="$SHIM_ROOT/noyaml:$PATH" python3 -c 'import yaml' 2>/dev/null; then
fail "fixture check: the no-PyYAML shim still imports yaml — the next assertion would be vacuous"
else
pass "fixture check: the no-PyYAML shim makes 'import yaml' fail"
fi
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
OUT="$(cd "$F" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
&& PATH="$SHIM_ROOT/noyaml:$PATH" bash "$SCRIPT" 2>&1)" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "PyYAML is required" <<< "$OUT" && grep -q "Fix: python3 -m pip install PyYAML" <<< "$OUT" \
&& ! grep -q "Traceback" <<< "$OUT"; then
pass "missing PyYAML fails with FAIL + Fix, no traceback"
else
fail "missing PyYAML not reported cleanly (rc=$RC): $OUT"
fi
OUT="$(cd "$F" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
&& PATH="$SHIM_ROOT/crash:$PATH" bash "$SCRIPT" 2>&1)" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version" <<< "$OUT" \
&& ! grep -q "missing or not" <<< "$OUT"; then
pass "python3 crash during the read is a read failure, not a missing version"
else
fail "python3 crash misreported (rc=$RC): $OUT"
fi
echo ""
echo "--- 25. SKILL.md deleted but skill dir kept ---"
F="$(make_fixture)"
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/x.md"
rm "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; commit "$F"
expect_fail "missing SKILL.md is reported as such" "alpha: SKILL\.md missing at HEAD \(baseline: 1\.0\.0\)" "$F"
echo ""
echo "--- 26. baseline without a valid version accepts any valid version ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo legacy ""; commit "$F" "add legacy skill"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo legacy 'version: "0.0.1"' "changed"; commit "$F"
expect_pass "invalid baseline + valid current passes" "$F"
echo ""
echo "--- 27. no merge-base (unrelated histories) fails closed ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q --orphan unrelated && git rm -rq --cached . && rm -rf plugins)
write_skill "$F" demo alpha 'version: "1.0.0"' "orphan"; commit "$F" "orphan root"
expect_fail "unrelated history fails at the merge-base check" "no merge-base between main and HEAD" "$F"
echo ""
echo "--- 28. annotated tag objects as PRE_COMMIT_TO_REF ---"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
(cd "$F" && git tag -a v9 -m "tag" && git checkout -q main)
TAG_OBJ="$(cd "$F" && git rev-parse v9)"
if [[ "$(cd "$F" && git cat-file -t "$TAG_OBJ")" == "tag" ]]; then
pass "fixture check: TO_REF is a tag object, not a commit"
else
fail "fixture check: v9 is not an annotated tag object"
fi
expect_fail "unbumped change behind an annotated tag fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$TAG_OBJ"
# Peeling is what makes the local-main fallback's "pushed commit is the
# merge-base" test see through a tag: compared unpeeled, the tag's own sha
# never equals the merge-base and the empty diff would pass.
(cd "$F" && git tag -a on-main -m "tag" main)
expect_fail "a tag on local main's commit fails closed like the commit itself" \
"origin/main does not resolve and [0-9a-f]+ is already contained in local main" \
"$F" "$(cd "$F" && git rev-parse on-main)"
(cd "$F" && git tag -a tree-tag -m "tag" "main^{tree}")
expect_fail "a tag on a tree fails closed" "pushed ref [0-9a-f]+ does not resolve to a commit" \
"$F" "$(cd "$F" && git rev-parse tree-tag)"
echo ""
echo "--- 29. CRLF frontmatter is parsed ---"
# crlf <repo> <skill>: rewrite that skill's SKILL.md with CRLF line endings.
crlf() {
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
sed 's/$/\r/' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
}
F="$(make_fixture)"
(cd "$F" && git config core.autocrlf false && git checkout -q main)
crlf "$F" alpha; commit "$F" "alpha to CRLF"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo alpha 'version: "1.0.0"' "crlf body"; crlf "$F" alpha; commit "$F"
if grep -q $'\r' "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; then
pass "fixture check: SKILL.md carries CRLF"
else
fail "fixture check: SKILL.md has no CRLF"
fi
expect_fail "unbumped CRLF skill reports both parsed versions" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
write_skill "$F" demo alpha 'version: "1.0.1"' "crlf body 2"; crlf "$F" alpha; commit "$F"
expect_pass "bumped CRLF skill passes" "$F"
echo ""
echo "--- 30. identical bump already merged to main fails ---"
# Branches A and B both bump alpha 1.0.0 -> 1.0.1 with different content. The
# bumps do not conflict at merge, so without the tip rule main would ship two
# changes under one version.
F="$(make_fixture)"
(cd "$F" && git checkout -q -b branch-a main)
write_skill "$F" demo alpha 'version: "1.0.1"' "change A"; commit "$F"
(cd "$F" && git checkout -q main && git merge -q --no-ff -m "merge A" branch-a \
&& git update-ref refs/remotes/origin/main main && git checkout -q feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "change B"; commit "$F"
expect_fail "B's 1.0.1 fails against A's 1.0.1 on origin/main" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
write_skill "$F" demo alpha 'version: "1.0.2"' "change B 2"; commit "$F"
expect_pass "B at 1.0.2 passes" "$F"
echo ""
echo "--- 31. skill deleted on main's tip: only the merge-base rule applies ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
rm -rf "$F/plugins/demo/.apm/skills/alpha"; commit "$F" "drop alpha on main"
(cd "$F" && git checkout -q feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
expect_pass "bump over the merge-base passes when main's tip lacks the skill" "$F"
(cd "$F" && git checkout -q -b unbumped main~1)
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
expect_fail "unbumped change still fails against the merge-base" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
echo ""
echo "--- 32. UTF-8 BOM before the frontmatter is parsed ---"
# bom <repo> <skill>: prefix that skill's SKILL.md with a UTF-8 byte-order mark.
bom() {
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
{ printf '\xef\xbb\xbf'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
}
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "bom body"; bom "$F" alpha; commit "$F"
if [[ "$(head -c 3 "$F/plugins/demo/.apm/skills/alpha/SKILL.md" | od -An -tx1 | tr -d ' ')" == "efbbbf" ]]; then
pass "fixture check: SKILL.md starts with a BOM"
else
fail "fixture check: SKILL.md has no BOM"
fi
expect_fail "unbumped BOM skill reports its parsed version, not a missing one" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
write_skill "$F" demo alpha 'version: "1.0.1"' "bom body 2"; bom "$F" alpha; commit "$F"
expect_pass "bumped BOM skill passes" "$F"
echo ""
echo "--- 33. a file moved from one skill to another flags both ---"
# --no-renames: with rename detection, --name-only lists only the new path and
# alpha would lose a file without anyone noticing.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
printf 'ref line %s\n' 1 2 3 4 5 > "$F/plugins/demo/.apm/skills/alpha/references/x.md"
commit "$F" "alpha reference"
(cd "$F" && git checkout -q feature && git merge -q main)
mkdir -p "$F/plugins/demo/.apm/skills/beta/references"
(cd "$F" && git mv plugins/demo/.apm/skills/alpha/references/x.md plugins/demo/.apm/skills/beta/references/x.md)
write_skill "$F" demo beta "version: 1.0.10"; commit "$F"
expect_fail "alpha is flagged although only beta was bumped" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
echo ""
echo "--- 34. comparison is ordered major first ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "2.0.0"'; commit "$F" "alpha 2.0.0"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo alpha 'version: "1.9.0"' "new body"; commit "$F"
expect_fail "2.0.0 -> 1.9.0 fails although minor rose" "alpha: 2\.0\.0 -> 1\.9\.0" "$F"
echo ""
echo "--- 35. unresolvable PRE_COMMIT_TO_REF fails closed ---"
F="$(make_fixture)"
expect_fail "a sha absent from the repo fails" \
"pushed ref 1234567890abcdef1234567890abcdef12345678 does not resolve to a commit" \
"$F" "1234567890abcdef1234567890abcdef12345678"
echo ""
echo "--- 36. a SKILL.md git cannot read fails closed ---"
# drop_blob <repo> <rev:path>: delete that blob's loose object, as a corrupt or
# partial clone would lack it. The tree still names the file.
drop_blob() {
local sha
sha="$(cd "$1" && git rev-parse "$2")"
rm -f "$1/.git/objects/${sha:0:2}/${sha:2}"
}
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
drop_blob "$F" "HEAD:plugins/demo/.apm/skills/alpha/SKILL.md"
OUT="$(run_check "$F")" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version from [0-9a-f]*:plugins/demo/.apm/skills/alpha/SKILL.md" <<< "$OUT" \
&& grep -q "fatal: bad object" <<< "$OUT" && ! grep -qE "missing or not|SKILL\.md missing" <<< "$OUT"; then
pass "an unreadable pushed SKILL.md is a read failure carrying git's error"
else
fail "unreadable pushed SKILL.md misreported (rc=$RC): $OUT"
fi
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
drop_blob "$F" "main:plugins/demo/.apm/skills/alpha/SKILL.md"
OUT="$(run_check "$F")" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version" <<< "$OUT"; then
pass "an unreadable merge-base SKILL.md fails closed instead of exempting the skill"
else
fail "unreadable merge-base SKILL.md not caught (rc=$RC): $OUT"
fi
echo ""
echo "--- 37. a mode-only change counts as a change ---"
F="$(make_fixture)"
(cd "$F" && git config core.fileMode true)
chmod +x "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; commit "$F"
if [[ "$(cd "$F" && git diff --summary main HEAD)" == *"mode change 100644 => 100755"* ]]; then
pass "fixture check: the commit changes only the file mode"
else
fail "fixture check: no mode change recorded"
fi
expect_fail "chmod +x without a bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
echo ""
echo "--- 38. skill directory replaced by a symlink ---"
# Current behaviour, pinned: the path is no longer a tree at the pushed commit,
# so the skill is exempt as deleted. apm drops symlinks under .apm/ (ADR-0017),
# so readers do lose the skill.
F="$(make_fixture)"
rm -rf "$F/plugins/demo/.apm/skills/alpha"
ln -s beta "$F/plugins/demo/.apm/skills/alpha"; commit "$F"
if [[ "$(cd "$F" && git ls-tree HEAD plugins/demo/.apm/skills/alpha)" == 120000* ]]; then
pass "fixture check: alpha is committed as a symlink"
else
fail "fixture check: alpha is not a symlink in the commit"
fi
expect_pass "a skill replaced by a symlink is exempt as deleted" "$F"
echo ""
echo "--- 39. leading blank lines before the frontmatter are parsed ---"
# skill-size-check's FRONTMATTER_RE accepts whitespace before the opening
# `---`; this gate must read the same shape. Otherwise the pushed side reports
# "missing" for a file the commit hook accepted, and a baseline shaped this way
# reads as "no valid version", which lets any version pass.
# blank_lead <repo> <skill>: prefix that skill's SKILL.md with blank lines.
blank_lead() {
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
{ printf '\n \t\n'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
}
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
blank_lead "$F" alpha; commit "$F" "alpha leading blank"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo alpha 'version: "1.0.0"' "lead body"; blank_lead "$F" alpha; commit "$F"
expect_fail "unbumped skill with leading blank lines is held to its baseline version" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
write_skill "$F" demo alpha 'version: "1.0.1"' "lead body 2"; blank_lead "$F" alpha; commit "$F"
expect_pass "bumped skill with leading blank lines passes" "$F"
echo ""
echo "--- 40. a criss-cross history is judged against EVERY merge-base ---"
# Two merge bases, and which one plain `git merge-base` prints is git's choice,
# not a property of the history. The gate used to take that single answer, so
# the verdict turned on it: here `git diff main feature -- plugins` is EMPTY
# (main already carries the bump, via its merge of the feature branch) and the
# push still failed with "not above main tip", because the base git picked was
# the one that predates the bump. `--all` plus the intersection rule makes the
# answer the same whichever base git would have named.
#
# C0 alpha 1.0.0
# +-- feature: F1 bumps alpha to 1.0.1
# +-- main: M1 unrelated, then M2 merges F1
# feature: F2 merges M1 -> merge bases {M1, F1}
F="$(mktemp -d)"; CLEANUP_DIRS+=("$F")
(cd "$F" && git init -q -b main && git config user.email t@t.t && git config user.name t)
write_skill "$F" demo alpha 'version: "1.0.0"'; commit "$F" C0
(cd "$F" && git checkout -q -b feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F" F1
F1_SHA="$(cd "$F" && git rev-parse HEAD)"
(cd "$F" && git checkout -q main)
echo unrelated > "$F/m1.txt"; commit "$F" M1
M1_SHA="$(cd "$F" && git rev-parse HEAD)"
(cd "$F" && git merge -q --no-edit "$F1_SHA" -m M2 > /dev/null)
(cd "$F" && git checkout -q feature && git merge -q --no-edit "$M1_SHA" -m F2 > /dev/null)
BASES40="$(cd "$F" && git merge-base --all main feature | sort)"
if [[ "$(printf '%s\n' "$BASES40" | wc -l)" -eq 2 ]]; then
pass "fixture check: the history really does have two merge bases"
else
fail "fixture check: expected two merge bases, got: $BASES40"
fi
if [[ -z "$(cd "$F" && git diff main feature -- plugins)" ]]; then
pass "fixture check: nothing under plugins/ differs between main and the branch"
else
fail "fixture check: plugins/ differs between main and the branch, so this is not the case under test"
fi
expect_pass "a skill identical to main's tip passes whichever merge-base git would pick" "$F"
# And the ratchet still holds on the same shape: a further edit with no bump
# differs from BOTH bases, so it is not excused by the criss-cross.
write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F" F3
expect_fail "an unbumped edit on a criss-cross branch still fails, naming the baseline sha" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base [0-9a-f]{40}\)" "$F"
echo ""
echo "--- 41. a cherry-picked fix identical to main's tip passes ---"
# Case 40 pins the same property — content identical to main's tip ships
# nothing — for a criss-cross history, where the merge-base intersection alone
# already exempts the skill because one base carries that content. This is the
# LINEAR shape, where no base does: the branch was cut before the fix landed on
# main and then cherry-picked it, so the single merge-base predates the fix and
# the skill reaches the tip comparison carrying exactly the tip's version. The
# only escapes would be a spurious 1.0.2 — leaving main with two versions of
# identical content — or a rebase the push does not otherwise need.
#
# C0 alpha 1.0.0
# +-- main: FIX bumps alpha to 1.0.1 (origin/main)
# +-- feature: cherry-picks FIX
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "1.0.1"' "fixed body"; commit "$F" "fix alpha"
FIX_SHA="$(cd "$F" && git rev-parse HEAD)"
# -x: without it the picked commit can come out byte-identical to FIX — same
# tree, same parent, same author and committer second — and git reuses the sha,
# so the branch silently fast-forwards onto main and the case under test is
# gone. The trailer -x adds guarantees a distinct commit.
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature \
&& git cherry-pick -x "$FIX_SHA" > /dev/null)
if [[ "$(cd "$F" && git rev-parse HEAD)" != "$FIX_SHA" ]]; then
pass "fixture check: the cherry-pick made a distinct commit, not a fast-forward onto main"
else
fail "fixture check: the branch fast-forwarded onto main, so the tip is the merge-base"
fi
if [[ "$(cd "$F" && git merge-base --all origin/main HEAD | wc -l)" -eq 1 ]]; then
pass "fixture check: the linear history has exactly one merge-base"
else
fail "fixture check: expected one merge-base, got $(cd "$F" && git merge-base --all origin/main HEAD)"
fi
if [[ -z "$(cd "$F" && git diff origin/main HEAD -- plugins)" ]]; then
pass "fixture check: nothing under plugins/ differs between origin/main and the branch"
else
fail "fixture check: plugins/ differs, so this is not the case under test"
fi
expect_pass "a cherry-picked skill identical to main's tip passes without a further bump" "$F"
# The ratchet still holds on the same shape: a further edit is no longer
# identical to the tip, so the tip rule applies again.
write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F"
expect_fail "an unbumped edit on top of the cherry-pick still fails against the tip" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
echo ""
echo "--- 42. the named baseline is the tip when no merge-base carries the skill ---"
# A skill added on main after the branch was cut is absent at every merge-base,
# so only the tip names a version — and the tip's is the version the push is
# held to. Reporting "none" sends the author to the natural 1.0.0 and costs a
# second blocked push on the same mistake.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
write_skill "$F" demo delta "" "branch delta"; commit "$F" "add delta on branch"
expect_fail "a missing version names the tip's version, not 'none'" \
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: 3\.2\.1\)" "$F"
expect_fail "the baseline is never reported as none while the tip carries one" \
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: [0-9]" "$F"
# The same message shape for the SKILL.md-missing branch of the report.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
mkdir -p "$F/plugins/demo/.apm/skills/delta/references"
echo "ref" > "$F/plugins/demo/.apm/skills/delta/references/x.md"; commit "$F" "delta without SKILL.md"
expect_fail "a missing SKILL.md names the tip's version too" \
"delta: SKILL\.md missing at HEAD \(baseline: 3\.2\.1\)" "$F"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]