git keeps an annotated tag's signature inside the message body, so `git tag -l --format=%(contents)` returns the JSON record followed by an armored block, and `json.loads` of the whole message raises "Extra data: line 2 column 1" at the armor's first byte. Claim tags, final receipts and build receipts are created on a host whose git signs tags, so stable admission (`python -m scripts.releases.stable admit`), ordered reconciliation (`python -m scripts.releases.sequencer`) and tagless build receipts each refused to read their own tag. Add `versioning.tag_record()` as the one reader of a tag's record — the text before the armor, unchanged for an unsigned tag or a detached signature — and route every `%(contents)` read through it in stable.py, sequencer.py and commit_build.py. commit_build imports it inside the function so its module surface stays stdlib-only for the isolated checkout-admission step. The reads inside the release tests use the same reader, and the one fixture that creates a plain final tag says so explicitly, so the release suite passes on a host whose git signs tags by default.
213 lines
8.6 KiB
Python
213 lines
8.6 KiB
Python
"""Ref name to version, and the next version from the published stable head.
|
|
|
|
A version is spent only by publication. Attempt refs (``rc.<N>-vX.Y.Z``) and
|
|
their abandon markers number attempts within one version; they never move the
|
|
line. CalVer tags, canary identities and receipt namespaces are not versions.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from functools import cmp_to_key
|
|
from typing import Any
|
|
|
|
from hermes_cli.update_channel import STABLE_TAG_RE
|
|
from scripts.releases.semver import compare
|
|
|
|
SEED = "0.21.4"
|
|
BUMPS = ("major", "minor", "patch")
|
|
|
|
|
|
def published_channel_identity(repository: str, channel: str, *, base_url: str | None = None,
|
|
reader_type=None) -> tuple[str, str] | None:
|
|
"""Resolve one validated protected channel's payload version and commit."""
|
|
from hermes_cli.release_channels import ChannelNotFound, ChannelReader
|
|
from scripts.releases.r2 import public_base_url
|
|
|
|
if channel not in {"stable", "canary"}:
|
|
raise ValueError("Expected a protected release channel")
|
|
reader_type = reader_type or ChannelReader
|
|
try:
|
|
resolved = reader_type(base_url or public_base_url(), repository=repository).resolve(channel)
|
|
except ChannelNotFound:
|
|
return None
|
|
if resolved.manifest is None:
|
|
return None
|
|
if resolved.terminal.get("policy") != f"{channel}-release":
|
|
raise ValueError(f"{channel.title()} channel has the wrong publication policy")
|
|
request = resolved.manifest["request"]
|
|
version, commit = request.get("version"), request.get("commit")
|
|
if not isinstance(version, str) or not isinstance(commit, str) or not re.fullmatch(r"[a-f0-9]{40}", commit):
|
|
raise ValueError(f"{channel.title()} channel has an invalid published identity")
|
|
return version, commit
|
|
|
|
|
|
def published_release_identity(repository: str, run=None) -> tuple[str, str] | None:
|
|
"""The newest published stable GitHub release and its commit, or None.
|
|
|
|
Publication makes a release public only after its final tag and draft edits
|
|
read back, so this covers every published release. That includes a release
|
|
that skipped bundles, which never moves the protected channel. A bare
|
|
``vX.Y.Z`` tag without a published release does not count.
|
|
"""
|
|
run = run or _gh
|
|
pages = json.loads(run(["gh", "api", "--paginate", "--slurp",
|
|
f"repos/{repository}/releases?per_page=100"]))
|
|
versions = [version for page in pages for row in page
|
|
if isinstance(row, dict) and row.get("draft") is False
|
|
and row.get("prerelease") is False
|
|
and (version := version_from_tag(row.get("tag_name")))]
|
|
if not versions:
|
|
return None
|
|
newest = max(versions, key=cmp_to_key(compare))
|
|
commit = run(["gh", "api", f"repos/{repository}/commits/v{newest}", "--jq", ".sha"]).strip()
|
|
if not re.fullmatch(r"[a-f0-9]{40}", commit):
|
|
raise ValueError(f"v{newest} has no valid release commit")
|
|
return newest, commit
|
|
|
|
|
|
def published_stable_identity(repository: str, *, base_url: str | None = None,
|
|
reader_type=None, run=None) -> tuple[str, str | None]:
|
|
"""The newest published stable ``(version, commit)``, or the seed before one exists.
|
|
|
|
The protected channel names the newest release that shipped bundles. The
|
|
GitHub releases also name one that skipped them. The newer of the two wins.
|
|
"""
|
|
found = published_channel_identity(
|
|
repository, "stable", base_url=base_url, reader_type=reader_type,
|
|
)
|
|
if found is not None and version_from_tag("v" + found[0]) is None:
|
|
raise ValueError("Stable channel has an invalid source version")
|
|
candidates = [identity for identity in (found, published_release_identity(repository, run))
|
|
if identity is not None]
|
|
if not candidates:
|
|
return SEED, None
|
|
return max(candidates, key=cmp_to_key(lambda a, b: compare(a[0], b[0])))
|
|
|
|
|
|
def published_stable_version(repository: str, *, base_url: str | None = None, reader_type=None,
|
|
run=None) -> str:
|
|
return published_stable_identity(repository, base_url=base_url, reader_type=reader_type,
|
|
run=run)[0]
|
|
|
|
|
|
def _gh(argv: list[str]) -> str:
|
|
return subprocess.check_output(argv, text=True, encoding="utf-8")
|
|
|
|
|
|
def version_from_tag(ref: str) -> str | None:
|
|
"""The version a final release tag names, or None for anything else.
|
|
|
|
``-rc`` claims, build-metadata identities and non-``v`` receipt namespaces
|
|
are not final tags, and a 4-digit major is a CalVer label.
|
|
"""
|
|
if not isinstance(ref, str) or not STABLE_TAG_RE.fullmatch(ref):
|
|
return None
|
|
return ref[1:]
|
|
|
|
|
|
def tag_record(raw: str) -> Any:
|
|
"""Decode the JSON record an annotated release tag carries.
|
|
|
|
``git tag -l --format=%(contents)`` returns the whole message, and git keeps
|
|
a signature *inside* the message body: a signed tag reads as the record
|
|
followed by an armored signature block. The record is everything before the
|
|
armor, so every reader parses through here, never ``json.loads(raw)``.
|
|
|
|
Detached signatures (``git tag --detach-sign``) live outside the message and
|
|
leave this unchanged, as does an unsigned tag.
|
|
"""
|
|
return json.loads(raw.split("\n-----BEGIN ", 1)[0])
|
|
|
|
|
|
# The ref grammar only anchors; ``version_from_tag`` owns the version shape.
|
|
_ATTEMPT_REF_RE = re.compile(r"rc\.(?P<attempt>[1-9][0-9]*)-(?P<tag>v[^/]+)")
|
|
_MARKER_PREFIX = "abandoned-"
|
|
|
|
|
|
def parse_attempt_ref(ref: str) -> tuple[str, int] | None:
|
|
"""``rc.<N>-vX.Y.Z`` to ``(version, N)``, or None for anything else.
|
|
|
|
The attempt comes first so the ref can never read as a SemVer prerelease
|
|
of the version it claims.
|
|
"""
|
|
match = _ATTEMPT_REF_RE.fullmatch(ref) if isinstance(ref, str) else None
|
|
if match is None:
|
|
return None
|
|
version = version_from_tag(match.group("tag"))
|
|
return (version, int(match.group("attempt"))) if version else None
|
|
|
|
|
|
def parse_marker_ref(ref: str) -> tuple[str, int] | None:
|
|
"""``abandoned-rc.<N>-vX.Y.Z``, the record that clears one attempt."""
|
|
if not isinstance(ref, str) or not ref.startswith(_MARKER_PREFIX):
|
|
return None
|
|
return parse_attempt_ref(ref[len(_MARKER_PREFIX):])
|
|
|
|
|
|
def attempt_ref(version: str, attempt: int) -> str:
|
|
ref = f"rc.{attempt}-v{version}"
|
|
if parse_attempt_ref(ref) != (version, attempt):
|
|
raise ValueError(f"invalid attempt ref: {version!r} attempt {attempt!r}")
|
|
return ref
|
|
|
|
|
|
def marker_ref(version: str, attempt: int) -> str:
|
|
return _MARKER_PREFIX + attempt_ref(version, attempt)
|
|
|
|
|
|
def _bump(version: str, bump: str) -> str:
|
|
if bump not in BUMPS:
|
|
raise ValueError(f"unknown bump {bump!r}")
|
|
major, minor, patch = (int(part) for part in version.split("."))
|
|
if bump == "major":
|
|
return f"{major + 1}.0.0"
|
|
if bump == "minor":
|
|
return f"{major}.{minor + 1}.0"
|
|
return f"{major}.{minor}.{patch + 1}"
|
|
|
|
|
|
def derive_next_version(*, published: str | None, bump: str) -> str:
|
|
"""The next version from the published head, or the seed before one exists.
|
|
|
|
Attempts do not move the line. A version is spent by publication, so an
|
|
abandoned attempt leaves its version free for the next cut.
|
|
"""
|
|
return _bump(published or SEED, bump)
|
|
|
|
|
|
def next_attempt(version: str, refs: list[str]) -> int:
|
|
"""One past the highest attempt ref for ``version``.
|
|
|
|
Marker refs do not count and do not free a number: an abandoned attempt
|
|
keeps its ref, so its number is never cut again.
|
|
"""
|
|
attempts = [parsed[1] for parsed in map(parse_attempt_ref, refs)
|
|
if parsed and parsed[0] == version]
|
|
return max(attempts, default=0) + 1
|
|
|
|
|
|
def outstanding_attempts(refs: list[str], is_published) -> list[tuple[str, int, str]]:
|
|
"""The one definition of an outstanding attempt.
|
|
|
|
An attempt ref is outstanding when its abandon marker ref does not exist
|
|
and its version has no final tag. ``is_published(version)`` answers whether
|
|
the final tag ``v{version}`` exists, so callers keep their own remote
|
|
lookup. Both the release entrypoint and the sequencer refuse more than one
|
|
of these, across all versions.
|
|
"""
|
|
cleared = {parsed for parsed in map(parse_marker_ref, refs) if parsed}
|
|
published: dict[str, bool] = {}
|
|
outstanding: list[tuple[str, int, str]] = []
|
|
for ref in refs:
|
|
parsed = parse_attempt_ref(ref)
|
|
if parsed is None or parsed in cleared:
|
|
continue
|
|
version, attempt = parsed
|
|
if version not in published:
|
|
published[version] = bool(is_published(version))
|
|
if not published[version]:
|
|
outstanding.append((version, attempt, ref))
|
|
return outstanding
|