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:
@@ -67,23 +67,14 @@ it's asked, not one that varies by plugin domain.
|
||||
create/improve pass owes a bump, so the 12 skills carrying it are not tracking dead weight — removing
|
||||
it discards real revision signal for no gain.
|
||||
|
||||
## Consequences
|
||||
|
||||
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
|
||||
value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md`
|
||||
moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s
|
||||
own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
`main`, and `gitea` alone sat at six different values. Both validators —
|
||||
`scripts/skill-size-check.sh` (the pre-commit hook) and `factory-audit`'s
|
||||
`scripts/lib-checks-skill.sh` — 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.)
|
||||
|
||||
`scripts/check-skill-version-bump.sh` now runs as a pre-push hook on every push, whatever the
|
||||
@@ -91,7 +82,8 @@ target branch. It takes its baseline from the merge-base of the pushed commit wi
|
||||
(local `main` if `origin/main` does not resolve). For each skill directory under
|
||||
`plugins/*/.apm/skills/` that differs between the pushed commit and that merge-base, ignoring
|
||||
`tests/`, the pushed `metadata.version` must be strictly greater than the version the skill had at
|
||||
the merge-base — not the version on `main`'s current tip.
|
||||
the merge-base — not the version on `main`'s current tip. (The amendment below reverses that last
|
||||
choice: the pushed version must now also exceed `main`'s tip.)
|
||||
|
||||
The baseline is the merge-base, 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
|
||||
@@ -106,8 +98,10 @@ through `.pre-commit-hooks.yaml`.
|
||||
The gate fails closed rather than passing when it has no trustworthy baseline: when neither
|
||||
`origin/main` nor `main` resolves, when the pushed commit shares no merge-base with it, and when
|
||||
only local `main` resolves and the pushed commit is that merge-base, since a local `main` the
|
||||
pushed commit already contains is no independent record of what shipped. It reads versions with
|
||||
`python3` and PyYAML and fails with a clear message if either is missing.
|
||||
pushed commit already contains is no independent record of what shipped. It also fails closed when
|
||||
the pushed ref does not resolve to a commit, and when a `SKILL.md` the tree names cannot be read by
|
||||
`git show` or parsed by `python3` — a read failure is reported as such, never as a missing version.
|
||||
It reads versions with `python3` and PyYAML and fails with a clear message if either is missing.
|
||||
|
||||
Three alternatives were rejected. Declaring the field advisory is the cheapest, but concedes the
|
||||
field cannot do its job. Dropping the field was rejected by this ADR already, and costs more now.
|
||||
@@ -120,6 +114,46 @@ The gate differs from `check-release-needed` in when it runs: that hook acts onl
|
||||
reports a push to `main`, so a manual `pre-commit run --hook-stage pre-push` skips it, while this
|
||||
gate runs there too and checks `HEAD`. The two hooks share both known gaps. A merge made with
|
||||
Gitea's merge button runs no local hooks, so it is not checked. And pre-commit's pre-push
|
||||
integration checks only the first ref with new commits in a multi-ref push (`git push origin a b`,
|
||||
`git push --all`): `_pre_push_ns` in pre-commit's `hook_impl.py` returns after that ref, so the
|
||||
other refs are pushed unchecked.
|
||||
integration checks only one ref of a multi-ref push (`git push origin a b`, `git push --all`).
|
||||
In pre-commit 4.6.1, `_pre_push_ns` in `hook_impl.py` skips delete lines and returns on the first
|
||||
remaining ref whose remote sha is non-zero and present locally; a ref whose remote sha is zero or
|
||||
unknown locally is returned only if it has commits that no remote-tracking ref of that remote has.
|
||||
Every later ref is pushed unchecked. When the returned ref's unpushed history reaches a root
|
||||
commit, pre-commit sets no `PRE_COMMIT_TO_REF` at all, so the gate checks `HEAD`, which is the
|
||||
pushed ref only if it is checked out.
|
||||
|
||||
## Amendment (2026-09-16): the pushed version must also exceed `main`'s tip
|
||||
|
||||
This reverses the choice above that the baseline is the merge-base "not the version on `main`'s
|
||||
current tip". A changed skill's pushed `metadata.version` must now be strictly greater than **both**
|
||||
its version at the merge-base and its version at the tip of `origin/main` (local `main` under the
|
||||
same fallback, with the same fail-closed rules).
|
||||
|
||||
The merge-base alone lets two branches ship two different changes under one version. Branches A and
|
||||
B both start from a skill at `1.0.0`, change it differently, and bump it to `1.0.1`. A merges. B's
|
||||
merge-base is still the `1.0.0` commit, so B passes, and the two `1.0.1` bumps are the same line
|
||||
change, so git merges B without a conflict. `main` then carries two different `1.0.1` contents, and
|
||||
the field again fails to answer "did this change since I last read it". Checking against the tip as
|
||||
well makes B fail until it bumps past `1.0.1`.
|
||||
|
||||
- **A skill absent at the tip** (deleted on `main` since the branch started) is held to the
|
||||
merge-base rule alone. **A skill absent at both** is new and stays exempt.
|
||||
- **When `main` has not moved since the merge-base**, the two baselines are the same commit and the
|
||||
skill is checked once.
|
||||
- **The failure names the baseline it missed**: `(not above merge-base)` or
|
||||
`(not above origin/main tip)`, one line per baseline missed.
|
||||
|
||||
The cost is that a branch behind `main` may have to bump again after another branch lands a bump
|
||||
on the same skill. That is the case the rule exists for, and rebasing onto or merging `main` first
|
||||
shows the version to beat. The rule reads `origin/main` as last fetched, so a tip that moved since
|
||||
the last fetch is not seen until the next one.
|
||||
|
||||
## Consequences
|
||||
|
||||
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
|
||||
value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md`
|
||||
moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s
|
||||
own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since
|
||||
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
|
||||
with nothing enforcing it drifts the same way an unstated one does.
|
||||
|
||||
@@ -37,10 +37,15 @@ rehearsal of 8 hooks and a skip of the ninth. The script's own header records th
|
||||
a PR merged through Gitea's merge button, where no local push happens at all.
|
||||
|
||||
A real push has a gap of its own. When one `git push` carries several refs
|
||||
(`git push origin a b`, `git push --all`), pre-commit runs the pre-push stage once, for the first
|
||||
ref that has new commits: `_pre_push_ns` in pre-commit's `hook_impl.py` returns as soon as it finds
|
||||
that ref. The two hooks that read the pushed ref — `check-release-needed` and
|
||||
(`git push origin a b`, `git push --all`), pre-commit runs the pre-push stage once, for one ref.
|
||||
In pre-commit 4.6.1, `_pre_push_ns` in `hook_impl.py` skips delete lines and returns on the first
|
||||
remaining ref whose remote sha is non-zero and present locally; a ref whose remote sha is zero or
|
||||
unknown locally is returned only if it has commits that no remote-tracking ref of that remote has.
|
||||
The two hooks that read the pushed ref — `check-release-needed` and
|
||||
`check-skill-version-bump` — therefore check only that ref, and the others are pushed unchecked.
|
||||
When that ref's unpushed history reaches a root commit, pre-commit runs with all files and sets no
|
||||
`PRE_COMMIT_TO_REF`, so `check-skill-version-bump` checks `HEAD`, which is the pushed ref only if it
|
||||
is checked out.
|
||||
For `check-release-needed`, a multi-ref push whose first ref is not `main` never gates `main` at
|
||||
all. Push one ref at a time when the gate matters.
|
||||
|
||||
@@ -87,7 +92,7 @@ drift in generated text.
|
||||
|
||||
| Hook | Guards |
|
||||
|---|---|
|
||||
| `check-skill-version-bump` | fails if a skill directory changed since the pushed commit's merge-base with `main` without its `metadata.version` rising above the merge-base's (see [below](#check-skill-version-bump)) |
|
||||
| `check-skill-version-bump` | fails if a skill directory changed since the pushed commit's merge-base with `main` without its `metadata.version` rising above both the merge-base's and `main`'s tip's (see [below](#check-skill-version-bump)) |
|
||||
|
||||
**Release**
|
||||
|
||||
@@ -108,32 +113,46 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
|
||||
- **It runs on every push and under a manual `pre-commit run --hook-stage pre-push`.** It does not
|
||||
read `PRE_COMMIT_REMOTE_BRANCH`, so unlike `check-release-needed` the manual rehearsal really
|
||||
checks it. The pushed commit is `PRE_COMMIT_TO_REF`, or `HEAD` when that is unset.
|
||||
- **Baseline is the merge-base of the pushed commit with `origin/main`** (local `main` if
|
||||
`origin/main` does not resolve). The pushed version is compared with the skill's version *at
|
||||
that merge-base*, not with `main`'s current tip. Readers install from `main`, so "changed" means
|
||||
- **"Changed" is measured from the merge-base of the pushed commit with `origin/main`** (local
|
||||
`main` if `origin/main` does not resolve). Readers install from `main`, so "changed" means
|
||||
changed against the `main` the branch started from. The remote branch tip is not the baseline:
|
||||
a second push would excuse an unbumped change the first push already carried.
|
||||
- **A changed skill's version must beat two baselines**: its version at that merge-base *and* its
|
||||
version at the tip of the same `main` ref (ADR-0022's second 2026-09-16 amendment). The tip
|
||||
check stops two branches that make the same bump (`1.0.0` → `1.0.1`) with different content from
|
||||
both landing, since the identical version lines merge without a conflict. A skill absent at the
|
||||
tip is held to the merge-base alone; when `main` has not moved, the two are the same commit. Each
|
||||
failure line names the baseline it missed: `(not above merge-base)` or
|
||||
`(not above origin/main tip)`. The tip is `origin/main` as last fetched.
|
||||
- **It fails closed when it has no trustworthy baseline:** neither `origin/main` nor `main`
|
||||
resolves; there is no merge-base (shallow clone, unrelated history); or only local `main`
|
||||
resolves and the pushed commit *is* the merge-base, so local `main` already contains the pushed
|
||||
commit and says nothing independent about what shipped.
|
||||
- **A changed skill must end strictly above its baseline version**, compared numerically
|
||||
- **A changed skill must end strictly above each 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, and so does a skill directory left without its `SKILL.md`. Versions are
|
||||
ASCII-only with at most nine digits per part — stricter than `skill-size-check`'s shape check,
|
||||
so a Unicode digit or an overflowing part cannot pass as a bump. Skills absent at the baseline
|
||||
(new, renamed, merged) or at the pushed ref (deleted) are exempt.
|
||||
- **It also fails closed on read errors:** a pushed ref that does not resolve to a commit, or a
|
||||
`SKILL.md` that `git show` or `python3` cannot read, stops the push with a read-failure message
|
||||
rather than being reported as a missing version. Frontmatter that reads but does not parse
|
||||
pushed ref fails, and so does a skill directory left without its `SKILL.md`. Each part is ASCII
|
||||
digits, at most nine of them, with no leading zero (`1.0.08` is malformed) — the same shape
|
||||
`skill-size-check` enforces, so a Unicode digit, an overflowing part or an octal-looking part
|
||||
cannot pass as a bump. A leading UTF-8 BOM is ignored. A baseline with no valid version accepts
|
||||
any valid version. Skills absent at both baselines (new, renamed, merged) or at the pushed ref
|
||||
(deleted, or replaced by a symlink) are exempt. A file moved between skills counts as a change
|
||||
to both: renames are diffed as delete plus add. A mode-only change counts too.
|
||||
- **It also fails closed on read errors:** a pushed ref that does not resolve to a commit
|
||||
(including a tag on a tree), or a `SKILL.md` that the tree names but `git show` or `python3`
|
||||
cannot read, stops the push with a read-failure message rather than being reported as a missing
|
||||
version or treated as an absent skill. Presence is read from the tree, so a blob missing from a
|
||||
corrupt or partial clone cannot make a skill look new. Frontmatter that reads but does not parse
|
||||
counts as an invalid version.
|
||||
- **`<skill>/tests/` is excluded**: no agent loads it, so a test-only change ships nothing.
|
||||
- **It needs `python3` and PyYAML** to read the frontmatter, and fails with a clear message if
|
||||
either is missing, for the reasons in
|
||||
[`python3` and PyYAML are hard requirements](#python3-and-pyyaml-are-hard-requirements).
|
||||
- **Known gaps, both shared with `check-release-needed`:** a PR merged through Gitea's merge button
|
||||
runs no local hook; and a multi-ref push checks only its first ref with new commits (see
|
||||
runs no local hook; and a multi-ref push checks only the one ref pre-commit selects, and a push
|
||||
reaching a root commit gets no `PRE_COMMIT_TO_REF`, so `HEAD` is checked (see
|
||||
[Running the gates](#running-the-gates)).
|
||||
- **An all-zeros `PRE_COMMIT_TO_REF` (a branch delete) exits 0.** The branch is defensive:
|
||||
pre-commit 4.6.1 skips delete lines before it sets the variable.
|
||||
|
||||
## Skill and agent context gates (ADR-0020)
|
||||
|
||||
|
||||
@@ -235,7 +235,9 @@ else:
|
||||
# --- ADR-0022: metadata.version is mandatory -------------------------------
|
||||
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
|
||||
# `.pre-commit-config.yaml`'s `skill-size-check` hook REJECTS a SKILL.md with
|
||||
# no `metadata.version`, and rejects a value that is not three-part semver.
|
||||
# no `metadata.version`, and rejects a value that is not three-part semver:
|
||||
# ASCII digits only, no leading zero (semver 2.0.0 item 2), at most nine digits
|
||||
# per part (bash arithmetic in check-skill-version-bump.sh), whole-value match.
|
||||
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
|
||||
# tier below FAIL lets that step report done on a skill the commit gate then
|
||||
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
|
||||
@@ -246,8 +248,10 @@ else:
|
||||
# The rule is DUPLICATED from that hook for the same cache-isolation reason as
|
||||
# every other constant here — an installed plugin's scripts cannot read the
|
||||
# repo-root config. Keep the two in step: this check must accept exactly what
|
||||
# the hook accepts.
|
||||
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
|
||||
# the hook accepts. Used with fullmatch(), never match() with ^...$ anchors:
|
||||
# `$` also matches before a trailing newline, and `\d` also matches non-ASCII
|
||||
# Unicode digits — both of which the hook rejects.
|
||||
SEMVER_RE = re.compile(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})')
|
||||
|
||||
try:
|
||||
fm_data = yaml.safe_load(fm)
|
||||
@@ -268,13 +272,15 @@ else:
|
||||
# spelling is exactly the two-part value the hook rejects — coercing and
|
||||
# then matching keeps this check and the hook agreeing on that case.
|
||||
version_text = version_value if isinstance(version_value, str) else str(version_value)
|
||||
version_text = version_text.strip()
|
||||
if SEMVER_RE.match(version_text):
|
||||
# Same normalisation as the hook: surrounding whitespace, then quotes.
|
||||
version_text = version_text.strip().strip('\'"')
|
||||
if SEMVER_RE.fullmatch(version_text):
|
||||
ok(f"metadata.version present: '{version_text}' (ADR-0022)")
|
||||
else:
|
||||
fail(f"metadata.version '{version_text}' is not three-part semver — the "
|
||||
f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH, "
|
||||
f"e.g. \"1.0.0\"")
|
||||
f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH "
|
||||
f"with ASCII digits, no leading zeros and at most nine digits per "
|
||||
f"part, e.g. \"1.0.0\"")
|
||||
|
||||
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
||||
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
||||
|
||||
@@ -377,7 +377,7 @@ SH
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-0022 — metadata.version is mandatory. FAIL tier, matching the
|
||||
# skill-frontmatter pre-commit hook: an audit that graded this lower would
|
||||
# skill-size-check pre-commit hook: an audit that graded this lower would
|
||||
# report ready-to-ship on a file the commit gate rejects.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -437,6 +437,62 @@ PY
|
||||
assert_output --partial "metadata.version present: '0.1.3'"
|
||||
}
|
||||
|
||||
@test "ADR-0022: a leading zero in the patch part FAILs (1.0.08)" {
|
||||
local skill="$TMPDIR/my-skill"
|
||||
make_valid_skill "$skill"
|
||||
python3 - "$skill/SKILL.md" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "1.0.08"\n')
|
||||
open(p, 'w').write(s)
|
||||
PY
|
||||
run bash "$SCRIPT" "$skill"
|
||||
assert_failure
|
||||
assert_output --partial "three-part semver"
|
||||
}
|
||||
|
||||
@test "ADR-0022: a leading zero in the major part FAILs (01.0.1)" {
|
||||
local skill="$TMPDIR/my-skill"
|
||||
make_valid_skill "$skill"
|
||||
python3 - "$skill/SKILL.md" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "01.0.1"\n')
|
||||
open(p, 'w').write(s)
|
||||
PY
|
||||
run bash "$SCRIPT" "$skill"
|
||||
assert_failure
|
||||
assert_output --partial "three-part semver"
|
||||
}
|
||||
|
||||
@test "ADR-0022: a multi-digit part with no leading zero passes (1.0.10)" {
|
||||
local skill="$TMPDIR/my-skill"
|
||||
make_valid_skill "$skill"
|
||||
python3 - "$skill/SKILL.md" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "1.0.10"\n')
|
||||
open(p, 'w').write(s)
|
||||
PY
|
||||
run bash "$SCRIPT" "$skill"
|
||||
assert_success
|
||||
assert_output --partial "metadata.version present: '1.0.10'"
|
||||
}
|
||||
|
||||
@test "ADR-0022: a zero major part passes (0.1.0)" {
|
||||
local skill="$TMPDIR/my-skill"
|
||||
make_valid_skill "$skill"
|
||||
python3 - "$skill/SKILL.md" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "0.1.0"\n')
|
||||
open(p, 'w').write(s)
|
||||
PY
|
||||
run bash "$SCRIPT" "$skill"
|
||||
assert_success
|
||||
assert_output --partial "metadata.version present: '0.1.0'"
|
||||
}
|
||||
|
||||
@test "fails when name contains consecutive hyphens" {
|
||||
local skill="$TMPDIR/my--skill"
|
||||
make_valid_skill "$skill"
|
||||
|
||||
@@ -17,6 +17,14 @@ set -euo pipefail
|
||||
# PRE_COMMIT_TO_REF is set). A missing bump is cheapest to fix on the branch,
|
||||
# before review.
|
||||
#
|
||||
# Second baseline: the tip of that same <main> ref. A changed skill's pushed
|
||||
# version must exceed its version there too (ADR-0022, second 2026-09-16
|
||||
# amendment). Two branches bumping 1.0.0 -> 1.0.1 with different content merge
|
||||
# without a conflict, so the merge-base alone would let main ship both under
|
||||
# one version. When <main> has not moved since the merge-base, the two
|
||||
# baselines are one commit and the skill is checked once. The tip is read as
|
||||
# last fetched.
|
||||
#
|
||||
# Pushing main itself: with origin/main as the baseline, a push of main diffs
|
||||
# the new commits against what the remote already has, so it is covered. A
|
||||
# pushed commit that is already an ancestor of origin/main (merge-base equals
|
||||
@@ -31,36 +39,44 @@ set -euo pipefail
|
||||
# when any other file under its directory differs between baseline and pushed
|
||||
# commit. Paths are read NUL-delimited (`git diff -z`), so core.quotePath never
|
||||
# hides a non-ASCII path. 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 both baselines (new, renamed-to, merged-into) is
|
||||
# exempt; it has no prior version to exceed. Absent at the tip only
|
||||
# (deleted on main since): the merge-base rule alone applies.
|
||||
# - a file moved from one skill to another changes both.
|
||||
# - a skill directory absent at the pushed commit (deleted, renamed-from) is
|
||||
# exempt; there is nothing left to version. A directory that survives
|
||||
# without its SKILL.md is NOT exempt: it fails as "SKILL.md missing".
|
||||
# A changed skill present at both refs must carry a three-part semver
|
||||
# `metadata.version` at the pushed commit that is numerically greater than the
|
||||
# baseline's. The shape rule follows skill-size-check.sh (str()-coerce, strip
|
||||
# whitespace and quotes, three dot-separated numbers, so `1.0` and `1.0.0-rc1`
|
||||
# are rejected) but is deliberately stricter: ASCII digits only (Python's `\d`
|
||||
# also matches e.g. U+FF11) and at most 9 digits per part, so every part fits
|
||||
# bash arithmetic. A baseline with no valid version (a skill predating
|
||||
# exempt; there is nothing left to version. A directory replaced by a
|
||||
# symlink is no longer a tree, so it counts as deleted (apm drops symlinks
|
||||
# under .apm/, ADR-0017). A directory that survives without its SKILL.md
|
||||
# is NOT exempt: it fails as "SKILL.md missing".
|
||||
# Presence is read from the tree (rev-parse <commit>:<path>), not from the
|
||||
# blob, so a blob missing from a corrupt or partial clone is a read failure,
|
||||
# never a skill that looks new.
|
||||
# A changed skill must carry a three-part semver `metadata.version` at the
|
||||
# pushed commit that is numerically greater than each baseline's. The shape
|
||||
# rule matches skill-size-check.sh (str()-coerce, strip whitespace and quotes,
|
||||
# so `1.0` and `1.0.0-rc1` are rejected): each part is ASCII digits (Python's
|
||||
# `\d` also matches e.g. U+FF11), at most 9 of them so it fits bash
|
||||
# arithmetic, with no leading zero so bash never reads it as octal. A leading
|
||||
# UTF-8 BOM is ignored. A baseline with no valid version (a skill predating
|
||||
# ADR-0022) accepts any valid version. Versions are read from git objects,
|
||||
# never the working tree.
|
||||
#
|
||||
# Fails closed: if neither origin/main nor main resolves, if no merge-base
|
||||
# exists (shallow clone, unrelated history), if the pushed ref does not resolve
|
||||
# to a commit, if python3 or PyYAML is unavailable, or if a SKILL.md cannot be
|
||||
# read. Passing in any of those would make that environment the one place the
|
||||
# rule is silently off.
|
||||
# to a commit (an unknown sha, a tag on a tree), if python3 or PyYAML is
|
||||
# unavailable, or if a SKILL.md the tree names cannot be read. Passing in any
|
||||
# of those would make that environment the one place the rule is silently off.
|
||||
#
|
||||
# Known gaps:
|
||||
# - Only the first pushed ref is gated. pre-commit (4.x, hook_impl.py
|
||||
# `_pre_push_ns`) consumes the pre-push stdin itself and builds the
|
||||
# environment from the first ref line that is not a delete and has commits
|
||||
# the remote lacks; every later ref in the same `git push` (e.g.
|
||||
# `git push origin a b`, `--all`, `--tags`) is never seen. When that first
|
||||
# ref's unpushed history reaches a root commit, pre-commit runs with
|
||||
# all_files and sets no PRE_COMMIT_TO_REF at all, so this script checks
|
||||
# HEAD — which is the pushed ref only if it happens to be checked out. The
|
||||
# - Only one pushed ref is gated. pre-commit (4.6.1, hook_impl.py
|
||||
# `_pre_push_ns`) consumes the pre-push stdin itself and walks the ref
|
||||
# lines in order: it skips deletes, returns on a ref whose remote sha is
|
||||
# non-zero and exists locally, and otherwise returns on the ref only if it
|
||||
# has commits no remote-tracking ref of that remote has. Every other ref in
|
||||
# the same `git push` (e.g. `git push origin a b`, `--all`, `--tags`) is
|
||||
# never seen. When the selected ref's unpushed history reaches a root
|
||||
# commit, pre-commit runs with all_files and sets no PRE_COMMIT_TO_REF at
|
||||
# all, so this script checks HEAD — the pushed ref only if checked out. The
|
||||
# script cannot recover either case: the ref list is gone by the time it
|
||||
# runs. Push refs one at a time to be sure each is checked.
|
||||
# - A PR merged via Gitea's merge button runs no local hook at all (the same
|
||||
@@ -76,7 +92,10 @@ export LC_ALL=C
|
||||
# peeled to a commit below before use.
|
||||
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
||||
|
||||
# All-zeros sha: the push deletes a branch. Nothing ships; bail out.
|
||||
# All-zeros sha: the push deletes a branch, so nothing ships. Defensive only:
|
||||
# pre-commit 4.6.1's `_pre_push_ns` already skips delete lines and never passes
|
||||
# one here. Kept so a different caller cannot turn a delete into a rev-parse
|
||||
# failure.
|
||||
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
@@ -160,7 +179,7 @@ done < "$CHANGED_FILE"
|
||||
read_version() {
|
||||
python3 -c '
|
||||
import re, sys, yaml
|
||||
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
||||
text = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
||||
m = re.match(r"---[ \t\r]*\n(.*?)\n---[ \t\r]*(\n|\Z)", text, re.S)
|
||||
data = None
|
||||
if m:
|
||||
@@ -171,7 +190,7 @@ if m:
|
||||
meta = data.get("metadata") if isinstance(data, dict) else None
|
||||
ver = meta.get("version") if isinstance(meta, dict) else None
|
||||
ver = None if ver is None else str(ver).strip().strip("\x27\"")
|
||||
if ver is not None and re.fullmatch(r"[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}", ver):
|
||||
if ver is not None and re.fullmatch(r"(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})", ver):
|
||||
print("OK " + ver)
|
||||
else:
|
||||
print("INVALID")
|
||||
@@ -192,31 +211,47 @@ version_at() {
|
||||
}
|
||||
|
||||
# Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH with parts of at most 9 ASCII
|
||||
# digits, compared numerically so 1.0.10 > 1.0.9. 10# forces base 10 on a
|
||||
# leading zero.
|
||||
# digits and no leading zero (so bash never reads a part as octal), compared
|
||||
# numerically so 1.0.10 > 1.0.9.
|
||||
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
|
||||
if (( a[i] > b[i] )); then return 0; fi
|
||||
if (( a[i] < b[i] )); then return 1; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
MAIN_TIP="$(git rev-parse --verify -q "$MAIN_REF^{commit}")"
|
||||
|
||||
# in_tree <commit> <path>: the tree names <path>. Unlike `git cat-file -e`, it
|
||||
# does not need the blob itself, so a blob a corrupt or partial clone lacks is a
|
||||
# read failure in version_at, not a skill that silently looks absent.
|
||||
in_tree() {
|
||||
git rev-parse --verify -q "$1:$2" > /dev/null
|
||||
}
|
||||
|
||||
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
|
||||
at_base=false
|
||||
at_tip=false
|
||||
in_tree "$BASELINE" "$dir/SKILL.md" && at_base=true
|
||||
# When main has not moved since the merge-base, the tip is the same baseline.
|
||||
[[ "$MAIN_TIP" != "$BASELINE" ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" && at_tip=true
|
||||
# Absent at both baselines: new, renamed-to, or merged-into. Exempt.
|
||||
$at_base || $at_tip || continue
|
||||
# Directory absent at pushed commit: deleted or renamed-from. Exempt.
|
||||
[[ "$(git cat-file -t "$PUSHED_COMMIT:$dir" 2>/dev/null)" == "tree" ]] || continue
|
||||
|
||||
version_at "$BASELINE" "$dir/SKILL.md"
|
||||
base_ver="$VERSION"
|
||||
base_ver=""
|
||||
tip_ver=""
|
||||
if $at_base; then version_at "$BASELINE" "$dir/SKILL.md"; base_ver="$VERSION"; fi
|
||||
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
|
||||
|
||||
if ! git cat-file -e "$PUSHED_COMMIT:$dir/SKILL.md" 2>/dev/null; then
|
||||
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
|
||||
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${base_ver:-none})")
|
||||
continue
|
||||
fi
|
||||
@@ -225,14 +260,19 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
|
||||
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")
|
||||
continue
|
||||
fi
|
||||
if [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then
|
||||
OFFENDERS+=("$dir: $base_ver -> $cur_ver (not above merge-base)")
|
||||
fi
|
||||
if [[ -n "$tip_ver" ]] && ! semver_gt "$cur_ver" "$tip_ver"; then
|
||||
OFFENDERS+=("$dir: $tip_ver -> $cur_ver (not above $MAIN_REF tip)")
|
||||
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
|
||||
echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version above both that merge-base and the $MAIN_REF tip (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
|
||||
echo " Fix: raise metadata.version in each SKILL.md above the baseline named — bump PATCH at minimum." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1374,7 +1374,10 @@ for path in files:
|
||||
if version_val is None:
|
||||
error("%s: metadata.version field is missing (required frontmatter "
|
||||
"field, e.g. \"1.0.0\")." % path)
|
||||
elif not re.match(r'^\d+\.\d+\.\d+$', str(version_val).strip().strip('\'"')):
|
||||
# Same shape as check-skill-version-bump.sh: ASCII digits, at most nine per
|
||||
# part (bash arithmetic), no leading zero (semver 2.0.0 item 2).
|
||||
elif not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',
|
||||
str(version_val).strip().strip('\'"')):
|
||||
error("%s: metadata.version is malformed (%r) -- expected a "
|
||||
"three-part semver, e.g. \"1.0.0\"." % (path, version_val))
|
||||
|
||||
|
||||
@@ -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