feat(gates): enforce metadata.version bumps on changed skills at pre-push

check-skill-version-bump fails a push when a skill directory changed
against its merge-base with main (tests/ excluded) without a strictly
higher metadata.version than main. New, renamed and deleted skills are
exempt; every plugin is covered. Recorded as a dated section in
ADR-0022 and documented in gates.md.

Patch-bumps the 17 skills that changed on this branch without a bump,
so the branch passes its own gate. Simplification audit finding 33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:09:56 +00:00
parent 69119f4754
commit 8451169d2b
22 changed files with 497 additions and 22 deletions

View File

@@ -192,6 +192,19 @@ repos:
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: check-skill-version-bump
name: Check changed skills bump metadata.version
description: On every push, fail if a skill directory changed (tests/ excluded) since the merge-base with main without its SKILL.md metadata.version rising (ADR-0022)
entry: bash scripts/check-skill-version-bump.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
# Baseline is the merge-base with origin/main (falling back to main),
# not the remote branch tip: readers install from main. Fails closed
# when no main ref resolves. Merges through Gitea's merge button run no
# local hook, so they bypass this, just as they bypass check-release-needed.
- id: validate-marketplace - id: validate-marketplace
name: Validate marketplace manifest name: Validate marketplace manifest
description: Run claude plugin validate --strict on the root marketplace manifest description: Run claude plugin validate --strict on the root marketplace manifest

View File

@@ -76,3 +76,39 @@ own SKILL.md drops the "with `metadata.version` present" conditional in its bump
presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended
to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule
with nothing enforcing it drifts the same way an unstated one does. with nothing enforcing it drifts the same way an unstated one does.
## Amendment (2026-09-16) — the bump is enforced at push, not only required to exist
**Context.** Making the field mandatory did not make it move. The only thing that bumped it was
`skill-author` Step 4, so every hand edit and every trim pass skipped the bump: on
`docs/simplification-audit`, 17 of the 40 skill directories that changed against `main` carried
the same `metadata.version` as `main`, and `gitea` alone sat at six different values. Both
validators checked presence and semver shape, never movement, so the field could not answer the
question this ADR gives it — "did this change since I last read it". (Simplification audit
finding 33.)
**Decision.** `scripts/check-skill-version-bump.sh` runs as a pre-push hook on every push. For each
skill directory under `plugins/*/.apm/skills/` that differs between the pushed ref and its
merge-base with `main`, ignoring `tests/`, the pushed `metadata.version` must be strictly greater
than `main`'s.
- **Baseline is the merge-base with `main`, not the previous commit.** Readers only ever see
`main` — installs resolve against the default branch (ADR-0018) — so one bump per branch is what
the field owes them. A per-commit check would bump a skill once per commit and inflate the
number past meaning.
- **"Greater", not "exactly one patch higher".** A second `skill-author` pass on the same branch
that bumps again still passes.
- **`tests/` is excluded.** No agent loads it; a fixture-only change does not change the skill.
- **Skills absent from either side are exempt.** New, renamed and merged skills start fresh under
the rules above; deleted skills have nothing to check.
- **Every plugin is covered, `bin` included,** and the gate is repo-local — it is not exported
through `.pre-commit-hooks.yaml`.
**Considered options.** *Declare the field advisory* — cheapest, but concedes the field cannot do
its job. *Drop the field* — rejected by this ADR already, and costlier now. *Check at commit
time against `HEAD`* — rejected for the inflation above.
**Consequences.** The 17 unbumped skills took a patch bump in the commit that added the gate.
Like `check-release-needed`, the gate only fires on a local `git push` through pre-commit: a merge
made with Gitea's merge button runs no local hooks and is not checked. A typo fix in a skill now
costs a version bump; that is the rule working, not noise.

View File

