CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 16s
#3127 checklist 19. The step's own deletion list turned out to be largely spent: `check_version_bump()` came out with #3327, and the machinery the step expected to delete alongside it is load-bearing for its replacement. `manifest_version(ref=…)`, `--base`, `--no-version` and the `origin/main` resolve path all STAY. Derivation makes the value right; it does not make the comparison unnecessary. `check_version_is_minted` still has to ask "did the version move when the shipped content did?", and that is a base-branch question no matter who chose the number. The step was planned before #3327 landed, when the assumption was that these died with the guard. What was actually still standing, all of it teaching or asserting the retired scheme: - `plugin/README.md` told the reader to "set a `version` bump per release." A shipped file, instructing the exact act the mint replaced — this is how a deleted control gets re-added by someone following the docs. Now says not to hand-edit the field, names `make mint-plugin`, and says what a forgotten mint costs. (`make` is not installed on every workstation, so the direct script invocation is given too.) - `test_plugin_version_bumped_with_the_hook` asserted `version >= (0,1,31)` as a tuple of ints. Under a minted value it passes vacuously — every date clears a floor of 0.1.31 — and `int("0415")` silently eats the padding the format exists to keep. Superseded by `test_the_shipped_manifest_carries_a_minted_version`, which asserts the canonical shape instead of an ordering the comparator does not perform. Removed whole (rule 22). - The module preamble still ended on "a written rule that depends on being remembered is not a control; this is" — true of the bump guard, and read as a stronger claim than the mint can support. Replaced with what the change did and did not remove: choosing a number is gone, running the mint is not, and the difference is that forgetting is now loud rather than silent. - An orphaned `# --- the version bump ---` section header with nothing under it, and a test docstring still naming `check_version_bump`. `--no-version` keeps its one legitimate case — on `main` the version is measured against itself — and now says so in both the usage block and its `--help`, so it does not read as an escape hatch. `check_session_context_ reports_its_version` stays untouched: a different check with a different job, and the only thing that makes step 6 readable from a transcript (#2220). Version minted 2026.09.02.0415 -> 2026.09.02.0438 for the README change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""One definition of what SHIPS in the plugin, and the drift guards on it.
|
|
|
|
WHAT THIS IS ABOUT (#3127 §3, milestone 334 step 2). Scribe publishes two
|
|
artifacts. `plugin/` is not in the Docker image — installs fetch it from this
|
|
repo through `.claude-plugin/marketplace.json`, so **a push IS the release**,
|
|
with no build step in between. That makes "which files reach an install?" a
|
|
question with real consequences, and it has been answered wrong twice:
|
|
|
|
- #2198 — `plugin/**` was in no `paths:` filter, so four broken hooks
|
|
reached live installs having triggered no CI at all.
|
|
- #2209 — the fix for that shipped and still could not reach an install,
|
|
because the manifest version had not moved.
|
|
|
|
The set lives in `scripts/check_plugin.py`. Its second consumer is the
|
|
workflow's `paths:` trigger, which is YAML and cannot import Python — so the
|
|
"one definition" is held together by the drift tests here rather than by an
|
|
import. That is the honest shape, and it is why these tests exist at all.
|
|
|
|
The exclusion tests are the load-bearing half. Without the manifest-`version`
|
|
exclusion the version check is CIRCULAR: bumping the version edits a file
|
|
inside `plugin/`, which then reads as the content change that justifies the
|
|
bump. Every bump passes, no bump ever fails, and the check has proved nothing
|
|
while looking green.
|
|
"""
|
|
import json
|
|
import pathlib
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from scripts.check_plugin import (
|
|
DERIVERS,
|
|
SHIPPED_PATHS,
|
|
manifest_differs_beyond_version,
|
|
)
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
CI = ROOT / ".forgejo/workflows/ci.yml"
|
|
|
|
|
|
def trigger_paths() -> list[str]:
|
|
"""The `paths:` list under the workflow's push trigger.
|
|
|
|
Parsed with a regex rather than a YAML library, matching what
|
|
test_version_endpoint.py already does with this file — the alternative is
|
|
adding PyYAML as a dependency for one assertion. Raises rather than
|
|
returning empty: a silent no-op here would defeat the point of the file.
|
|
"""
|
|
text = CI.read_text()
|
|
block = re.search(r"^ paths:\n((?:(?: [-#].*)?\n)+)", text, re.M)
|
|
if block is None:
|
|
raise AssertionError("could not find the push trigger's `paths:` block")
|
|
found = re.findall(r'^ - "([^"]+)"', block.group(1), re.M)
|
|
if not found:
|
|
raise AssertionError("the `paths:` block parsed to zero entries")
|
|
return found
|
|
|
|
|
|
# ── The set itself ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_shipped_path_exists():
|
|
"""A set naming something that isn't there is not a definition of anything."""
|
|
for path in SHIPPED_PATHS:
|
|
assert (ROOT / path).exists(), f"SHIPPED_PATHS names {path}, which does not exist"
|
|
|
|
|
|
def test_every_shipped_path_triggers_ci():
|
|
"""#2198's exact hole, stated as an assertion.
|
|
|
|
Directional on purpose: the trigger is a superset (it also fires on
|
|
`src/**`, `tests/**` and friends). What must never happen is a path that
|
|
reaches an install and fires no lane.
|
|
"""
|
|
triggers = trigger_paths()
|
|
for path in SHIPPED_PATHS:
|
|
covered = any(t == path or t.startswith(f"{path}/") for t in triggers)
|
|
assert covered, (
|
|
f"{path} ships to installs but no `paths:` entry covers it — "
|
|
f"changes there would reach a live install having run no CI (#2198)"
|
|
)
|
|
|
|
|
|
def test_the_checker_itself_triggers_ci():
|
|
"""Changing the checks must re-run them.
|
|
|
|
Not a member of the shipped set — a checker decides whether the lane goes
|
|
red, not what any artifact reports — but a change to it that runs no lane
|
|
is the same silence by a different route.
|
|
"""
|
|
assert "scripts/check_plugin.py" in trigger_paths()
|
|
|
|
|
|
def test_no_trigger_path_names_something_that_does_not_exist():
|
|
"""The guard that catches scaffolding outliving its subsystem.
|
|
|
|
`fable-mcp/**` sat in this list for three months after the directory was
|
|
deleted (commit 91bafb6, 2026-05-27), and `assets/**` named a path that
|
|
never existed at all. Neither ever failed anything — a `paths:` entry
|
|
matching nothing simply never fires — which is precisely why a list kept
|
|
by hand drifts and nobody finds out.
|
|
"""
|
|
missing = [
|
|
entry for entry in trigger_paths()
|
|
if not (ROOT / re.sub(r"/\*\*$", "", entry)).exists()
|
|
]
|
|
assert not missing, (
|
|
f"`paths:` names {missing}, which do not exist in the repo. A trigger "
|
|
f"that matches nothing is silent, so it survives every review."
|
|
)
|
|
|
|
|
|
def test_every_deriver_exists():
|
|
"""§3's table, kept honest.
|
|
|
|
The point of the table is that the next artifact is a one-line addition
|
|
(milestone 334 step 3 adds the plugin's mint script). A row pointing at a
|
|
file that has moved would make the table read as complete when it is not.
|
|
"""
|
|
for path, artifacts in DERIVERS.items():
|
|
assert (ROOT / path).exists(), f"DERIVERS names {path}, which does not exist"
|
|
assert artifacts, f"DERIVERS[{path}] names no artifact"
|
|
|
|
|
|
# ── The exclusion — the half that makes the version check mean anything ────
|
|
|
|
|
|
def manifest(**fields) -> str:
|
|
base = {
|
|
"name": "scribe",
|
|
"description": "d",
|
|
"version": "0.1.48",
|
|
"mcpServers": {"scribe": {"type": "http", "url": "${user_config.api_endpoint}/mcp"}},
|
|
"userConfig": {"api_endpoint": {"type": "string"}},
|
|
}
|
|
base.update(fields)
|
|
return json.dumps(base)
|
|
|
|
|
|
def test_a_version_only_change_is_NOT_a_content_change():
|
|
"""THE assertion. Without it the version check is self-satisfying: the
|
|
bump edits `plugin.json`, which lives inside `plugin/`, so the bump is its
|
|
own justification and every bump passes."""
|
|
assert manifest_differs_beyond_version(
|
|
manifest(version="2026.09.01.0512"), manifest(version="0.1.48")
|
|
) is False
|
|
|
|
|
|
def test_an_identical_manifest_is_not_a_change():
|
|
assert manifest_differs_beyond_version(manifest(), manifest()) is False
|
|
|
|
|
|
@pytest.mark.parametrize("field,value", [
|
|
("userConfig", {"api_endpoint": {"type": "string", "title": "changed"}}),
|
|
("mcpServers", {"scribe": {"type": "http", "url": "elsewhere"}}),
|
|
("description", "a different description"),
|
|
("name", "renamed"),
|
|
])
|
|
def test_every_OTHER_manifest_field_still_demands_a_new_version(field, value):
|
|
"""Why the exclusion is one FIELD and never the whole file.
|
|
|
|
`plugin.json` carries description, mcpServers and userConfig alongside the
|
|
version, and all of them reach an install. Excluding the file wholesale
|
|
would mean a userConfig-only edit computes an unchanged version and never
|
|
refreshes — #2209 again, with a narrower trigger and the same silence.
|
|
"""
|
|
assert manifest_differs_beyond_version(manifest(**{field: value}), manifest()) is True
|
|
|
|
|
|
def test_reformatting_is_not_a_content_change():
|
|
"""Parsed objects, not text. Whitespace and key order are not content, and
|
|
a check that treated them as such would demand a version for a re-indent."""
|
|
data = json.loads(manifest())
|
|
reordered = {k: data[k] for k in reversed(list(data))}
|
|
assert manifest_differs_beyond_version(
|
|
json.dumps(reordered, indent=4), json.dumps(data, separators=(",", ":"))
|
|
) is False
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["", "{not json", "[]", '"a string"', "null"])
|
|
def test_unreadable_input_demands_a_new_version(bad):
|
|
"""The conservative direction, chosen deliberately.
|
|
|
|
A spurious bump costs one cache refresh. A missed one is #2209 — the fix
|
|
reaches the repo and stops there, and the only detector is a human saying
|
|
"I don't think it updated."
|
|
"""
|
|
assert manifest_differs_beyond_version(bad, manifest()) is True
|
|
assert manifest_differs_beyond_version(manifest(), bad) is True
|
|
|
|
|
|
def test_a_manifest_appearing_or_vanishing_is_a_change():
|
|
"""None means the file is absent at that ref — a real difference, and not
|
|
the same thing as unreadable."""
|
|
assert manifest_differs_beyond_version(None, manifest()) is True
|
|
assert manifest_differs_beyond_version(manifest(), None) is True
|
|
|
|
|
|
# ── The reader that joins the exclusion to git ─────────────────────────────
|
|
|
|
|
|
def test_shipped_content_changed_reports_a_version_only_commit_as_unchanged(monkeypatch):
|
|
"""End to end through the git seam, with git stubbed.
|
|
|
|
The unit above proves the comparison; this proves it is actually WIRED to
|
|
the path that `check_version_is_minted` reads. A correct helper nobody calls
|
|
would leave the circular check exactly as it was.
|
|
"""
|
|
from scripts import check_plugin
|
|
|
|
monkeypatch.setattr(
|
|
check_plugin, "_git",
|
|
lambda *a: (0, "plugin/.claude-plugin/plugin.json"),
|
|
)
|
|
monkeypatch.setattr(
|
|
check_plugin, "manifest_text",
|
|
lambda ref=None: manifest(version="2026.09.01.0512" if ref is None else "0.1.48"),
|
|
)
|
|
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
|
assert changed is False
|
|
assert paths == ["plugin/.claude-plugin/plugin.json"]
|
|
|
|
|
|
def test_shipped_content_changed_reports_a_hook_edit_as_changed(monkeypatch):
|
|
"""The guard against an exclusion that swallowed everything — a check that
|
|
can never fire is indistinguishable from one that is broken."""
|
|
from scripts import check_plugin
|
|
|
|
monkeypatch.setattr(
|
|
check_plugin, "_git",
|
|
lambda *a: (0, "plugin/hooks/scribe_session_context.sh"),
|
|
)
|
|
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
|
assert changed is True
|
|
assert paths == ["plugin/hooks/scribe_session_context.sh"]
|
|
|
|
|
|
def test_a_failed_diff_is_None_and_never_False(monkeypatch):
|
|
"""Could-not-tell and nothing-changed must not collapse into one value.
|
|
|
|
#2663 is the precedent: a read that failed inside a broad except reported
|
|
the same zero as a genuinely empty window, and every counter read zero for
|
|
weeks with nothing to distinguish the two.
|
|
"""
|
|
from scripts import check_plugin
|
|
|
|
monkeypatch.setattr(check_plugin, "_git", lambda *a: (128, "fatal: bad revision"))
|
|
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
|
assert changed is None
|
|
assert paths == []
|