CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 15s
Run 5175 red on the Python tests lane. The failing assertion was test_the_mint_is_UTC_not_local, and it was right: `strftime` renders the offset the datetime carries, so mint() only produced UTC because its DEFAULT argument happens to be datetime.now(timezone.utc). Hand it an aware datetime in any other zone and it formats that zone's wall clock -- 22:52Z and its +09:00 twin, the same instant, minted as 2026.09.01.2252 and 2026.09.02.0752. The docstring already claimed "UTC, always", so this was a contract the code did not hold rather than a test asking for something new. Two people minting the same instant would disagree, and the string IS the artifact's identity. Now converts explicitly. A naive datetime is read as UTC rather than as the machine's zone: that is this function's stated contract, and guessing the host's offset is how the same bug returns by another route. Two things found while walking the rest of the module by hand: - test_a_failed_diff_FAILS_rather_than_passing_quietly stubbed EVERY git call to fail, so it tripped the base-branch guard first and passed while proving nothing about the diff arm. rev-parse now succeeds and only the diff fails, and the assertion names the diff message instead of the substring both messages happen to share. - the base-branch failure still said "version-bump check", a name that went away with check_version_bump. The mint script is in the version-relevant set, so fixing it is itself a version-relevant change and forced a fresh mint -- 2026.09.02.0415. That is the asymmetry in #3127 section 3 working as intended rather than a quirk: a format change that did not re-mint would leave the manifest reporting a value the current deriver can no longer produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
145 lines
5.8 KiB
Python
145 lines
5.8 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 (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.
|
|
|
|
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())
|