feat(release): cut rc.N attempt refs behind one global lock
A version is now spent only by publication. derive_next_version reads the published stable head alone, and release claims the next attempt of that version as rc.<N>-vX.Y.Z. An abandon marker clears an attempt without freeing its number. At most one attempt, of any version, is outstanding: an attempt ref with no marker and no final tag on the remote. release refuses while one exists and prints its workflow run, the abandon command, and the rerun command. The pre-check and the push are not atomic across versions, so release re-reads the attempts after its push and stops before the draft and the dispatch if a concurrent cut of another version landed. A new attempt must descend from the published stable head, read from the stable channel with its version. Abandoned attempts put no constraint on it. Fetch refspecs are rc.* and abandoned-rc.*: a refspec may hold one '*', and the parsers filter what the globs over-match.
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
"""The thin release entrypoint: claim a version, cut the draft, dispatch the gate.
|
||||
"""The thin release entrypoint: claim an attempt, cut the draft, dispatch the gate.
|
||||
|
||||
Nothing here builds. The claim is an annotated ``-rc`` tag pushed as exactly
|
||||
that ref, and a dispatch that never starts is an error — the claim stays,
|
||||
because a burned version is never retried under the same number.
|
||||
Nothing here builds. The claim is an annotated attempt ref ``rc.<N>-vX.Y.Z``
|
||||
pushed as exactly that ref. A version is spent only by publication: an attempt
|
||||
that fails is abandoned with a marker ref, and the next cut is attempt N+1 of
|
||||
the same version. At most one attempt, of any version, is outstanding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.releases.versioning import SEED, derive_next_version
|
||||
from scripts.releases.versioning import (
|
||||
SEED, attempt_ref, derive_next_version, next_attempt, parse_attempt_ref, parse_marker_ref,
|
||||
)
|
||||
|
||||
WORKFLOW = "stable-release.yml"
|
||||
# A fetch refspec may hold one ``*``; the parsers filter what the globs over-match.
|
||||
_ATTEMPT_GLOBS = ("rc.*", "abandoned-rc.*")
|
||||
|
||||
|
||||
class ReleaseRefused(RuntimeError):
|
||||
@@ -26,8 +32,10 @@ def _git(repo: Path, *args: str) -> str:
|
||||
|
||||
|
||||
def _claims(repo: Path) -> list[str]:
|
||||
listed = _git(repo, "tag", "--list", "v*-rc")
|
||||
return [tag for tag in listed.splitlines() if tag]
|
||||
"""Every local attempt ref and abandon marker ref."""
|
||||
listed = _git(repo, "tag", "--list", *_ATTEMPT_GLOBS)
|
||||
return [ref for ref in listed.splitlines()
|
||||
if parse_attempt_ref(ref) or parse_marker_ref(ref)]
|
||||
|
||||
|
||||
def _claim_commit(repo: Path, tag: str) -> str:
|
||||
@@ -38,7 +46,7 @@ def _refresh_claims(repo: Path, remote: str) -> None:
|
||||
_git(
|
||||
repo, "fetch", remote,
|
||||
"+refs/heads/main:refs/remotes/hermes-release/main",
|
||||
"+refs/tags/v*-rc:refs/tags/v*-rc",
|
||||
*(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,54 +73,106 @@ def _claim_collision(repo: Path, remote: str, tag: str, error: Exception) -> Rel
|
||||
return ReleaseRefused(f"{tag} was claimed by {actor} at {when} for {commit}")
|
||||
|
||||
|
||||
def _highest_claim(repo: Path) -> tuple[str, str] | None:
|
||||
"""The highest-version outstanding claim, as (version, commit)."""
|
||||
from scripts.releases.versioning import version_from_tag
|
||||
|
||||
best: tuple[list[int], str, str] | None = None
|
||||
for tag in _claims(repo):
|
||||
version = version_from_tag(tag[:-3]) if tag.endswith("-rc") else None
|
||||
if version is None:
|
||||
def _outstanding_attempts(repo: Path, remote: str) -> list[tuple[str, int, str]]:
|
||||
"""Attempts with no abandon marker whose version has no final tag on ``remote``."""
|
||||
refs = _claims(repo)
|
||||
cleared = {parsed for parsed in map(parse_marker_ref, refs) if parsed}
|
||||
published: dict[str, bool] = {}
|
||||
outstanding = []
|
||||
for ref in refs:
|
||||
parsed = parse_attempt_ref(ref)
|
||||
if parsed is None or parsed in cleared:
|
||||
continue
|
||||
key = [int(part) for part in version.split(".")]
|
||||
if best is None or key > best[0]:
|
||||
best = (key, version, _claim_commit(repo, tag))
|
||||
return None if best is None else (best[1], best[2])
|
||||
version, attempt = parsed
|
||||
if version not in published:
|
||||
published[version] = bool(_git(repo, "ls-remote", remote, f"refs/tags/v{version}"))
|
||||
if not published[version]:
|
||||
outstanding.append((version, attempt, ref))
|
||||
return outstanding
|
||||
|
||||
|
||||
def _require_ancestry(repo: Path, commit: str) -> None:
|
||||
"""A claim's commit must descend from the highest outstanding claim's."""
|
||||
highest = _highest_claim(repo)
|
||||
if highest is None:
|
||||
def _outstanding_attempt(repo: Path, remote: str) -> tuple[str, int, str] | None:
|
||||
"""The one outstanding attempt as ``(version, attempt, ref)``, or None."""
|
||||
outstanding = _outstanding_attempts(repo, remote)
|
||||
if len(outstanding) > 1:
|
||||
refs = ", ".join(ref for *_rest, ref in outstanding)
|
||||
raise ReleaseRefused(
|
||||
f"more than one outstanding attempt ({refs}) — abandon all but one before releasing")
|
||||
return outstanding[0] if outstanding else None
|
||||
|
||||
|
||||
def _attempt_run(execute, repository: str, ref: str, commit: str) -> dict | None:
|
||||
"""The workflow run for ``ref`` at ``commit``, or None when none is listed."""
|
||||
raw = execute([
|
||||
"gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
|
||||
"--branch", ref, "--json", "databaseId,url,headBranch,headSha,status",
|
||||
])
|
||||
try:
|
||||
rows = json.loads(raw or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return next((row for row in rows if isinstance(row, dict) and row.get("headSha") == commit
|
||||
and row.get("url") and row.get("databaseId") is not None), None)
|
||||
|
||||
|
||||
def _refuse_outstanding(outstanding: tuple[str, int, str], *, repo: Path, remote: str,
|
||||
repository: str, execute) -> ReleaseRefused:
|
||||
"""Print the blocking run and both ways out, then return the refusal to raise."""
|
||||
version, _attempt, ref = outstanding
|
||||
run = _attempt_run(execute, repository, ref, _claim_commit(repo, ref))
|
||||
lines = [f"{ref} is outstanding: it has no abandon marker and v{version} is not published."]
|
||||
lines.append(f"Workflow: {run['url']}" if run else f"No workflow run is listed for {ref}.")
|
||||
lines += ["If the attempt is unfixable, abandon it:",
|
||||
f" python scripts/release.py abandon --version {version} --remote {remote}"]
|
||||
if run:
|
||||
lines += ["If the attempt is fixable, rerun its failed jobs:",
|
||||
f" gh run rerun {run['databaseId']} --failed --repo {repository}"]
|
||||
print("\n".join(lines), file=sys.stderr)
|
||||
return ReleaseRefused(f"{ref} is outstanding — publish or abandon it first")
|
||||
|
||||
|
||||
def _require_ancestry(repo: Path, commit: str, published_commit: str | None) -> None:
|
||||
"""A new attempt descends from the published stable head; abandoned attempts do not bind it."""
|
||||
if published_commit is None:
|
||||
return
|
||||
version, claimed = highest
|
||||
ancestor = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", claimed, commit], cwd=repo, capture_output=True)
|
||||
["git", "merge-base", "--is-ancestor", published_commit, commit], cwd=repo, capture_output=True)
|
||||
if ancestor.returncode != 0:
|
||||
raise ReleaseRefused(
|
||||
f"{version} already claimed at {claimed} — publish or abandon it first")
|
||||
f"{commit} does not descend from the published stable head {published_commit}")
|
||||
|
||||
|
||||
def _next_claim_epoch(repo: Path) -> int:
|
||||
epochs = _git(repo, "for-each-ref", "refs/tags/v*-rc", "--format=%(taggerdate:unix)")
|
||||
previous = [int(value) for value in epochs.splitlines() if value.isdigit()]
|
||||
epochs = _git(repo, "for-each-ref", "refs/tags/rc.*", "--format=%(refname:strip=2) %(taggerdate:unix)")
|
||||
previous = [int(stamp) for ref, _, stamp in (line.partition(" ") for line in epochs.splitlines())
|
||||
if parse_attempt_ref(ref) and stamp.isdigit()]
|
||||
return max(int(time.time()), max(previous, default=0) + 1)
|
||||
|
||||
|
||||
def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
execute, autopublish: bool = False, published: str = SEED) -> dict:
|
||||
"""Claim the derived version, cut its draft, and start the gate."""
|
||||
execute, autopublish: bool = False,
|
||||
published: tuple[str, str | None] = (SEED, None)) -> dict:
|
||||
"""Claim the next attempt of the derived version, cut its draft, and start the gate.
|
||||
|
||||
``published`` is the stable channel's ``(version, commit)``; the commit is
|
||||
None before the first publication.
|
||||
"""
|
||||
_refresh_claims(repo, remote)
|
||||
_require_remote_main(repo, commit)
|
||||
_require_ancestry(repo, commit)
|
||||
version = derive_next_version(
|
||||
published=published, claims=_claims(repo), bump=bump,
|
||||
)
|
||||
tag = f"v{version}-rc"
|
||||
outstanding = _outstanding_attempt(repo, remote)
|
||||
if outstanding is not None:
|
||||
raise _refuse_outstanding(outstanding, repo=repo, remote=remote,
|
||||
repository=repository, execute=execute)
|
||||
published_version, published_commit = published
|
||||
_require_ancestry(repo, commit, published_commit)
|
||||
version = derive_next_version(published=published_version, bump=bump)
|
||||
attempt = next_attempt(version, _claims(repo))
|
||||
tag = attempt_ref(version, attempt)
|
||||
claim_epoch = _next_claim_epoch(repo)
|
||||
claim = json.dumps({
|
||||
"schema": 1,
|
||||
"version": version,
|
||||
"attempt": attempt,
|
||||
"commit": commit,
|
||||
"autopublish": autopublish,
|
||||
"claimEpoch": claim_epoch,
|
||||
@@ -133,6 +193,10 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
if (remote_ref.get(ref) != _git(repo, "rev-parse", ref)
|
||||
or remote_ref.get(f"{ref}^{{}}") != commit):
|
||||
raise ReleaseRefused(f"claim {tag} did not persist with exact remote custody")
|
||||
# The pre-check and the push are not one atomic step: a concurrent cut of a
|
||||
# different version passes the same check. Re-read before anything starts.
|
||||
_refresh_claims(repo, remote)
|
||||
_outstanding_attempt(repo, remote)
|
||||
url = f"https://github.com/{repository}/releases/tag/{tag}"
|
||||
try:
|
||||
execute([
|
||||
@@ -235,7 +299,7 @@ def next_steps(result: dict) -> str:
|
||||
"""Say what started, what the operator waits for, and the next action."""
|
||||
version = result["version"]
|
||||
lines = [
|
||||
f"Claimed v{version}. The release workflow started on {result['tag']}.",
|
||||
f"Claimed {result['tag']} for v{version}. The release workflow started on {result['tag']}.",
|
||||
f"Workflow: {result['run_url']}" if result.get("run_url") else "Workflow: the run is not listed yet. Open the Actions tab for this claim.",
|
||||
"Wait for that workflow to finish. It builds and tests this commit.",
|
||||
f"When it is green, the release notes are at {result['final_url']}.",
|
||||
@@ -266,11 +330,11 @@ def cmd_release(args) -> None:
|
||||
raise RuntimeError(completed.stderr.strip() or "release command failed")
|
||||
return completed.stdout
|
||||
|
||||
from scripts.releases.versioning import published_stable_version
|
||||
from scripts.releases.versioning import published_stable_identity
|
||||
result = release(
|
||||
commit, bump=args.bump, repo=repo, remote=remote, repository=repository,
|
||||
execute=execute, autopublish=args.autopublish,
|
||||
published=published_stable_version(repository),
|
||||
published=published_stable_identity(repository),
|
||||
)
|
||||
print(result["url"])
|
||||
print(next_steps(result))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Ref name to version, and the next version from the release family.
|
||||
"""Ref name to version, and the next version from the published stable head.
|
||||
|
||||
The family is the published stable head, then outstanding ``-rc`` claims, then
|
||||
the seed when both are empty. CalVer tags and receipt tags are excluded: a
|
||||
CalVer tag is a valid three-component version and would win every ``max()``.
|
||||
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
|
||||
|
||||
@@ -103,12 +103,6 @@ def marker_ref(version: str, attempt: int) -> str:
|
||||
return _MARKER_PREFIX + attempt_ref(version, attempt)
|
||||
|
||||
|
||||
def _claim_version(tag: str) -> str | None:
|
||||
if isinstance(tag, str) and tag.endswith("-rc"):
|
||||
return version_from_tag(tag[:-3])
|
||||
return None
|
||||
|
||||
|
||||
def _bump(version: str, bump: str) -> str:
|
||||
if bump not in BUMPS:
|
||||
raise ValueError(f"unknown bump {bump!r}")
|
||||
@@ -120,13 +114,21 @@ def _bump(version: str, bump: str) -> str:
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def derive_next_version(*, published: str | None, claims: list[str], bump: str) -> str:
|
||||
"""The next version: max of the family, then the bump.
|
||||
def derive_next_version(*, published: str | None, bump: str) -> str:
|
||||
"""The next version from the published head, or the seed before one exists.
|
||||
|
||||
``claims`` may contain anything a tag list contains. Only ``v<semver>-rc``
|
||||
entries count; CalVer labels and receipt tags are ignored, not errors.
|
||||
Attempts do not move the line. A version is spent by publication, so an
|
||||
abandoned attempt leaves its version free for the next cut.
|
||||
"""
|
||||
family = [published] if published else []
|
||||
family.extend(version for version in (_claim_version(tag) for tag in claims) if version)
|
||||
base = max(family, key=lambda version: [int(part) for part in version.split(".")]) if family else SEED
|
||||
return _bump(base, bump)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user