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:
@@ -899,6 +899,45 @@ else
|
||||
pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metadata.version shape: no leading zeros, matching check-skill-version-bump
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- metadata.version with a leading zero is malformed ---"
|
||||
for version_case in "1.0.08:malformed" "01.0.1:malformed" "1.0.10:valid" "0.1.0:valid"; do
|
||||
version="${version_case%%:*}"
|
||||
expected="${version_case##*:}"
|
||||
VERSION_SKILL="$TMPDIR/version-$version"
|
||||
mkdir -p "$VERSION_SKILL"
|
||||
cat > "$VERSION_SKILL/SKILL.md" <<VERSIONEOF
|
||||
---
|
||||
name: version-skill
|
||||
description: A valid skill description that is well within the limit.
|
||||
metadata:
|
||||
version: "$version"
|
||||
---
|
||||
|
||||
## Step 1
|
||||
|
||||
Do the thing.
|
||||
VERSIONEOF
|
||||
set +e
|
||||
VERSION_OUT="$("$SCRIPT" "$VERSION_SKILL/SKILL.md" 2>&1)"
|
||||
VERSION_STATUS=$?
|
||||
set -e
|
||||
if [[ "$expected" == malformed ]]; then
|
||||
if [[ $VERSION_STATUS -ne 0 && "$VERSION_OUT" == *"metadata.version is malformed ('$version')"* ]]; then
|
||||
pass "'$version' is rejected as malformed"
|
||||
else
|
||||
fail "'$version' was not rejected as malformed (exit $VERSION_STATUS): ${VERSION_OUT:-<empty>}"
|
||||
fi
|
||||
elif [[ "$VERSION_OUT" == *"metadata.version is malformed"* ]]; then
|
||||
fail "'$version' was wrongly rejected as malformed: $VERSION_OUT"
|
||||
else
|
||||
pass "'$version' is accepted"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -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 ]]
|
||||
|
||||
Reference in New Issue
Block a user