31662dc3ff
Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/dev/stack-dev/SKILL.md - instructions/dev/version-parts.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/version.py
375 lines
15 KiB
Python
375 lines
15 KiB
Python
"""The stack's own version: what `VERSION` holds, how a release stamps an
|
|
instance, and when two versions are compatible.
|
|
|
|
The version describes the **stack** - `tools/`, `types/`, `instructions/`,
|
|
`AGENTS.md` and the contracts - never the wiki content sitting next to it in
|
|
the same repo. That split is the whole reason it is set explicitly rather than
|
|
derived from commit messages: `publish --message "ingest: ..."` writes content
|
|
commits into this same repo, so a conventional-commit reading would turn every
|
|
ingest into a release.
|
|
|
|
**Compatibility is read off the leftmost non-zero component**, the rule Cargo's
|
|
caret ranges use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from
|
|
`1.0.0` on the same rule reads as the familiar "MAJOR breaks". Stating it that
|
|
way is what lets the 0.x era carry the signal at all - under a rule keyed to
|
|
the MAJOR component alone, every 0.x release would be indistinguishable from
|
|
every other, which is exactly the signal update detection needs. Nothing about
|
|
the mechanism changes at 1.0.0.
|
|
|
|
What that component answers is **whether the new version is a drop-in
|
|
replacement**: whether an instance can copy the new machinery over itself with
|
|
no hand-work and still put the old version back afterwards. Whether *content*
|
|
must be migrated is a **second, independent question**. It is one way to fail
|
|
the first - but a renamed release feed, artefact, import name, flag or envvar
|
|
fails it too, with `kb/` untouched, which is why `--no-migration` exists at all:
|
|
boundary-crossing bumps that migrate nothing are a real case, not an escape
|
|
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
|
|
records the break, `MIGRATION_NONE_MARKER` records the absence of the
|
|
migration. Which part a change earns stays a judgment call made before the
|
|
bump; this module only enforces that a crossing says what it costs.
|
|
|
|
Paths are resolved through `config.ROOT` at call time rather than at import,
|
|
because the tests (and `dist export`'s own fixtures) relocate the root.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
from chemenu import config
|
|
|
|
VERSION_FILENAME = "VERSION"
|
|
CHANGES_FILENAME = "CHANGES.md"
|
|
|
|
# Written into an exported distribution by `dist export`, and committed with
|
|
# it: an instance has to keep knowing which stack it came from, or update
|
|
# detection has nothing to compare against. Machine-written, never hand-edited.
|
|
RELEASE_STAMP_FILENAME = ".wikitool-release.json"
|
|
STAMP_SCHEMA = 1
|
|
|
|
# Where `version check` looks when neither the environment nor a release stamp
|
|
# names something else. A fork changes this line; an instance exported from a
|
|
# fork carries the fork's URL in its stamp and never reaches this default.
|
|
DEFAULT_UPDATE_URL = (
|
|
"https://gitea.nehmer.net/api/v1/repos/torben/chemenu/releases/latest"
|
|
)
|
|
UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
|
# Optional: only needed if the origin's release feed is not readable
|
|
# anonymously. Absent is the normal case, not a misconfiguration.
|
|
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
|
|
|
PARTS = ("major", "minor", "patch")
|
|
|
|
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
|
# second ordering rule everywhere a version is compared - the release feed, the
|
|
# migration chain, the compatibility check - to serve a workflow this stack does
|
|
# not have.
|
|
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
|
|
|
# Written into a CHANGES.md entry whose version crosses a compatibility
|
|
# boundary that needs no content migration. `docs verify` accepts it in place
|
|
# of a migration document, so the exact string is a contract between the two.
|
|
MIGRATION_NONE_MARKER = "**Migration:** none required"
|
|
# Written into every CHANGES.md entry whose version crosses a compatibility
|
|
# boundary, migration or not: the swap is not drop-in, and the operator of an
|
|
# existing instance has to be told what stops working. `docs verify` checks the
|
|
# newest crossing carries it, so this string too is a contract between the two.
|
|
BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
|
# A changelog entry that names a version. Entries predating versioning start
|
|
# with a date instead and are deliberately not matched - they are history, not
|
|
# a claim about which version the tree is.
|
|
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
|
|
|
|
|
class VersionError(ValueError):
|
|
"""A version could not be read, parsed, or fetched. Carries a message
|
|
written to be shown to the user verbatim."""
|
|
|
|
|
|
@dataclass(frozen=True, order=True)
|
|
class Version:
|
|
major: int
|
|
minor: int
|
|
patch: int
|
|
|
|
@classmethod
|
|
def parse(cls, text: str) -> "Version":
|
|
match = _SEMVER_RE.match(text or "")
|
|
if not match:
|
|
raise VersionError(
|
|
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH"
|
|
)
|
|
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
|
|
def __str__(self) -> str: # noqa: D105 - obvious
|
|
return f"{self.major}.{self.minor}.{self.patch}"
|
|
|
|
def bumped(self, part: str) -> "Version":
|
|
if part == "major":
|
|
return Version(self.major + 1, 0, 0)
|
|
if part == "minor":
|
|
return Version(self.major, self.minor + 1, 0)
|
|
if part == "patch":
|
|
return Version(self.major, self.minor, self.patch + 1)
|
|
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
|
|
|
@property
|
|
def compat_key(self) -> tuple[int, ...]:
|
|
"""The prefix up to and including the leftmost non-zero component.
|
|
|
|
Two versions are compatible exactly when this is equal. `0.1.3` and
|
|
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
|
non-zero component, so it compares by all three - during `0.0.x`
|
|
every release is a breaking one, which is what that range means.
|
|
"""
|
|
components = (self.major, self.minor, self.patch)
|
|
for index, component in enumerate(components):
|
|
if component:
|
|
return components[: index + 1]
|
|
return components
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UpdateStatus:
|
|
"""The answer `version check` reports. `state` is the actionable part:
|
|
`migration` is not a louder `update`, it is a different instruction."""
|
|
|
|
local: Version
|
|
latest: Version
|
|
state: str # "current" | "update" | "migration" | "ahead"
|
|
release_url: Optional[str] = None
|
|
published_at: Optional[str] = None
|
|
|
|
@property
|
|
def headline(self) -> str:
|
|
if self.state == "current":
|
|
return f"Up to date: {self.local} is the latest release."
|
|
if self.state == "ahead":
|
|
return (
|
|
f"Local stack {self.local} is ahead of the latest release {self.latest} "
|
|
"- an unreleased tree."
|
|
)
|
|
if self.state == "migration":
|
|
return (
|
|
f"Update available: {self.local} -> {self.latest}. This crosses a "
|
|
"compatibility boundary - the release notes name the migration required."
|
|
)
|
|
return f"Update available: {self.local} -> {self.latest} (compatible)."
|
|
|
|
|
|
def compare(local: Version, latest: Version) -> str:
|
|
if latest == local:
|
|
return "current"
|
|
if latest < local:
|
|
return "ahead"
|
|
return "update" if latest.compat_key == local.compat_key else "migration"
|
|
|
|
|
|
def version_file() -> Path:
|
|
return config.ROOT / VERSION_FILENAME
|
|
|
|
|
|
def stamp_file() -> Path:
|
|
return config.ROOT / RELEASE_STAMP_FILENAME
|
|
|
|
|
|
def changes_file() -> Path:
|
|
return config.ROOT / CHANGES_FILENAME
|
|
|
|
|
|
def read_version() -> Version:
|
|
"""This tree's stack version. Raises rather than guessing: a stack with no
|
|
declared version cannot answer "is there an update", and a placeholder
|
|
would answer it wrongly."""
|
|
path = version_file()
|
|
if not path.is_file():
|
|
raise VersionError(
|
|
f"{VERSION_FILENAME} is missing - this tree declares no stack version"
|
|
)
|
|
return Version.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_version(version: Version) -> None:
|
|
version_file().write_text(f"{version}\n", encoding="utf-8")
|
|
|
|
|
|
def read_stamp() -> Optional[dict]:
|
|
"""The release stamp, if this tree came from one. `None` is a normal
|
|
answer - a dev checkout has no stamp - so a malformed one is the only
|
|
case worth failing over."""
|
|
path = stamp_file()
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
raise VersionError(f"{RELEASE_STAMP_FILENAME} is not readable JSON: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise VersionError(f"{RELEASE_STAMP_FILENAME} does not contain a JSON object")
|
|
return data
|
|
|
|
|
|
def update_url(stamp: Optional[dict] = None) -> str:
|
|
"""Where to ask for the latest release: the environment overrides, then
|
|
the stamp this instance was exported with, then the compiled-in default."""
|
|
override = os.environ.get(UPDATE_URL_ENV, "").strip()
|
|
if override:
|
|
return override
|
|
if stamp:
|
|
from_stamp = str(stamp.get("update_url") or "").strip()
|
|
if from_stamp:
|
|
return from_stamp
|
|
return DEFAULT_UPDATE_URL
|
|
|
|
|
|
Fetcher = Callable[[str, Optional[str], float], bytes]
|
|
|
|
|
|
def _urlopen_fetch(url: str, token: Optional[str], timeout: float) -> bytes:
|
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
if token:
|
|
request.add_header("Authorization", f"token {token}")
|
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - explicit https URL
|
|
return response.read()
|
|
|
|
|
|
def fetch_latest(
|
|
url: str,
|
|
token: Optional[str] = None,
|
|
timeout: float = 10.0,
|
|
fetcher: Optional[Fetcher] = None,
|
|
) -> Version:
|
|
"""The version the release feed reports as latest.
|
|
|
|
The network call sits behind `fetcher` so every caller above this line -
|
|
and every test - can run without a network. This is the one place in
|
|
`wikitool` that talks to a remote host, and it is reached only from
|
|
`version check`, never implicitly from another command.
|
|
"""
|
|
fetch = fetcher or _urlopen_fetch
|
|
try:
|
|
payload = fetch(url, token, timeout)
|
|
except urllib.error.HTTPError as exc:
|
|
hint = ""
|
|
if exc.code in (401, 403):
|
|
hint = f" - the feed needs authentication; set ${UPDATE_TOKEN_ENV}"
|
|
elif exc.code == 404:
|
|
hint = " - no release published yet, or the URL names the wrong repository"
|
|
raise VersionError(f"{url} answered HTTP {exc.code}{hint}") from exc
|
|
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
raise VersionError(f"Could not reach {url}: {exc}") from exc
|
|
|
|
try:
|
|
data = json.loads(payload)
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise VersionError(f"{url} did not answer with JSON: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise VersionError(f"{url} answered with JSON that is not an object")
|
|
|
|
tag = str(data.get("tag_name") or "").strip()
|
|
if not tag:
|
|
raise VersionError(f"{url} answered without a `tag_name` - not a release feed")
|
|
return Version.parse(tag)
|
|
|
|
|
|
def fetch_latest_release(
|
|
url: str,
|
|
token: Optional[str] = None,
|
|
timeout: float = 10.0,
|
|
fetcher: Optional[Fetcher] = None,
|
|
) -> tuple[Version, Optional[str], Optional[str]]:
|
|
"""`fetch_latest` plus the two display fields a report wants: the release's
|
|
own page and its publication date."""
|
|
fetch = fetcher or _urlopen_fetch
|
|
captured: dict = {}
|
|
|
|
def capturing(u: str, t: Optional[str], to: float) -> bytes:
|
|
payload = fetch(u, t, to)
|
|
try:
|
|
parsed = json.loads(payload)
|
|
if isinstance(parsed, dict):
|
|
captured.update(parsed)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
pass
|
|
return payload
|
|
|
|
version = fetch_latest(url, token, timeout, capturing)
|
|
html_url = captured.get("html_url") or captured.get("url")
|
|
published = captured.get("published_at") or captured.get("created_at")
|
|
return version, (str(html_url) if html_url else None), (str(published) if published else None)
|
|
|
|
|
|
# --- CHANGES.md ------------------------------------------------------------
|
|
#
|
|
# The changelog is prose and stays the author's job. What is mechanical is the
|
|
# heading - which version, which date - and checking that the topmost one
|
|
# agrees with VERSION. Same split as `new`: the tool writes structure, the LLM
|
|
# writes the entry.
|
|
|
|
|
|
def top_changes_version(text: str) -> Optional[Version]:
|
|
"""The version named by the topmost versioned entry, or `None` when the
|
|
changelog has none. `None` is valid: a fresh distribution ships a changelog
|
|
with no entries at all, and this repo's own history predates versioning."""
|
|
match = _CHANGES_ENTRY_RE.search(text)
|
|
if not match:
|
|
return None
|
|
return Version.parse(match.group(1))
|
|
|
|
|
|
def changes_section(text: str, version: Version) -> Optional[str]:
|
|
"""The body of one version's entry, heading included, ready to become
|
|
release notes.
|
|
|
|
The entry ends at the next `##` heading *of any kind*, not the next
|
|
versioned one: entries below `0.1.0` predate versioning and are headed by
|
|
a date, so terminating on a versioned heading would run the newest entry
|
|
all the way to the end of the file - which is exactly what it did.
|
|
"""
|
|
for match in _CHANGES_ENTRY_RE.finditer(text):
|
|
if Version.parse(match.group(1)) != version:
|
|
continue
|
|
rest = text[match.start():]
|
|
following = re.search(r"^## ", rest[1:], re.MULTILINE)
|
|
section = rest[: following.start() + 1] if following else rest
|
|
return section.rstrip().removesuffix("---").rstrip() + "\n"
|
|
return None
|
|
|
|
|
|
def insert_changes_entry(
|
|
text: str,
|
|
version: Version,
|
|
date: str,
|
|
title: str,
|
|
author: str,
|
|
no_migration_reason: Optional[str] = None,
|
|
breaking_reason: Optional[str] = None,
|
|
) -> str:
|
|
"""Add a heading for `version` above the newest existing entry.
|
|
|
|
Only the skeleton: heading, date, author, and - when a compatibility
|
|
boundary is crossed - the line saying what breaks, plus the line saying no
|
|
content has to change where that applies. The entry's actual content is
|
|
written afterwards by whoever made the change, which is also why `bump`
|
|
refuses to invent a title.
|
|
|
|
The break comes first: it is what an operator reading the release notes has
|
|
to act on, and the migration line only qualifies it.
|
|
"""
|
|
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
|
if breaking_reason:
|
|
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
|
if no_migration_reason:
|
|
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
|
entry = "\n".join(lines) + "\n---\n\n"
|
|
anchor = re.search(r"^## ", text, re.MULTILINE)
|
|
if anchor:
|
|
return text[: anchor.start()] + entry + text[anchor.start():]
|
|
return text.rstrip() + "\n\n---\n\n" + entry
|