feat(plugin): mint the version, and make CI the control that it moved (#3327)
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
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
|
|
||||||
@@ -36,3 +36,12 @@ test:
|
|||||||
|
|
||||||
# Run all checks in one shot (mirrors what CI does)
|
# Run all checks in one shot (mirrors what CI does)
|
||||||
check: lint typecheck test
|
check: lint typecheck test
|
||||||
|
|
||||||
|
# --- Plugin ---
|
||||||
|
|
||||||
|
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
||||||
|
# committing. The plugin ships straight from git with no build step, so its
|
||||||
|
# version is minted here rather than stamped by CI; the lane fails if you
|
||||||
|
# forget, but this is what makes remembering cheap.
|
||||||
|
mint-plugin:
|
||||||
|
python3 scripts/mint_plugin_version.py
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||||
"version": "0.1.48",
|
"version": "2026.09.01.2252",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
+126
-33
@@ -32,7 +32,7 @@ whole file exists to prevent.
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 scripts/check_plugin.py # all checks
|
python3 scripts/check_plugin.py # all checks
|
||||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
python3 scripts/check_plugin.py --no-version # skip the version check
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -43,9 +43,19 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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"
|
PLUGIN_DIR = ROOT / "plugin"
|
||||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||||
@@ -82,14 +92,42 @@ SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
|||||||
#
|
#
|
||||||
# Note what is absent: a CHECKER does not belong here. Whatever validates a
|
# 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,
|
# 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
|
# so `check_plugin.py` itself is not a deriver, while the script that mints
|
||||||
# (milestone 334 step 3) will be, and adds its own row.
|
# the plugin version is.
|
||||||
DERIVERS: dict[str, tuple[str, ...]] = {
|
DERIVERS: dict[str, tuple[str, ...]] = {
|
||||||
# The "Generate image tags and version" step computes the server image's
|
# The "Generate image tags and version" step computes the server image's
|
||||||
# name, ordering key and channel (#3298).
|
# name, ordering key and channel (#3298).
|
||||||
".forgejo/workflows/ci.yml": ("server-image",),
|
".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] = []
|
failures: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -500,8 +538,11 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
|||||||
The manifest is special-cased, not excluded: if it is the ONLY thing that
|
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
|
moved and the only difference is `version`, nothing that reaches an
|
||||||
install has changed. Any other manifest field, or any other file, counts.
|
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, "--", *SHIPPED_PATHS)
|
code, out = _git("diff", "--name-only", base, "--", *version_relevant_paths())
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return None, []
|
return None, []
|
||||||
paths = [p for p in out.splitlines() if p.strip()]
|
paths = [p for p in out.splitlines() if p.strip()]
|
||||||
@@ -516,19 +557,39 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
|||||||
return True, paths
|
return True, paths
|
||||||
|
|
||||||
|
|
||||||
def check_version_bump(base: str = "origin/main") -> None:
|
def check_version_is_minted(base: str = "origin/main") -> None:
|
||||||
"""If shipped plugin content differs from `base`, the version must too.
|
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
||||||
|
|
||||||
Stated against the BASE BRANCH rather than the last commit on purpose. A
|
The checklist asks, of any hand-set component: *say what happens the
|
||||||
per-commit rule would demand a bump from every commit in a batch; what
|
release somebody forgets it.* This is the answer — the lane goes red,
|
||||||
actually matters is that whatever reaches an install carries a version the
|
deterministically, because CI can compute whether the value should have
|
||||||
installer can tell apart from the one already cached. One bump per batch,
|
moved. Its predecessor could only ask "did the number move at all", which
|
||||||
which is also how a human would do it.
|
any bump satisfied and which therefore proved nothing.
|
||||||
|
|
||||||
Reads the set through `shipped_content_changed`, so a commit whose ONLY
|
Four verdicts:
|
||||||
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
|
content changed, version did not FAIL — this is #2209, exactly
|
||||||
reads as the change that justifies the bump.
|
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)
|
code, _ = _git("rev-parse", "--verify", base)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -542,42 +603,74 @@ def check_version_bump(base: str = "origin/main") -> None:
|
|||||||
)
|
)
|
||||||
return
|
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)
|
changed, paths = shipped_content_changed(base)
|
||||||
if changed is None:
|
if changed is None:
|
||||||
fail(f"git diff against {base} failed, so the version check could not run")
|
fail(f"git diff against {base} failed, so the version check could not run")
|
||||||
return
|
return
|
||||||
if not changed:
|
|
||||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
|
||||||
return
|
|
||||||
|
|
||||||
here, there = manifest_version(), manifest_version(base)
|
there = manifest_version(base)
|
||||||
if here is None:
|
|
||||||
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
|
||||||
return
|
|
||||||
if there is None:
|
if there is None:
|
||||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||||
return
|
return
|
||||||
if here == there:
|
|
||||||
|
if changed and here == there:
|
||||||
files = "\n ".join(paths)
|
files = "\n ".join(paths)
|
||||||
fail(
|
fail(
|
||||||
f"plugin content changed but the manifest version is still {here}.\n"
|
f"plugin content changed but the version is still {here}.\n"
|
||||||
f" The installer compares versions to decide whether to refresh "
|
f" The installer decides whether to refresh its cache by "
|
||||||
f"its cache, so an unchanged version means these edits reach the repo "
|
f"comparing this string, so an unchanged version means these edits "
|
||||||
f"and stop there — the marketplace clone updates, the cache that "
|
f"reach the repo and stop there — the marketplace clone updates, the "
|
||||||
f"actually executes does not (issue #2209).\n"
|
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
||||||
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
|
f" Run `make mint-plugin`.\n"
|
||||||
f" Changed:\n {files}"
|
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:
|
else:
|
||||||
ok(f"plugin content changed and version moved {there} -> {here}")
|
ok(f"nothing version-relevant changed against {base} — no mint required")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--no-version", action="store_true",
|
parser.add_argument("--no-version", action="store_true",
|
||||||
help="skip the manifest version-bump check")
|
help="skip the minted-version check")
|
||||||
parser.add_argument("--base", default="origin/main",
|
parser.add_argument("--base", default="origin/main",
|
||||||
help="branch the version bump is measured against")
|
help="branch the version is measured against")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not HOOKS_DIR.is_dir():
|
if not HOOKS_DIR.is_dir():
|
||||||
@@ -591,7 +684,7 @@ def main() -> int:
|
|||||||
check_local_prior_art_needs_no_instance()
|
check_local_prior_art_needs_no_instance()
|
||||||
check_session_context_reports_its_version()
|
check_session_context_reports_its_version()
|
||||||
if not args.no_version:
|
if not args.no_version:
|
||||||
check_version_bump(args.base)
|
check_version_is_minted(args.base)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if failures:
|
if failures:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mint the plugin's version — `YYYY.MM.DD.HHMM`, UTC, zero-padded.
|
||||||
|
|
||||||
|
Run this whenever you change something under `plugin/` or `.claude-plugin/`,
|
||||||
|
before you commit:
|
||||||
|
|
||||||
|
make mint-plugin # or: python3 scripts/mint_plugin_version.py
|
||||||
|
|
||||||
|
WHY A SCRIPT AND NOT A BUILD STEP. `plugin/` is not in the Docker image.
|
||||||
|
Installs fetch it straight from this git repo via `.claude-plugin/
|
||||||
|
marketplace.json`, so **a push IS the release** — there is no build between
|
||||||
|
you committing and a user fetching, and therefore no moment at which CI could
|
||||||
|
stamp a version in. Every other artifact in the family derives its version
|
||||||
|
during a build (note #3127 §2). This one has no build to derive during.
|
||||||
|
|
||||||
|
WHICH CLOCK, AND WHY IT DIFFERS FROM THE SERVER IMAGE — the divergence is
|
||||||
|
deliberate, and it lives one directory away from its opposite, so it is
|
||||||
|
exactly what a later "let's make these consistent" change would collapse:
|
||||||
|
|
||||||
|
server image name from COMMIT time, ordering key from BUILD time
|
||||||
|
(two lanes building one source must report one string;
|
||||||
|
a rebuild of an older commit must not go backwards)
|
||||||
|
plugin one value, from MINT time
|
||||||
|
|
||||||
|
§2's reason for commit time is that two lanes build one source. The plugin has
|
||||||
|
one lane and no build, so that reason does not reach it and paying its cost
|
||||||
|
buys nothing. What is given up is reproducibility-from-history: you cannot
|
||||||
|
recompute this value later, only verify that it moved when it had to.
|
||||||
|
|
||||||
|
That trade is acceptable ONLY because of what #3325 established by reading the
|
||||||
|
installer's code: the refresh test is `P.version === H`, plain string
|
||||||
|
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
|
||||||
|
unreproducible version is dangerous — nothing can check it is right. Where it
|
||||||
|
only tests equality, "did it change when it should have" is the entire
|
||||||
|
specification, and `check_plugin.py` checks that completely.
|
||||||
|
|
||||||
|
The manifest is rewritten with a surgical replacement of the `version` line
|
||||||
|
rather than `json.dump`, because its formatting and key order are not this
|
||||||
|
script's to decide and a whole-file reformat would make every mint an
|
||||||
|
unreadable diff.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
|
||||||
|
|
||||||
|
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape
|
||||||
|
# for every human-readable version in the family (#3127 checklist 10). The
|
||||||
|
# padding is load-bearing for the midnight case the checklist names by hand:
|
||||||
|
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently
|
||||||
|
# shorten. Harmless while nothing orders these, wrong the moment anything does.
|
||||||
|
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||||
|
VERSION_FORMAT = "%Y.%m.%d.%H%M"
|
||||||
|
|
||||||
|
# The `version` line, captured so its surroundings survive byte-for-byte.
|
||||||
|
VERSION_LINE_RE = re.compile(r'^(\s*"version"\s*:\s*")([^"]*)(".*)$', re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def mint(now: datetime | None = None) -> str:
|
||||||
|
"""The version for this moment. UTC, always — a local-time mint would make
|
||||||
|
the value depend on who ran it."""
|
||||||
|
return (now or datetime.now(timezone.utc)).strftime(VERSION_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(text: str, version: str) -> str:
|
||||||
|
"""`text` with its `version` value replaced, and everything else untouched.
|
||||||
|
|
||||||
|
Raises rather than falling back to a JSON round-trip: a manifest this
|
||||||
|
cannot match is one whose shape changed, and quietly reformatting the file
|
||||||
|
to cope would be a much larger edit than the caller asked for.
|
||||||
|
"""
|
||||||
|
# Counted BEFORE substituting, not via subn's return: a capped `subn`
|
||||||
|
# reports the replacements it made, so a manifest with two `version` lines
|
||||||
|
# would look like a clean single match while the second one — the real one,
|
||||||
|
# perhaps — kept its old value.
|
||||||
|
matches = VERSION_LINE_RE.findall(text)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected exactly one `version` line in the manifest, found {len(matches)}"
|
||||||
|
)
|
||||||
|
return VERSION_LINE_RE.sub(
|
||||||
|
lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Mint the plugin's version.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--check", action="store_true",
|
||||||
|
help="print the version that WOULD be minted and change nothing",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
version = mint()
|
||||||
|
if args.check:
|
||||||
|
print(version)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = MANIFEST.read_text()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"cannot read {MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
previous = json.loads(text).get("version")
|
||||||
|
except Exception:
|
||||||
|
previous = None
|
||||||
|
|
||||||
|
if previous == version:
|
||||||
|
# Same minute. Not an error — the value is already correct for now, and
|
||||||
|
# failing here would turn "I ran it twice" into a problem to solve.
|
||||||
|
print(f"plugin version already {version} (same minute) — unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
MANIFEST.write_text(rewrite(text, version))
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"{MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"plugin version {previous} -> {version}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""The plugin's version is MINTED, and CI is the control that it moved.
|
||||||
|
|
||||||
|
WHAT THIS IS ABOUT (milestone 334 step 3). `plugin/` ships straight from this
|
||||||
|
git repo — no build step, so no moment at which CI could stamp a version in.
|
||||||
|
The value is therefore minted by a script before the commit, and CI's job is
|
||||||
|
not to produce it but to prove it moved when it had to.
|
||||||
|
|
||||||
|
THE DIVERGENCE THESE GUARD. Two artifacts in one repo derive their versions
|
||||||
|
from different clocks, on purpose:
|
||||||
|
|
||||||
|
server image name from COMMIT time, ordering key from BUILD time
|
||||||
|
plugin one value, from MINT time
|
||||||
|
|
||||||
|
#3127 §2 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. "Let's make these consistent" is the obvious tidy-up and it breaks
|
||||||
|
whichever artifact loses — which is why the difference is pinned here rather
|
||||||
|
than only explained in a comment.
|
||||||
|
|
||||||
|
The trade mint time makes — you cannot recompute the value from history, only
|
||||||
|
verify it moved — is acceptable ONLY because #3325 established that the
|
||||||
|
installer's refresh test is `===` with no ordering anywhere. Where a
|
||||||
|
comparator orders, an unreproducible version would be unverifiable too.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts import check_plugin
|
||||||
|
from scripts.mint_plugin_version import VERSION_RE, mint, rewrite
|
||||||
|
|
||||||
|
MINT_SRC = pathlib.Path(check_plugin.ROOT) / "scripts" / "mint_plugin_version.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_failures():
|
||||||
|
"""`check_plugin.fail` appends to a module global; without this a failing
|
||||||
|
assertion in one test would be visible from the next."""
|
||||||
|
check_plugin.failures.clear()
|
||||||
|
yield
|
||||||
|
check_plugin.failures.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def fake_manifest(version: str = "2026.09.01.2252") -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{"name": "scribe", "description": "d", "version": version,
|
||||||
|
"userConfig": {"api_endpoint": {"type": "string"}}},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── The mint ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("when,expected", [
|
||||||
|
# THE midnight case, which #3127 checklist 10 names by hand. An unpadded
|
||||||
|
# `%-H%M` renders this hour as `0` and silently shortens the string.
|
||||||
|
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
|
||||||
|
(datetime(2026, 1, 5, 0, 9, tzinfo=timezone.utc), "2026.01.05.0009"),
|
||||||
|
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
|
||||||
|
(datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc), "2026.09.01.2252"),
|
||||||
|
])
|
||||||
|
def test_the_mint_zero_pads_every_field(when, expected):
|
||||||
|
assert mint(when) == expected
|
||||||
|
assert VERSION_RE.match(mint(when))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mint_is_UTC_not_local():
|
||||||
|
"""A local-time mint would make the value depend on who ran it — two people
|
||||||
|
minting the same minute would disagree, and the string is the artifact's
|
||||||
|
identity."""
|
||||||
|
utc = datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc)
|
||||||
|
east = utc.astimezone(timezone(timedelta(hours=9)))
|
||||||
|
assert mint(east) == mint(utc) == "2026.09.01.2252"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mint_reads_a_CLOCK_and_never_git():
|
||||||
|
"""The clock divergence from the server image, asserted structurally.
|
||||||
|
|
||||||
|
Mint time is only meaningful if nothing consults history — the moment this
|
||||||
|
script shells out to git it has quietly become a commit-time deriver, and
|
||||||
|
the two artifacts' clocks have been "made consistent" without anyone
|
||||||
|
deciding to. That change would pass every other test in this file.
|
||||||
|
|
||||||
|
Asserted over the AST rather than the text, because the module docstring
|
||||||
|
discusses git at length explaining why it is absent. This looks for USE,
|
||||||
|
not mention.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(MINT_SRC.read_text())
|
||||||
|
|
||||||
|
imported = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
imported |= {a.name.split(".")[0] for a in node.names}
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imported.add(node.module.split(".")[0])
|
||||||
|
assert "subprocess" not in imported, (
|
||||||
|
"the mint script imports subprocess — a mint that can read history is "
|
||||||
|
"a commit-time deriver wearing the wrong name"
|
||||||
|
)
|
||||||
|
|
||||||
|
called = {ast.unparse(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)}
|
||||||
|
assert "datetime.now" in called, "the mint script no longer reads a clock"
|
||||||
|
|
||||||
|
|
||||||
|
# ── The rewrite ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rewrite_touches_exactly_one_line():
|
||||||
|
"""Surgical, not a JSON round-trip. The manifest's formatting and key order
|
||||||
|
are not this script's to decide, and a whole-file reformat would make every
|
||||||
|
mint an unreadable diff."""
|
||||||
|
before = fake_manifest("0.1.48")
|
||||||
|
after = rewrite(before, "2026.09.01.2252")
|
||||||
|
|
||||||
|
b, a = before.splitlines(), after.splitlines()
|
||||||
|
assert len(b) == len(a)
|
||||||
|
differing = [i for i, (x, y) in enumerate(zip(b, a)) if x != y]
|
||||||
|
assert len(differing) == 1
|
||||||
|
assert '"version": "2026.09.01.2252"' in a[differing[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rewrite_preserves_indentation_and_key_order():
|
||||||
|
weird = '{\n\t"name": "scribe",\n\t"version": "0.1.48",\n\t"z": 1\n}\n'
|
||||||
|
out = rewrite(weird, "2026.09.01.2252")
|
||||||
|
assert out == '{\n\t"name": "scribe",\n\t"version": "2026.09.01.2252",\n\t"z": 1\n}\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rewrite_refuses_a_manifest_it_cannot_match():
|
||||||
|
"""Raises rather than falling back to a JSON round-trip: a manifest this
|
||||||
|
cannot match is one whose shape changed, and quietly reformatting the file
|
||||||
|
to cope would be a far larger edit than the caller asked for."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rewrite('{"name": "scribe"}', "2026.09.01.2252")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rewrite_refuses_TWO_version_lines():
|
||||||
|
"""A capped `subn` would report one replacement and look clean while the
|
||||||
|
second `version` — possibly the real one — kept its old value."""
|
||||||
|
two = '{\n "version": "0.1.48",\n "nested": {\n "version": "9.9.9"\n }\n}\n'
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rewrite(two, "2026.09.01.2252")
|
||||||
|
|
||||||
|
|
||||||
|
# ── The version-relevant set includes its own deriver ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mint_script_is_version_relevant():
|
||||||
|
"""#3127 §3's asymmetry. A change to how the version is COMPUTED is
|
||||||
|
compared against nothing — leave the deriver out of the set and a format
|
||||||
|
change never forces a re-mint, so the manifest keeps a value in the old
|
||||||
|
format indefinitely and nothing says so."""
|
||||||
|
paths = check_plugin.version_relevant_paths()
|
||||||
|
assert "scripts/mint_plugin_version.py" in paths
|
||||||
|
for shipped in check_plugin.SHIPPED_PATHS:
|
||||||
|
assert shipped in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_checker_is_NOT_version_relevant():
|
||||||
|
"""The inverse, and it is the easy mistake. A checker decides whether the
|
||||||
|
lane goes red, not what the artifact reports — so its absence here is a
|
||||||
|
decision, not an oversight."""
|
||||||
|
assert "scripts/check_plugin.py" not in check_plugin.version_relevant_paths()
|
||||||
|
|
||||||
|
|
||||||
|
# ── The check ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(here: str, there: str | None, changed_paths: list[str], monkeypatch):
|
||||||
|
"""Drive `check_version_is_minted` with git stubbed. Returns the failures."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
check_plugin, "_git",
|
||||||
|
lambda *a: (0, "\n".join(changed_paths)) if a[0] == "diff" else (0, ""),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
check_plugin, "manifest_version",
|
||||||
|
lambda ref=None: here if ref is None else there,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
check_plugin, "manifest_text",
|
||||||
|
lambda ref=None: fake_manifest(here if ref is None else (there or "0.0.0.0000")),
|
||||||
|
)
|
||||||
|
check_plugin.check_version_is_minted("origin/main")
|
||||||
|
return list(check_plugin.failures)
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_changed_and_the_version_did_not_FAILS(monkeypatch):
|
||||||
|
"""#2209, exactly. The headline, and the only reason the check exists."""
|
||||||
|
failures = run_check(
|
||||||
|
"2026.09.01.2252", "2026.09.01.2252",
|
||||||
|
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||||
|
)
|
||||||
|
assert len(failures) == 1
|
||||||
|
assert "still 2026.09.01.2252" in failures[0]
|
||||||
|
assert "scribe_session_context.sh" in failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_changed_and_the_version_moved_PASSES(monkeypatch):
|
||||||
|
assert run_check(
|
||||||
|
"2026.09.01.2252", "2026.08.30.1200",
|
||||||
|
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_version_that_is_not_the_canonical_shape_FAILS(monkeypatch):
|
||||||
|
"""`K4` returns the manifest string verbatim, so a malformed value is not
|
||||||
|
rejected by the installer — it either sorts as an ordinary string or, when
|
||||||
|
unreadable, forces a reinstall every session. Neither is loud (#3325)."""
|
||||||
|
failures = run_check("0.1.48", "0.1.47", [], monkeypatch)
|
||||||
|
assert len(failures) == 1
|
||||||
|
assert "not YYYY.MM.DD.HHMM" in failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["2026.9.1.2252", "2026.09.01.252", "2026.09.01"])
|
||||||
|
def test_an_UNPADDED_or_short_version_FAILS(bad, monkeypatch):
|
||||||
|
"""The padding is the contract, not cosmetics — one shape for every version
|
||||||
|
in the family (#3127 checklist 10)."""
|
||||||
|
assert run_check(bad, "2026.08.30.1200", [], monkeypatch) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_version_in_the_future_FAILS(monkeypatch):
|
||||||
|
ahead = (datetime.now(timezone.utc) + timedelta(days=400)).strftime("%Y.%m.%d.%H%M")
|
||||||
|
failures = run_check(ahead, "2026.08.30.1200", [], monkeypatch)
|
||||||
|
assert len(failures) == 1
|
||||||
|
assert "in the future" in failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_version_minted_minutes_ago_is_NOT_in_the_future(monkeypatch):
|
||||||
|
"""The guard has to tolerate ordinary skew: the mint happens on a
|
||||||
|
workstation and the lane runs later, on another machine's clock."""
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y.%m.%d.%H%M")
|
||||||
|
assert run_check(now, "2026.08.30.1200", [], monkeypatch) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_changed_and_nothing_minted_PASSES(monkeypatch):
|
||||||
|
assert run_check("2026.09.01.2252", "2026.09.01.2252", [], monkeypatch) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_version_that_moved_with_no_content_change_is_NOT_a_failure(monkeypatch):
|
||||||
|
"""Deliberately a pass. A needless re-mint costs one cache refresh; 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: content changed IMPLIES version moved."""
|
||||||
|
assert run_check("2026.09.01.2252", "2026.08.30.1200", [], monkeypatch) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_diff_FAILS_rather_than_passing_quietly(monkeypatch):
|
||||||
|
"""A check that cannot run must not report the same thing as a check that
|
||||||
|
passed — #2663's lesson, and the reason this whole file's siblings exist."""
|
||||||
|
monkeypatch.setattr(check_plugin, "_git", lambda *a: (128, "fatal"))
|
||||||
|
monkeypatch.setattr(check_plugin, "manifest_version",
|
||||||
|
lambda ref=None: "2026.09.01.2252")
|
||||||
|
check_plugin.check_version_is_minted("origin/main")
|
||||||
|
assert len(check_plugin.failures) == 1
|
||||||
|
assert "could not run" in check_plugin.failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── The real manifest ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_manifest_carries_a_minted_version():
|
||||||
|
"""The end of the hand-bumped scheme, asserted on the real file. `0.1.48`
|
||||||
|
was the last of 48 numbers a person typed."""
|
||||||
|
version = json.loads(check_plugin.MANIFEST.read_text())["version"]
|
||||||
|
assert VERSION_RE.match(version), (
|
||||||
|
f"the shipped manifest says {version!r}, which is not a minted version"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_session_context_hook_still_reads_the_version_field():
|
||||||
|
"""The marker #2220 asked for. The value's SHAPE changed, not the field or
|
||||||
|
its reader — if this had to move, the derivation went somewhere it should
|
||||||
|
not have."""
|
||||||
|
hook = (check_plugin.HOOKS_DIR / "scribe_session_context.sh").read_text()
|
||||||
|
assert re.search(r"jq\s+-r\s+'\.version", hook)
|
||||||
Reference in New Issue
Block a user