CI & Build / Python tests (push) Failing after 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Milestone 334 step 3. 0.1.48 was the last of 48 numbers a person typed by hand; forgetting to type the 49th is #2209, #1040 and #2220, three separate times a shipped fix reached the repo and stopped there. WHY A SCRIPT AND NOT A BUILD STEP. plugin/ is not in the image -- installs fetch it from this repo via marketplace.json, so the push IS the release and there is no moment at which CI could stamp a version in. Every other artifact in the family derives during a build (#3127 section 2). This one has no build to derive during, so the value is minted before the commit and CI's job is to prove it moved when it had to. MINT TIME, a fourth clock section 2 does not name. It prescribes commit time so two lanes building one source report one string; the plugin has one lane and no build, so that reason does not reach it. What is given up is reproducibility-from-history -- you cannot recompute the value, only verify it moved. That is acceptable ONLY because #3325 read the installer's code and found the refresh test is `P.version === H`, plain equality, with zero ordering comparisons anywhere. Where a comparator orders, an unreproducible version would be unverifiable too. Two artifacts in one repo now derive from different clocks on purpose, one directory apart. "Let's make these consistent" is the obvious tidy-up and breaks whichever loses, so the divergence is pinned in tests rather than only explained in a comment -- including an AST assertion that the mint script never imports subprocess, since a mint that can read history is a commit-time deriver wearing the wrong name. check_version_bump becomes check_version_is_minted. It gains the shape gate and a future-value gate, and it keeps deliberately NOT failing when the version moved without content changing: a needless re-mint costs one cache refresh, and failing the lane over a harmless act is how a check earns a --no-version in somebody's muscle memory and stops running at all. The implication that matters is one-directional. The mint script joins the version-relevant set, which is step 2's DERIVERS table finally being read by something. Section 3's asymmetry is why it is not optional: change the format string, change nothing else, and a diff over the shipped paths alone says "no content change" while the manifest keeps a value in the old format forever. Its own introduction demonstrates this -- adding the deriver is itself the version-relevant change that forced this mint. fetch-depth: 0 was NOT added, against this step's own brief. The plugin job carries a comment refusing it, backed by an observed act_runner failure (any `with:` block made checkout fail to extract, run 3027), and the reasoning holds: the check diffs two trees and the workflow already fetches main at depth 1. Checklist 6 is about jobs that derive; this one checks. Verified live before pushing: the session-context marker reports v2026.09.01.2252 keylessly, and both failure arms were probed by hand rather than assumed. The shape gate fires first on a reverted 0.1.48, so the stale arm is covered by unit test rather than by that probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
699 lines
30 KiB
Python
Executable File
699 lines
30 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Guards for `plugin/` — the one part of this repo that ships straight to users.
|
|
|
|
WHY THIS EXISTS. `plugin/` is not built into the Docker image. Installs fetch it
|
|
from this git repo via `.claude-plugin/marketplace.json`, so a push IS the
|
|
release for plugin content: no build, no gate, immediately fetchable. Two
|
|
separate defects have reached a live install through that path:
|
|
|
|
- #2198 — all four hook scripts were inert (wrong env-var case, line-oriented
|
|
`jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in
|
|
the workflow's `paths:` filter at all.
|
|
- #2209 — the fix for #2198 shipped to `main` and still couldn't reach an
|
|
install, because `plugin.json`'s version wasn't bumped and the installer
|
|
compares versions to decide whether to refresh its cache.
|
|
|
|
The rule for the second one was already written down and was still missed. A
|
|
written rule that depends on being remembered during a long session is not a
|
|
control; this is.
|
|
|
|
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
|
|
and scripts/install-common.sh, not from memory — rule #37). CI installs both
|
|
per-job, which is what CI-runner's own docs/process.md prescribes for a dep with
|
|
a single consumer: "If only one project needs the dep, prefer that project
|
|
installing it per-job in their workflow — at least until a second consumer
|
|
arrives." Promotion into the image is filed as an issue there rather than
|
|
assumed here.
|
|
|
|
Both are optional at runtime: without shellcheck the lint step is SKIPPED and
|
|
says so, and without jq the smoke test is skipped. A skipped check announces
|
|
itself loudly, because a check that quietly no-ops is the failure mode this
|
|
whole file exists to prevent.
|
|
|
|
Usage:
|
|
python3 scripts/check_plugin.py # all checks
|
|
python3 scripts/check_plugin.py --no-version # skip the version check
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
# The shape contract is ONE definition, shared with the script that mints it —
|
|
# a checker carrying its own copy of the format would drift from the minter
|
|
# and pass values the minter can no longer produce. Explicit path insert
|
|
# because this file runs both as `python3 scripts/check_plugin.py` (which puts
|
|
# `scripts/` on the path, not the root) and as an import from the test suite.
|
|
sys.path.insert(0, str(ROOT))
|
|
from scripts.mint_plugin_version import VERSION_RE # noqa: E402
|
|
|
|
PLUGIN_DIR = ROOT / "plugin"
|
|
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
|
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
|
|
|
# ── 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, while the script that mints
|
|
# the plugin version is.
|
|
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",),
|
|
# Decides the plugin's version FORMAT, so it decides what every future
|
|
# manifest says about itself (milestone 334 step 3).
|
|
"scripts/mint_plugin_version.py": ("plugin",),
|
|
}
|
|
|
|
|
|
def version_relevant_paths() -> tuple[str, ...]:
|
|
"""Everything a change to which must produce a NEW plugin version.
|
|
|
|
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
|
|
|
|
A change to how the VERSION is computed is compared against nothing at
|
|
all. Left out, the published artifact goes on reporting the OLD value
|
|
indefinitely.
|
|
|
|
Concretely — change the mint script's format string, change nothing else,
|
|
and a diff over the shipped paths alone reports "no content change, the
|
|
version need not move". The manifest then keeps a value in the old format
|
|
forever and nothing ever says so. The mint script reaches no install and
|
|
belongs here anyway; that is #3156's exact shape.
|
|
|
|
A CHECKER is deliberately not here. Whatever validates the version decides
|
|
whether the lane goes red, not what any artifact reports — so this file is
|
|
absent from its own set, and that is not an oversight.
|
|
"""
|
|
return SHIPPED_PATHS + tuple(
|
|
path for path, artifacts in DERIVERS.items() if "plugin" in artifacts
|
|
)
|
|
|
|
|
|
failures: list[str] = []
|
|
|
|
|
|
def fail(msg: str) -> None:
|
|
failures.append(msg)
|
|
print(f"FAIL {msg}")
|
|
|
|
|
|
def ok(msg: str) -> None:
|
|
print(f"ok {msg}")
|
|
|
|
|
|
def skip(msg: str) -> None:
|
|
# Loud on purpose. A check that quietly does nothing is indistinguishable
|
|
# from a check that passed — the exact confusion that let #2198 survive.
|
|
print(f"SKIP {msg}")
|
|
|
|
|
|
def hook_scripts() -> list[Path]:
|
|
return sorted(HOOKS_DIR.glob("*.sh"))
|
|
|
|
|
|
# --- syntax ----------------------------------------------------------------
|
|
|
|
def check_syntax() -> None:
|
|
"""`bash -n` every hook. Catches nothing subtle, costs nothing, and a syntax
|
|
error here means a hook that silently never runs."""
|
|
for script in hook_scripts():
|
|
proc = subprocess.run(
|
|
["bash", "-n", str(script)], capture_output=True, text=True
|
|
)
|
|
if proc.returncode != 0:
|
|
fail(f"{script.relative_to(ROOT)}: bash -n — {proc.stderr.strip()}")
|
|
else:
|
|
ok(f"{script.relative_to(ROOT)}: syntax")
|
|
|
|
|
|
# --- known-bad patterns ----------------------------------------------------
|
|
|
|
# Each entry: (compiled pattern, short label, why it's wrong).
|
|
# These are the exact classes from #2198. They are deliberately specific — a
|
|
# broad shell linter belongs in the image, not hand-rolled here.
|
|
PATTERNS: list[tuple[re.Pattern, str, str]] = [
|
|
(
|
|
re.compile(r"CLAUDE_PLUGIN_OPTION_[a-z]"),
|
|
"lowercase userConfig env var",
|
|
"Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_<KEY> "
|
|
"with the key UPPERCASED. The lowercase spelling reads as empty and the "
|
|
"hook then does nothing, silently.",
|
|
),
|
|
(
|
|
# -R without -s: reads input line by line, so a multi-line payload is
|
|
# encoded per line and joined with raw newlines. The class is a-r + t-z
|
|
# (i.e. every letter EXCEPT `s`) so `-rR` is caught and `-sRr` is not —
|
|
# an earlier a-q spelling silently excluded `r` and missed the real
|
|
# defect, which is exactly the flag combination that shipped.
|
|
re.compile(r"jq\s+-(?:[a-rt-zA-Z]*R[a-rt-zA-Z]*)\s"),
|
|
"line-oriented jq -R",
|
|
"jq -R reads input LINE BY LINE. Encoding a multi-line payload that way "
|
|
"produces separate encoded lines joined by raw newlines — an invalid "
|
|
"URL. Use -s (slurp) as well, e.g. `jq -sRr '@uri'`.",
|
|
),
|
|
(
|
|
re.compile(r"\|\s*cut\s+-c"),
|
|
"line-oriented cut for a payload cap",
|
|
"cut -c truncates EACH LINE, so it does not cap total size. Use "
|
|
"`head -c N` to bound a payload.",
|
|
),
|
|
]
|
|
|
|
|
|
def check_patterns() -> None:
|
|
for script in hook_scripts():
|
|
text = script.read_text(encoding="utf-8", errors="replace")
|
|
rel = script.relative_to(ROOT)
|
|
hits = 0
|
|
for line_no, line in enumerate(text.splitlines(), 1):
|
|
# A line that only *documents* the trap is fine — several hooks now
|
|
# carry a comment naming the wrong form so the next reader knows.
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
for pattern, label, why in PATTERNS:
|
|
if pattern.search(line):
|
|
hits += 1
|
|
fail(f"{rel}:{line_no}: {label}\n {line.strip()}\n {why}")
|
|
if not hits:
|
|
ok(f"{rel}: no known-bad patterns")
|
|
|
|
|
|
# --- the version bump ------------------------------------------------------
|
|
|
|
# --- shellcheck ------------------------------------------------------------
|
|
|
|
def check_shellcheck() -> None:
|
|
"""Real shell linting, where available.
|
|
|
|
The hand-rolled patterns above only know the bugs that already happened.
|
|
This is what catches the next one.
|
|
"""
|
|
exe = shutil.which("shellcheck")
|
|
if not exe:
|
|
skip("shellcheck not installed — install it to lint the hooks properly")
|
|
return
|
|
for script in hook_scripts():
|
|
proc = subprocess.run(
|
|
# -x FOLLOWS `# shellcheck source=` directives into the sourced
|
|
# file. Without it the shared helpers in scribe_defs.sh are
|
|
# invisible, so every variable they set reads as unassigned
|
|
# (SC2154) and every bug inside them goes unlinted at the call
|
|
# site — which is the opposite of what sharing them was for.
|
|
[exe, "--severity=warning", "--shell=bash", "-x", str(script)],
|
|
capture_output=True, text=True,
|
|
)
|
|
rel = script.relative_to(ROOT)
|
|
if proc.returncode != 0:
|
|
fail(f"{rel}: shellcheck\n{proc.stdout.strip()}")
|
|
else:
|
|
ok(f"{rel}: shellcheck")
|
|
|
|
|
|
# --- the fail-open contract ------------------------------------------------
|
|
|
|
# Every hook promises never to break the operator's session: unconfigured or
|
|
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
|
|
# call was owed. scribe_session_context.sh is the exception by design — it
|
|
# always emits a static behavioural floor that needs no credentials and no
|
|
# network, so "silent" would be the wrong assertion for it.
|
|
#
|
|
# UNREACHABLE is different for the two write-path hooks since #2932: a call
|
|
# that was owed and did not come back is SAID, once per outage ("> Scribe did
|
|
# not answer …"), so a session can tell "checked, nothing there" from "never
|
|
# checked". That line — or silence, when the once-per-outage marker in
|
|
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
|
|
# is the only output allowed with no working instance; anything else is a hook
|
|
# speaking on data it cannot have.
|
|
#
|
|
# This is the contract that made #2198 invisible for weeks, so it is worth
|
|
# pinning: the bug and the healthy no-results case looked identical from
|
|
# outside. #2932 is what finally makes the failure visible at the write; this
|
|
# check makes sure the fail-open behaviour stays deliberate rather than
|
|
# accidental.
|
|
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
|
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
|
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
|
# both hit while writing this:
|
|
# - `def f` matched real code, so the hook spoke and "silent" was asserting
|
|
# the wrong thing;
|
|
# - spelling the replacement out in full put `def <name>(` INTO this file,
|
|
# so the smoke event defined the very symbol it claimed was absent.
|
|
# Concatenating keeps the contiguous string out of the source.
|
|
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
|
|
|
|
SMOKE_EVENTS: dict[str, str] = {
|
|
"scribe_autoinject.sh": json.dumps(
|
|
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
|
|
),
|
|
"scribe_prior_art.sh": json.dumps(
|
|
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
|
|
"tool_input": {"file_path": "src/x.py",
|
|
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
|
),
|
|
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
|
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
|
# The after-write hook (#2901) diffs the working tree; on CI's clean
|
|
# checkout there is nothing to report, so silence is the right assertion.
|
|
# (On a dirty local tree with a definition just written it may speak —
|
|
# that is the hook working, not a failure of the contract.)
|
|
"scribe_after_write.sh": json.dumps(
|
|
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
|
|
"tool_input": {"command": "true"}, "tool_response": {}}
|
|
),
|
|
# The shared library is sourced, never run; executed bare it defines
|
|
# functions and exits — silent by construction.
|
|
"scribe_defs.sh": "",
|
|
}
|
|
|
|
# The one hook that legitimately produces output with no credentials.
|
|
STATIC_FLOOR = "scribe_session_context.sh"
|
|
# The hooks that say so when a configured instance does not answer (#2932).
|
|
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
|
|
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
|
|
|
|
|
|
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not k.startswith(("SCRIBE_", "CLAUDE_PLUGIN_OPTION_"))}
|
|
env.update(env_extra)
|
|
return subprocess.run(
|
|
["bash", str(script)], input=event, capture_output=True, text=True,
|
|
env=env, timeout=30,
|
|
)
|
|
|
|
|
|
def check_fail_open() -> None:
|
|
if not shutil.which("jq"):
|
|
# Without jq every hook bails at its first line, so this would pass
|
|
# while exercising nothing. Say so rather than bank a green tick.
|
|
skip("jq not installed — the hooks would exit at line 1, so this "
|
|
"check would pass without testing anything")
|
|
return
|
|
|
|
scenarios = [
|
|
("unconfigured", {}),
|
|
# Connection refused immediately — exercises the unreachable-instance
|
|
# path without waiting on a real network timeout.
|
|
("unreachable", {"SCRIBE_URL": "http://127.0.0.1:1", "SCRIBE_TOKEN": "x"}),
|
|
]
|
|
for script in hook_scripts():
|
|
rel = script.relative_to(ROOT)
|
|
event = SMOKE_EVENTS.get(script.name)
|
|
if event is None:
|
|
skip(f"{rel}: no smoke event defined")
|
|
continue
|
|
for label, env_extra in scenarios:
|
|
try:
|
|
proc = _run_hook(script, event, env_extra)
|
|
except subprocess.TimeoutExpired:
|
|
fail(f"{rel} [{label}]: hung — a hook must never block a session")
|
|
continue
|
|
if proc.returncode != 0:
|
|
fail(f"{rel} [{label}]: exited {proc.returncode}, must be 0 — "
|
|
f"a recall aid may never fail the operator's action")
|
|
continue
|
|
out = proc.stdout.strip()
|
|
if script.name == STATIC_FLOOR:
|
|
# Emits its bundled static tier regardless; that floor is the
|
|
# whole point of the two-tier design.
|
|
if not out:
|
|
fail(f"{rel} [{label}]: emitted nothing — the static "
|
|
f"behavioural floor must survive having no credentials")
|
|
else:
|
|
ok(f"{rel} [{label}]: exit 0, static floor present")
|
|
elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
|
|
# The only thing allowed here is the outage line itself.
|
|
try:
|
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
except (ValueError, KeyError, TypeError):
|
|
ctx = ""
|
|
if ctx.startswith(OUTAGE_LINE):
|
|
ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
|
|
else:
|
|
fail(f"{rel} [{label}]: emitted output with no working instance "
|
|
f"that is not the outage line:\n {out[:200]}")
|
|
elif out:
|
|
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
|
f" {out[:200]}")
|
|
else:
|
|
ok(f"{rel} [{label}]: exit 0, silent")
|
|
|
|
|
|
def check_local_prior_art_needs_no_instance() -> None:
|
|
"""The prior-art hook's local arm must answer with no credentials (#2280).
|
|
|
|
The other arms ask Scribe what was RECORDED. This one asks the repo what
|
|
EXISTS, which needs no instance — and that is the whole reason it catches
|
|
the case the recorded arms structurally cannot: a helper nobody thought to
|
|
record. If it ever silently starts depending on configuration, it stops
|
|
covering that case and nothing else would notice.
|
|
|
|
Paired with the silence assertion in check_fail_open, which uses a symbol
|
|
that cannot exist. Together they pin both halves: silent when there is
|
|
nothing to say, and speaking when there is — both with no instance at all.
|
|
"""
|
|
script = HOOKS_DIR / "scribe_prior_art.sh"
|
|
if not script.is_file() or not shutil.which("jq"):
|
|
skip("prior-art local arm: hook or jq missing")
|
|
return
|
|
|
|
# A definition this repo really does contain, written into a DIFFERENT file
|
|
# so the self-match exclusion doesn't suppress it.
|
|
event = json.dumps({
|
|
"session_id": "smoke", "cwd": ".", "tool_name": "Write",
|
|
"tool_input": {
|
|
"file_path": "scripts/_probe_not_real.py",
|
|
"content": "def check_local_prior_art_needs_no_instance():\n pass\n",
|
|
},
|
|
})
|
|
try:
|
|
proc = _run_hook(script, event, {}) # NO credentials, on purpose
|
|
except subprocess.TimeoutExpired:
|
|
fail("prior-art local arm: hung")
|
|
return
|
|
if proc.returncode != 0:
|
|
fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
|
|
elif "already defined" not in proc.stdout:
|
|
fail("prior-art local arm: found nothing for a symbol this repo "
|
|
"defines, with no credentials — the arm that needs no instance "
|
|
"has stopped working, and the recorded arms cannot cover for it")
|
|
else:
|
|
ok("prior-art local arm: answers with no instance configured")
|
|
|
|
|
|
def check_session_context_reports_its_version() -> None:
|
|
"""The SessionStart context must name the plugin version it is running.
|
|
|
|
An install has two halves and only one self-updates: the marketplace clone
|
|
pulls on its own, while the CACHE is what executes and refreshes only when
|
|
the manifest version changes. So a shipped fix can sit unreached while
|
|
inspecting the clone shows it present — the obvious debugging move
|
|
misleads, and twice the only detector was a human saying "I don't think it
|
|
updated" (#2209, #2220).
|
|
|
|
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
|
|
is the one where the token never arrives, and a marker that vanished there
|
|
would be missing exactly when it is wanted.
|
|
"""
|
|
script = HOOKS_DIR / "scribe_session_context.sh"
|
|
if not script.is_file() or not shutil.which("jq"):
|
|
skip("version marker: hook or jq missing")
|
|
return
|
|
|
|
manifest_v = manifest_version()
|
|
if manifest_v is None:
|
|
fail("version marker: could not read the manifest version")
|
|
return
|
|
|
|
try:
|
|
proc = _run_hook(script, json.dumps({"source": "startup"}), {})
|
|
except subprocess.TimeoutExpired:
|
|
fail("version marker: hook hung")
|
|
return
|
|
if proc.returncode != 0:
|
|
fail(f"version marker: hook exited {proc.returncode}")
|
|
elif manifest_v not in proc.stdout:
|
|
fail(f"version marker: session context never names v{manifest_v} — "
|
|
f"a stale install would be undetectable from the transcript")
|
|
else:
|
|
ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
|
|
|
|
|
|
def _git(*args: str) -> tuple[int, str]:
|
|
proc = subprocess.run(
|
|
["git", *args], capture_output=True, text=True, cwd=ROOT
|
|
)
|
|
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
|
|
|
|
|
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 MANIFEST.read_text()
|
|
except OSError:
|
|
return None
|
|
rel = MANIFEST.relative_to(ROOT).as_posix()
|
|
code, out = _git("show", f"{ref}:{rel}")
|
|
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(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.
|
|
|
|
Reads `version_relevant_paths`, which is the shipped set PLUS the files
|
|
that decide the version — see there for why the deriver has to be in it.
|
|
"""
|
|
code, out = _git("diff", "--name-only", base, "--", *version_relevant_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_is_minted(base: str = "origin/main") -> None:
|
|
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
|
|
|
The checklist asks, of any hand-set component: *say what happens the
|
|
release somebody forgets it.* This is the answer — the lane goes red,
|
|
deterministically, because CI can compute whether the value should have
|
|
moved. Its predecessor could only ask "did the number move at all", which
|
|
any bump satisfied and which therefore proved nothing.
|
|
|
|
Four verdicts:
|
|
|
|
content changed, version did not FAIL — this is #2209, exactly
|
|
version not in canonical shape FAIL — see below
|
|
version implausibly in the future FAIL — a bad clock or a hand-edit
|
|
version moved, content did not pass, and say so
|
|
|
|
THE LAST ROW IS NOT A FAILURE, DELIBERATELY. A needless re-mint costs one
|
|
cache refresh and nothing else. Failing the lane over a harmless act is how
|
|
a check earns a `--no-version` in somebody's muscle memory and stops
|
|
running at all — which is the failure mode this whole file exists to
|
|
prevent. The implication that matters is one-directional: content changed
|
|
IMPLIES version moved.
|
|
|
|
A malformed version is worth failing on even though the installer would
|
|
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
|
|
sets `forceOverwrite`, so a broken value either sorts as a normal string
|
|
or reinstalls the plugin every single session (#3325). Neither is loud.
|
|
|
|
Stated against the BASE BRANCH rather than the last commit, as its
|
|
predecessor was: a per-commit rule would demand a fresh mint from every
|
|
commit in a batch, when what matters is that whatever reaches an install
|
|
differs from what is cached. One mint per batch, which is also how a person
|
|
would do it.
|
|
"""
|
|
code, _ = _git("rev-parse", "--verify", base)
|
|
if code != 0:
|
|
# Do NOT pass silently — a check that quietly no-ops is how this class
|
|
# of bug survives in the first place.
|
|
fail(
|
|
f"cannot resolve {base}, so the version-bump check could not run. "
|
|
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
|
|
f"origin/main` is enough, since this diffs two trees and needs no "
|
|
f"common ancestor — or pass --no-version deliberately."
|
|
)
|
|
return
|
|
|
|
here = manifest_version()
|
|
if here is None:
|
|
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
|
return
|
|
if not VERSION_RE.match(here):
|
|
fail(
|
|
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
|
|
f" One shape for every version in the family (#3127 checklist "
|
|
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
|
|
f" Run `make mint-plugin`."
|
|
)
|
|
return
|
|
|
|
minted = datetime.strptime(here, "%Y.%m.%d.%H%M").replace(tzinfo=timezone.utc)
|
|
# A day of slack: the mint happens on a workstation and the lane runs
|
|
# later, so a *small* skew is ordinary. A value further out than that is
|
|
# a wrong clock or a typed year, and it makes the version lie about when
|
|
# it was minted.
|
|
if minted > datetime.now(timezone.utc) + timedelta(days=1):
|
|
fail(
|
|
f"the manifest version {here} is in the future. Either the clock "
|
|
f"that minted it is wrong, or it was typed by hand."
|
|
)
|
|
return
|
|
|
|
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
|
|
|
|
there = manifest_version(base)
|
|
if there is None:
|
|
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
|
return
|
|
|
|
if changed and here == there:
|
|
files = "\n ".join(paths)
|
|
fail(
|
|
f"plugin content changed but the version is still {here}.\n"
|
|
f" The installer decides whether to refresh its cache by "
|
|
f"comparing this string, so an unchanged version means these edits "
|
|
f"reach the repo and stop there — the marketplace clone updates, the "
|
|
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
|
f" Run `make mint-plugin`.\n"
|
|
f" Changed:\n {files}"
|
|
)
|
|
elif changed:
|
|
ok(f"plugin content changed and the version was minted {there} -> {here}")
|
|
elif here != there:
|
|
# Not a failure — see the docstring. Named rather than silent, because
|
|
# the uninteresting cause (minted twice) and the interesting one (the
|
|
# version-relevant set is too narrow to see what actually changed)
|
|
# produce the same line, and only a person can tell them apart.
|
|
ok(
|
|
f"the version moved {there} -> {here} with no version-relevant "
|
|
f"change — harmless, unless something DID change that the set "
|
|
f"cannot see"
|
|
)
|
|
else:
|
|
ok(f"nothing version-relevant changed against {base} — no mint required")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--no-version", action="store_true",
|
|
help="skip the minted-version check")
|
|
parser.add_argument("--base", default="origin/main",
|
|
help="branch the version is measured against")
|
|
args = parser.parse_args()
|
|
|
|
if not HOOKS_DIR.is_dir():
|
|
print(f"FAIL no hooks directory at {HOOKS_DIR}")
|
|
return 1
|
|
|
|
check_syntax()
|
|
check_patterns()
|
|
check_shellcheck()
|
|
check_fail_open()
|
|
check_local_prior_art_needs_no_instance()
|
|
check_session_context_reports_its_version()
|
|
if not args.no_version:
|
|
check_version_is_minted(args.base)
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} problem(s) found.")
|
|
return 1
|
|
print("All plugin checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|