refactor(plugin): one definition of what ships, and the exclusion that makes the version check mean something (#3326)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 31s
Milestone 334 step 2. The set of files that reach a plugin install lived in
two hand-kept copies -- SHIPPED in check_plugin.py and the workflow's paths:
filter -- with a comment asking a human to keep them in step. That is the
shape #3127 section 3 warns about, and both copies had drifted.
The load-bearing change is the exclusion. The version check reads "did
shipped content change against the base?", and plugin.json lives INSIDE
plugin/ -- so bumping the version is itself a change to the set, which then
reads as the change that justifies the bump. Every bump passed, no bump could
ever fail, and the check proved nothing while looking green.
manifest_differs_beyond_version compares parsed objects with `version`
dropped from both sides. One field, never the whole file: plugin.json also
carries description, mcpServers and userConfig, all of which reach an install,
and excluding the file wholesale would let a userConfig-only edit compute an
unchanged version and never refresh -- #2209 again with a narrower trigger.
Unreadable input answers "changed", because a spurious bump costs one cache
refresh while a missed one is the fix reaching the repo and stopping there.
shipped_content_changed returns None, not False, when the diff fails. #2663 is
why: a read that failed inside a broad except reported the same zero as an
empty window, and every counter read zero for weeks.
Two dead trigger paths removed, both found by writing the guard rather than by
review. fable-mcp/** outlived its directory by three months (deleted in
91bafb6, 2026-05-27) and assets/** named a path that never existed at all. A
paths: entry matching nothing never fires, so neither ever failed anything.
Their two orphaned bump scripts go with them -- a third manual-bump mechanism,
wired into no settings file.
DERIVERS is section 3's (deriver -> artifacts whose identity it decides) table.
The membership test is "can changing this file change what the artifact says
about itself?", not "is it copied in" -- a deriver is never in the COPY list.
A checker is not a deriver, which is why check_plugin.py is absent from it;
step 3's mint script adds its own row.
The workflow's paths: filter is YAML and cannot import Python, so "one
definition" is held by drift tests rather than an import. Said plainly in the
test module, because it is the honest shape rather than the ideal one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
|
||||
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
|
||||
|
||||
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
|
||||
major=$(echo "$current" | cut -d. -f1)
|
||||
minor=$(echo "$current" | cut -d. -f2)
|
||||
patch=$(echo "$current" | cut -d. -f3)
|
||||
new_version="$major.$minor.$((patch + 1))"
|
||||
|
||||
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
|
||||
git -C "$REPO_ROOT" add "$FILE"
|
||||
echo "fable-mcp: $current → $new_version"
|
||||
+127
-14
@@ -50,9 +50,45 @@ PLUGIN_DIR = ROOT / "plugin"
|
||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Paths whose contents reach an install. Keep in step with the workflow's
|
||||
# `paths:` filter — a path that ships but isn't checked here is the gap again.
|
||||
SHIPPED = ("plugin", ".claude-plugin")
|
||||
# ── What ships, and what decides what it says about itself ─────────────────
|
||||
#
|
||||
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
|
||||
# need different granularities, and conflating them is the bug:
|
||||
#
|
||||
# the workflow's `paths:` trigger whole paths should CI run at all?
|
||||
# the version check paths MINUS should the version
|
||||
# the manifest have moved?
|
||||
# `version`
|
||||
#
|
||||
# The second one is why this is not just a tuple of paths. `plugin.json` lives
|
||||
# INSIDE `plugin/`, so a version bump is itself a change to the shipped set —
|
||||
# and a check that reads the set naively then treats the bump as its own
|
||||
# justification. Any bump passes, no bump fails, and it has proved nothing.
|
||||
# `shipped_content_changed` below is the exclusion-aware reader.
|
||||
#
|
||||
# The exclusion is that ONE FIELD, never the whole file: `plugin.json` also
|
||||
# carries description, mcpServers and userConfig, all of which reach an
|
||||
# install and all of which matter. Excluding the file wholesale would mean a
|
||||
# userConfig-only edit computes an unchanged version and never refreshes —
|
||||
# #2209 again with a narrower trigger.
|
||||
SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
||||
|
||||
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
|
||||
# table so the next artifact is a one-line addition rather than a third
|
||||
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
|
||||
# the artifact?" but "can changing this file change the published bytes, or
|
||||
# what the artifact says about itself?" — FC learned that twice in four days
|
||||
# (#3156, #3202), and a deriver is never in the COPY list.
|
||||
#
|
||||
# Note what is absent: a CHECKER does not belong here. Whatever validates a
|
||||
# version decides whether the lane goes red, not what any artifact reports,
|
||||
# so `check_plugin.py` itself is not a deriver — the plugin's mint script
|
||||
# (milestone 334 step 3) will be, and adds its own row.
|
||||
DERIVERS: dict[str, tuple[str, ...]] = {
|
||||
# The "Generate image tags and version" step computes the server image's
|
||||
# name, ordering key and channel (#3298).
|
||||
".forgejo/workflows/ci.yml": ("server-image",),
|
||||
}
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
@@ -391,23 +427,95 @@ def _git(*args: str) -> tuple[int, str]:
|
||||
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
def manifest_text(ref: str | None = None) -> str | None:
|
||||
"""The manifest's RAW TEXT at `ref`, or in the working tree when ref is None.
|
||||
|
||||
Split out from `manifest_version` because the exclusion below needs every
|
||||
field except one, not the one field.
|
||||
"""
|
||||
if ref is None:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text()).get("version")
|
||||
except Exception:
|
||||
return MANIFEST.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||
code, out = _git("show", f"{ref}:{rel}")
|
||||
if code != 0:
|
||||
return out if code == 0 else None
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
text = manifest_text(ref)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out).get("version")
|
||||
return json.loads(text).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Distinct from None, which is a legitimate "this manifest does not exist".
|
||||
_UNREADABLE = object()
|
||||
|
||||
|
||||
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
|
||||
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
|
||||
|
||||
THE exclusion, and it is kept pure — no git, no filesystem — because this
|
||||
is the half worth testing hard and it needs no repository to exercise.
|
||||
|
||||
Compares PARSED objects rather than text, so reformatting, key reordering
|
||||
and whitespace do not read as content changes. `version` is dropped from
|
||||
both sides; everything else counts, which is what keeps a userConfig-only
|
||||
or mcpServers-only edit demanding a new version.
|
||||
|
||||
Unreadable input answers True. The conservative direction is "demand a new
|
||||
version": a spurious bump costs one cache refresh, while a missed one is
|
||||
#2209 — the fix reaches the repo and stops there.
|
||||
"""
|
||||
def without_version(text: str | None):
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return _UNREADABLE
|
||||
if not isinstance(data, dict):
|
||||
return _UNREADABLE
|
||||
return {k: v for k, v in data.items() if k != "version"}
|
||||
|
||||
left, right = without_version(a), without_version(b)
|
||||
if left is _UNREADABLE or right is _UNREADABLE:
|
||||
return True
|
||||
return left != right
|
||||
|
||||
|
||||
def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
||||
"""Has anything that REACHES AN INSTALL changed against `base`?
|
||||
|
||||
Returns `(changed, paths)`. `changed` is **None** when the question could
|
||||
not be answered — a caller must never read that as "no", which is the
|
||||
distinction #2663 cost weeks of zeroed telemetry to learn.
|
||||
|
||||
The manifest is special-cased, not excluded: if it is the ONLY thing that
|
||||
moved and the only difference is `version`, nothing that reaches an
|
||||
install has changed. Any other manifest field, or any other file, counts.
|
||||
"""
|
||||
code, out = _git("diff", "--name-only", base, "--", *SHIPPED_PATHS)
|
||||
if code != 0:
|
||||
return None, []
|
||||
paths = [p for p in out.splitlines() if p.strip()]
|
||||
if not paths:
|
||||
return False, []
|
||||
|
||||
rel_manifest = MANIFEST.relative_to(ROOT).as_posix()
|
||||
if paths == [rel_manifest]:
|
||||
return manifest_differs_beyond_version(
|
||||
manifest_text(), manifest_text(base)
|
||||
), paths
|
||||
return True, paths
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
|
||||
@@ -416,6 +524,11 @@ def check_version_bump(base: str = "origin/main") -> None:
|
||||
actually matters is that whatever reaches an install carries a version the
|
||||
installer can tell apart from the one already cached. One bump per batch,
|
||||
which is also how a human would do it.
|
||||
|
||||
Reads the set through `shipped_content_changed`, so a commit whose ONLY
|
||||
change is the version field does not count as content moving. Without that
|
||||
the check is circular — the bump edits a file inside `plugin/`, which then
|
||||
reads as the change that justifies the bump.
|
||||
"""
|
||||
code, _ = _git("rev-parse", "--verify", base)
|
||||
if code != 0:
|
||||
@@ -429,11 +542,11 @@ def check_version_bump(base: str = "origin/main") -> None:
|
||||
)
|
||||
return
|
||||
|
||||
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
|
||||
if code != 0:
|
||||
fail(f"git diff against {base} failed: {changed}")
|
||||
changed, paths = shipped_content_changed(base)
|
||||
if changed is None:
|
||||
fail(f"git diff against {base} failed, so the version check could not run")
|
||||
return
|
||||
if not changed.strip():
|
||||
if not changed:
|
||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
||||
return
|
||||
|
||||
@@ -445,7 +558,7 @@ def check_version_bump(base: str = "origin/main") -> None:
|
||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||
return
|
||||
if here == there:
|
||||
files = "\n ".join(changed.splitlines())
|
||||
files = "\n ".join(paths)
|
||||
fail(
|
||||
f"plugin content changed but the manifest version is still {here}.\n"
|
||||
f" The installer compares versions to decide whether to refresh "
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claude Code PreToolUse hook for Bash.
|
||||
# Reads the tool input JSON from stdin; if the command is a git commit
|
||||
# and fable-mcp files (other than pyproject.toml) are staged, bumps
|
||||
# the fable-mcp patch version before the commit proceeds.
|
||||
#
|
||||
# Exits 0 always so it never blocks the commit.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
# Claude Code sends {tool_input: {command: ...}}
|
||||
ti = data.get('tool_input', data)
|
||||
print(ti.get('command', ''))
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Only act on git commit commands
|
||||
if ! echo "$command" | grep -qE "git commit"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if fable-mcp files other than pyproject.toml are staged
|
||||
fable_staged=$(git diff --cached --name-only 2>/dev/null \
|
||||
| grep "^fable-mcp/" \
|
||||
| grep -v "^fable-mcp/pyproject.toml$" \
|
||||
|| true)
|
||||
|
||||
if [ -n "$fable_staged" ]; then
|
||||
bash "$REPO_ROOT/scripts/bump_fable_mcp_version.sh"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user