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:
2026-09-16 11:24:08 +00:00
parent b426460f75
commit 1d40544075
8 changed files with 446 additions and 87 deletions

View File

@@ -217,13 +217,22 @@ 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. baseline is the merge-base, not main's tip ---"
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_pass "bump over the merge-base passes even though main is ahead" "$F"
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 ---"
@@ -241,6 +250,8 @@ 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 ---"
@@ -271,7 +282,8 @@ 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" "Fix: git fetch origin main" "$F"
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 ---"
@@ -287,9 +299,10 @@ expect_pass "already-merged content (merge-base == pushed) passes against origin
"$(cd "$F" && git rev-parse main~1)"
echo ""
echo "--- 23. version shape is ASCII-only and bounded ---"
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"'; do
'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)"
@@ -303,6 +316,9 @@ 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 ---"
@@ -372,7 +388,7 @@ write_skill "$F" demo alpha 'version: "1.0.0"' "orphan"; commit "$F" "orphan roo
expect_fail "unrelated history fails at the merge-base check" "no merge-base between main and HEAD" "$F"
echo ""
echo "--- 28. annotated tag push is peeled to its commit ---"
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)
@@ -383,6 +399,16 @@ 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 ---"
@@ -405,6 +431,142 @@ expect_fail "unbumped CRLF skill reports both parsed versions" "alpha: 1\.0\.0 -
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 "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]