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
135 lines
5.3 KiB
Python
135 lines
5.3 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 — 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())
|