CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s
The acceptance greps found what the per-area passes missed: - src/scribe: "Phase 7/8" removal notes in projects, settings and user_profile; a paragraph promising an auto-summary side effect that no longer exists; user_profile's claim that "upcoming MCP profile tools" read the profile, which nothing does; an instance count and instance rule titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks. - scripts/: check_plugin's header and inline comments were a history of the incidents behind each guard, with issue numbers in two failure messages; check_dangling_styles named this app's classes as its examples; check_design_tokens, measure_duplication and mint_plugin_version cited issues and checklists. - frontend/src: forty-odd comments with four-digit issue numbers that the earlier frontend scan filtered out as hex colours, plus "used to" and "Phase N" notes. Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration Create Date headers and one migration's dated data floor, and the plugin skills' worked examples of how to cite a record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
#!/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. 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 the installer's code does: 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. The padding is
|
|
# load-bearing for the midnight case:
|
|
# 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.
|
|
|
|
The conversion is not decoration: `strftime` renders whatever offset the
|
|
datetime carries, so without it two people minting the same instant in
|
|
different zones produce different strings — and the string IS the
|
|
artifact's identity. A naive datetime is read as UTC rather than as the
|
|
machine's zone, because that is this function's stated contract and
|
|
guessing the host's offset is how the bug comes back by another route.
|
|
"""
|
|
moment = now or datetime.now(timezone.utc)
|
|
if moment.tzinfo is None:
|
|
moment = moment.replace(tzinfo=timezone.utc)
|
|
return moment.astimezone(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())
|