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 intersectionea119d8added 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, andea119d8set the precedent by fixing the same failure class script-only.1614bceverified 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
710 lines
31 KiB
Bash
Executable File
710 lines
31 KiB
Bash
Executable File
#!/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 ]]
|