@@ -21,24 +21,24 @@ Install hooks via `pc-run`, wiring **all three stages**. This repo's `.pre-commi
`default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits)
and `pre-push` (everything below). and `pre-push` (everything below).
The pre-push command reports **10** hooks, not 8. The extra two are pre-commit's own `meta` hooks, The pre-push command reports **11** hooks, not 9. The extra two are pre-commit's own `meta` hooks,
`check-hooks-apply` and `check-useless-excludes`: they declare no `stages:`, so they run at every `check-hooks-apply` and `check-useless-excludes`: they declare no `stages:`, so they run at every
stage including this one. Both are declared in this repo's `.pre-commit-config.yaml` like everything stage including this one. Both are declared in this repo's `.pre-commit-config.yaml` like everything
else — what separates them is `repo: meta` (pre-commit's own built-ins) from `repo: local`. Eight else — what separates them is `repo: meta` (pre-commit's own built-ins) from `repo: local`. Nine
is the count of hooks this repo authors itself. is the count of hooks this repo authors itself.
**The caveat: one of those 8 is a silent no-op under that invocation.** **The caveat: one of those 9 is a silent no-op under that invocation.**
`check-release-needed` exits 0 immediately unless `PRE_COMMIT_REMOTE_BRANCH` equals `check-release-needed` exits 0 immediately unless `PRE_COMMIT_REMOTE_BRANCH` equals
`refs/heads/main`, and pre-commit exports that variable only from the real pre-push git hook during `refs/heads/main`, and pre-commit exports that variable only from the real pre-push git hook during
an actual `git push`. Running the stage by hand — or from a CI runner — therefore reports it an actual `git push`. Running the stage by hand — or from a CI runner — therefore reports it
`Passed` having checked nothing. That is by design for feature branches — pushing WIP must not be `Passed` having checked nothing. That is by design for feature branches — pushing WIP must not be
blocked on cutting a premature tag — but it means `--hook-stage pre-push --all-files` is a full blocked on cutting a premature tag — but it means `--hook-stage pre-push --all-files` is a full
rehearsal of 7 hooks and a skip of the eighth. The script's own header records the same gap for rehearsal of 8 hooks and a skip of the ninth. The script's own header records the same gap for
a PR merged through Gitea's merge button, where no local push happens at all. a PR merged through Gitea's merge button, where no local push happens at all.
## The pre-push gate ## The pre-push gate
Eight hooks, grouped below by what they guard rather than by the order `.pre-commit-config.yaml` declares them in. Nine hooks, grouped below by what they guard rather than by the order `.pre-commit-config.yaml` declares them in.
**Core checks** **Core checks**
@@ -80,12 +80,30 @@ drift in generated text.
| Hook | Guards | | Hook | Guards |
|---|---| |---|---|
| `check-release-needed` | on a real `git push` to `main` only — fails if files exposed via `.pre-commit-hooks.yaml` changed since the last tag. A no-op everywhere else, including under `pre-commit run --hook-stage pre-push` (see [the caveat above](#running-the-gates)) | | `check-release-needed` | on a real `git push` to `main` only — fails if files exposed via `.pre-commit-hooks.yaml` changed since the last tag. A no-op everywhere else, including under `pre-commit run --hook-stage pre-push` (see [the caveat above](#running-the-gates)) |
| `check-skill-version-bump` | on every push — fails if a skill directory changed since the merge-base with `main` without its `metadata.version` rising (see [below](#check-skill-version-bump)) |
Two of these shell out to `apm`: `apm-audit-ci` and `apm-pack-check-clean`. The second is a bare Two of these shell out to `apm`: `apm-audit-ci` and `apm-pack-check-clean`. The second is a bare
`apm …` entry and the first is a `bash -c` loop calling `apm` once per package, so without the CLI `apm …` entry and the first is a `bash -c` loop calling `apm` once per package, so without the CLI
the push dies with an unhelpful "command not found". Install with `apm-install`, or the push dies with an unhelpful "command not found". Install with `apm-install`, or
`curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`. `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`.
### `check-skill-version-bump`
ADR-0022 makes `metadata.version` mandatory and a skill change carries a bump; `skill-size-check`
only checks the field's presence and shape, so this hook holds the bump itself.
- **Baseline is `git merge-base origin/main <pushed ref>`** (local `main` if `origin/main` does not
resolve). Readers install from `main`, so "changed" means changed against what `main` ships. The
remote branch tip is not the baseline: a second push would excuse an unbumped change the first
push already carried. With no `main` ref or no merge-base, the hook fails closed.
- **A changed skill must end strictly above its baseline version**, compared numerically
(`1.0.10` > `1.0.9`). Any bump size passes. A missing or non-`MAJOR.MINOR.PATCH` version at the
pushed ref fails. Skills absent at the baseline (new, renamed, merged) or at the pushed ref
(deleted) are exempt.
- **`<skill>/tests/` is excluded**: no agent loads it, so a test-only change ships nothing.
- **Known gap:** a PR merged through Gitea's merge button runs no local hook, the same gap
`check-release-needed` has.
## Skill and agent context gates (ADR-0020) ## Skill and agent context gates (ADR-0020)
The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`, The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`,

View File

@@ -5,7 +5,7 @@ description: >
Ultra-compressed output mode that drops articles, filler and pleasantries while Ultra-compressed output mode that drops articles, filler and pleasantries while
keeping technical substance exact, cutting token usage by roughly 75%. keeping technical substance exact, cutting token usage by roughly 75%.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
--- ---
Respond terse like smart caveman. All technical substance stay. Only fluff die. Respond terse like smart caveman. All technical substance stay. Only fluff die.

View File

@@ -6,7 +6,7 @@ description: >
decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs -> decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs ->
`grill-with-docs`. `grill-with-docs`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
--- ---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

View File

@@ -5,7 +5,7 @@ description: >
the interview challenges terms against `CONTEXT.md` and writes decisions into the interview challenges terms against `CONTEXT.md` and writes decisions into
it and into ADRs as they land. Not a plain interview -> `grill-me`. it and into ADRs as they land. Not a plain interview -> `grill-me`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
--- ---
<what-to-do> <what-to-do>

View File

@@ -6,7 +6,7 @@ description: >
variations. Not production code -> `tdd`. Not talking a design through -> variations. Not production code -> `tdd`. Not talking a design through ->
`grill-me`. `grill-me`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
--- ---
# Prototype # Prototype

View File

@@ -6,7 +6,7 @@ description: >-
documentation written from existing code or specs -> `write-docs`. Not a bug documentation written from existing code or specs -> `write-docs`. Not a bug
or incident -> `diagnose`. or incident -> `diagnose`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: research category: research
allowed-tools: allowed-tools:
- Grep - Grep

View File

@@ -9,7 +9,7 @@ description: >
updated: 2026-05-17 updated: 2026-05-17
when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: implement category: implement
source: source:
- repo: anthropics/skills - repo: anthropics/skills

View File

@@ -14,7 +14,7 @@ metadata:
- context7-websites-agents-md - context7-websites-agents-md
- context7-agentsmd-agents-md - context7-agentsmd-agents-md
- governance-secrets-hard-prohibition - governance-secrets-hard-prohibition
version: "0.1.2" version: "0.1.3"
--- ---
## Gotchas ## Gotchas

View File

@@ -12,7 +12,7 @@ metadata:
- agents-md-official - agents-md-official
- context7-websites-agents-md - context7-websites-agents-md
- context7-agentsmd-agents-md - context7-agentsmd-agents-md
version: "0.1.2" version: "0.1.3"
--- ---
## Gotchas ## Gotchas

View File

@@ -11,7 +11,7 @@ metadata:
category: docs category: docs
source_keys: source_keys:
- adr-0002-0003-two-tier-claude-md - adr-0002-0003-two-tier-claude-md
version: "0.1.1" version: "0.1.2"
--- ---
## Gotchas ## Gotchas

View File

@@ -9,7 +9,7 @@ description: >
Not the superproject's own remotes -> `git-remotes`. Not the superproject's own remotes -> `git-remotes`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: git category: git
source_keys: source_keys:
- git-scm-submodule-docs - git-scm-submodule-docs

View File

@@ -6,7 +6,7 @@ description: >
shellcheck"). Not running, installing, or updating hooks -> `pc-run`. shellcheck"). Not running, installing, or updating hooks -> `pc-run`.
allowed-tools: Bash Read Write Edit allowed-tools: Bash Read Write Edit
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: devtools category: devtools
source_keys: source_keys:
- context7-pre-commit-com - context7-pre-commit-com

View File

@@ -8,7 +8,7 @@ description: >
compatibility: Requires pre-commit installed and available on PATH. compatibility: Requires pre-commit installed and available on PATH.
metadata: metadata:
version: "1.0.1" version: "1.0.2"
category: devtools category: devtools
source_keys: source_keys:
- context7-pre-commit-com - context7-pre-commit-com

View File

@@ -12,7 +12,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
metadata: metadata:
category: integration category: integration
version: "0.1.3" version: "0.1.4"
source_keys: source_keys:
- gitea-mcp-repo - gitea-mcp-repo
- gitea-mcp-slim-go - gitea-mcp-slim-go

View File

@@ -6,7 +6,7 @@ description: >
authoring, publishing, auditing, or dependency installation for an apm authoring, publishing, auditing, or dependency installation for an apm
package -> `apm-workflow`. package -> `apm-workflow`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: apm category: apm
source_keys: source_keys:
- context7-microsoft-apm - context7-microsoft-apm

View File

@@ -5,7 +5,7 @@ description: >
the dependencies it declares, or an apm marketplace — even when the user does the dependencies it declares, or an apm marketplace — even when the user does
not say "apm". Not the apm binary or an agent runtime -> `apm-install`. not say "apm". Not the apm binary or an agent runtime -> `apm-install`.
metadata: metadata:
version: "1.0.0" version: "1.0.1"
category: apm category: apm
source_keys: source_keys:
- context7-microsoft-apm - context7-microsoft-apm

View File

@@ -8,7 +8,7 @@ description: >
metadata: metadata:
category: lint category: lint
version: "0.1.2" version: "0.1.3"
source_keys: source_keys:
- context7-websites-vale-sh - context7-websites-vale-sh
- house-vale-3-15-2-repro - house-vale-3-15-2-repro

View File

@@ -6,7 +6,7 @@ description: >
as in "lint the docs", "check prose style", or "why is CI failing on the docs as in "lint the docs", "check prose style", or "why is CI failing on the docs
check". Not setting up Vale config or styles -> `vale-config`. check". Not setting up Vale config or styles -> `vale-config`.
metadata: metadata:
version: "0.1.3" version: "0.1.4"
category: lint category: lint
source_keys: source_keys:
- context7-websites-vale-sh - context7-websites-vale-sh

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
set -euo pipefail
# Fails a push when a skill changed without its SKILL.md `metadata.version`
# being bumped. ADR-0022 makes the field mandatory and the bump the rule; this
# is the gate that holds the rule, since skill-size-check only checks presence
# and shape.
#
# Baseline: `git merge-base <main> <pushed ref>`. Readers install skills from
# main, so "changed" means changed relative to what main ships, not relative to
# the remote branch's current tip. Diffing from PRE_COMMIT_FROM_REF would let
# the second push of a feature branch excuse a change the first push already
# carried unbumped. Unlike check-release-needed this runs on EVERY push, not
# only pushes to main: a missing bump is cheapest to fix on the branch, before
# review, and a push to main is then already covered.
#
# Scope: every skill directory plugins/<plugin>/.apm/skills/<skill>/, bin
# included. Anything under <skill>/tests/ is ignored — no agent ever loads it,
# so a test-only change ships nothing to a reader. A skill counts as changed
# when any other file under its directory differs between baseline and pushed
# ref. Renames are diffed as delete + add (--no-renames), so:
# - a skill absent at the baseline (new, renamed-to, merged-into) is exempt;
# it has no prior version to exceed.
# - a skill absent at the pushed ref (deleted, renamed-from) is exempt;
# there is nothing left to version.
# A changed skill present at both refs must carry a three-part semver
# `metadata.version` at the pushed ref (same SEMVER_RE as skill-size-check.sh,
# so pre-release suffixes are rejected here as they are there) that is
# numerically greater than the baseline's. A baseline with no parseable
# version (a skill predating ADR-0022) accepts any valid version. Versions are
# read from git objects, never the working tree.
#
# Missing baseline: if neither origin/main nor main resolves, or no merge-base
# exists (shallow clone, unrelated history), the gate FAILS closed — the same
# choice check-release-needed makes for an unreadable tag. Passing would make
# a fresh clone without main the one place the rule is silently off.
#
# Known gap: this only fires on a local `git push` through pre-commit's
# pre-push hook (or a manual `pre-commit run --hook-stage pre-push`). A PR
# merged via Gitea's merge button runs no local hook at all — closing that
# requires a server-side CI job, which this repo does not have yet.
# See check-release-needed.sh: PRE_COMMIT_TO_REF is the local sha actually
# being pushed, which is only HEAD for the common case.
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
# All-zeros sha: the push deletes a branch. Nothing ships; bail out.
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
exit 0
fi
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
MAIN_REF=""
for candidate in origin/main main; do
if git rev-parse --verify -q "$candidate^{commit}" > /dev/null; then
MAIN_REF="$candidate"
break
fi
done
if [[ -z "$MAIN_REF" ]]; then
echo "FAIL: neither origin/main nor main resolves, so there is no baseline to compare skill versions against." >&2
echo " Fix: git fetch origin main (or create a local main) and retry." >&2
exit 1
fi
if ! BASELINE="$(git merge-base "$MAIN_REF" "$PUSHED_REF" 2>/dev/null)"; then
echo "FAIL: no merge-base between $MAIN_REF and $PUSHED_REF, so there is no baseline to compare skill versions against." >&2
echo " Fix: ensure full history is available (e.g. git fetch --unshallow) and retry." >&2
exit 1
fi
if ! CHANGED="$(git diff --no-renames --name-only "$BASELINE" "$PUSHED_REF" -- plugins)"; then
echo "FAIL: could not diff $BASELINE..$PUSHED_REF (see git error above)." >&2
exit 1
fi
SKILL_PATH_RE='^(plugins/[^/]+/\.apm/skills/[^/]+)/(.+)$'
SKILL_DIRS=()
while IFS= read -r path; do
[[ "$path" =~ $SKILL_PATH_RE ]] || continue
[[ "${BASH_REMATCH[2]}" == tests/* ]] && continue
dir="${BASH_REMATCH[1]}"
seen=false
for existing in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
[[ "$existing" == "$dir" ]] && { seen=true; break; }
done
$seen || SKILL_DIRS+=("$dir")
done <<< "$CHANGED"
[[ ${#SKILL_DIRS[@]} -eq 0 ]] && exit 0
# Prints the three-part semver metadata.version from SKILL.md on stdin, or
# nothing when it is missing or malformed. Same acceptance rule as
# skill-size-check.sh: str()-coerce, strip whitespace and quotes, then
# ^\d+\.\d+\.\d+$ — so `1.0` (a YAML float) and `1.0.0-rc1` both print nothing.
read_version() {
python3 -c '
import re, sys, yaml
text = sys.stdin.read()
m = re.match(r"^---\s*\n(.*?)\n---\s*(\n|$)", text, re.S)
if not m:
sys.exit(0)
try:
data = yaml.safe_load(m.group(1))
except Exception:
sys.exit(0)
meta = data.get("metadata") if isinstance(data, dict) else None
ver = meta.get("version") if isinstance(meta, dict) else None
if ver is None:
sys.exit(0)
ver = str(ver).strip().strip("\x27\"")
if re.match(r"^\d+\.\d+\.\d+$", ver):
print(ver)
'
}
# Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH, compared numerically so
# 1.0.10 > 1.0.9. 10# forces base 10 on a leading zero.
semver_gt() {
local -a a b
local i
IFS=. read -ra a <<< "$1"
IFS=. read -ra b <<< "$2"
for i in 0 1 2; do
if (( 10#${a[i]} > 10#${b[i]} )); then return 0; fi
if (( 10#${a[i]} < 10#${b[i]} )); then return 1; fi
done
return 1
}
OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
# Absent at baseline: new, renamed-to, or merged-into. Exempt.
git cat-file -e "$BASELINE:$dir/SKILL.md" 2>/dev/null || continue
# Absent at pushed ref: deleted or renamed-from. Exempt.
[[ "$(git cat-file -t "$PUSHED_REF:$dir" 2>/dev/null)" == "tree" ]] || continue
base_ver="$(git show "$BASELINE:$dir/SKILL.md" | read_version)"
cur_ver="$(git show "$PUSHED_REF:$dir/SKILL.md" 2>/dev/null | read_version || true)"
if [[ -z "$cur_ver" ]]; then
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${base_ver:-none})")
elif [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then
OFFENDERS+=("$dir: $base_ver -> $cur_ver")
fi
done
if [[ ${#OFFENDERS[@]} -gt 0 ]]; then
echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version bump (ADR-0022):" >&2
printf ' %s\n' ${OFFENDERS[@]+"${OFFENDERS[@]}"} >&2
echo " Fix: raise metadata.version in each SKILL.md above the baseline — bump PATCH at minimum." >&2
exit 1
fi

252
tests/test-skill-version-bump.sh Executable file
View File

@@ -0,0 +1,252 @@
#!/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 tests/test-check-release-needed.sh).
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)
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" "$(cd "$F" && git rev-parse main)"
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. baseline is the merge-base, not 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"
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"
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 "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]