596 lines
28 KiB
Python
596 lines
28 KiB
Python
"""The thin release entrypoint: claim an attempt, cut the draft, dispatch the gate.
|
|
|
|
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 tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from scripts.releases.draft_warning import draft_body
|
|
from scripts.releases.versioning import (
|
|
SEED, attempt_ref, derive_next_version, marker_ref, next_attempt,
|
|
outstanding_attempts, 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):
|
|
"""The release cannot proceed, and nothing was silently skipped."""
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str:
|
|
return subprocess.check_output(["git", *args], cwd=repo, text=True, encoding="utf-8").strip()
|
|
|
|
|
|
def _claims(repo: Path, remote: str) -> list[str]:
|
|
"""Every attempt ref and abandon marker ref on ``remote``, fetched.
|
|
|
|
The remote is the only authority. A local tag that was never pushed (a
|
|
failed claim, a hand-made ref) is not a claim and must not count. The
|
|
listed refs are fetched by exact name before this returns, so the list and
|
|
its objects are one snapshot: a claim pushed after an earlier fetch is
|
|
local here, and a stray local tag of a listed name is replaced by the
|
|
remote's.
|
|
"""
|
|
listed = _git(repo, "ls-remote", "--tags", remote, *(f"refs/tags/{glob}" for glob in _ATTEMPT_GLOBS))
|
|
refs = (line.split("\t", 1)[1].removeprefix("refs/tags/")
|
|
for line in listed.splitlines() if "\t" in line)
|
|
claims = sorted({ref for ref in refs if parse_attempt_ref(ref) or parse_marker_ref(ref)})
|
|
if claims:
|
|
_git(repo, "fetch", remote, *(f"+refs/tags/{ref}:refs/tags/{ref}" for ref in claims))
|
|
return claims
|
|
|
|
|
|
def _claim_commit(repo: Path, tag: str) -> str:
|
|
return _git(repo, "rev-parse", f"{tag}^{{commit}}")
|
|
|
|
|
|
def _refresh_claims(repo: Path, remote: str) -> None:
|
|
_git(
|
|
repo, "fetch", remote,
|
|
"+refs/heads/main:refs/remotes/hermes-release/main",
|
|
*(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS),
|
|
)
|
|
|
|
|
|
def _require_remote_main(repo: Path, commit: str) -> None:
|
|
result = subprocess.run(
|
|
["git", "merge-base", "--is-ancestor", commit, "refs/remotes/hermes-release/main"],
|
|
cwd=repo, capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise ReleaseRefused(f"{commit} is not on origin/main")
|
|
|
|
|
|
def _claim_collision(repo: Path, remote: str, tag: str, error: Exception) -> ReleaseRefused:
|
|
subprocess.run(["git", "tag", "--delete", tag], cwd=repo, capture_output=True, check=False)
|
|
try:
|
|
_git(repo, "fetch", remote, f"+refs/tags/{tag}:refs/tags/{tag}")
|
|
details = _git(
|
|
repo, "for-each-ref", f"refs/tags/{tag}",
|
|
"--format=%(taggername)|%(taggerdate:iso-strict)|%(*objectname)",
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
return ReleaseRefused(f"claim {tag} could not be pushed: {error}")
|
|
actor, when, commit = details.split("|", 2)
|
|
return ReleaseRefused(f"{tag} was claimed by {actor} at {when} for {commit}")
|
|
|
|
|
|
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``.
|
|
|
|
The predicate is the shared one in ``versioning``; this wrapper only owns
|
|
the remote lookup of the final tags.
|
|
"""
|
|
published: dict[str, bool] = {}
|
|
|
|
def is_published(version: str) -> bool:
|
|
if version not in published:
|
|
published[version] = bool(_git(repo, "ls-remote", remote, f"refs/tags/v{version}"))
|
|
return published[version]
|
|
|
|
return outstanding_attempts(_claims(repo, remote), is_published)
|
|
|
|
|
|
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
|
|
ancestor = subprocess.run(
|
|
["git", "merge-base", "--is-ancestor", published_commit, commit], cwd=repo, capture_output=True, check=False)
|
|
if ancestor.returncode != 0:
|
|
raise ReleaseRefused(
|
|
f"{commit} does not descend from the published stable head {published_commit}")
|
|
|
|
|
|
def _next_claim_epoch(repo: Path, claims: list[str]) -> int:
|
|
"""One past the newest claim epoch; ``claims`` are fetched, so their objects are local."""
|
|
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 ref in claims and parse_attempt_ref(ref) and stamp.isdigit()]
|
|
return max(int(time.time()), max(previous, default=0) + 1)
|
|
|
|
|
|
# GitHub refuses a release body longer than this with HTTP 422.
|
|
GITHUB_BODY_LIMIT = 125_000
|
|
|
|
|
|
def _changelog(repo: Path, repository: str, *, commit: str, tag: str, version: str,
|
|
published: tuple[str, str | None], no_changelog: bool) -> str:
|
|
"""The commit changelog from the stable base to the cut commit.
|
|
|
|
GitHub's generate-notes lists merged pull requests since the last published
|
|
release, so it is empty on a repository that merges none (a fork).
|
|
"""
|
|
from scripts import release as release_script
|
|
|
|
published_version, base = published
|
|
receipt = f"v{published_version}"
|
|
if base is None and subprocess.run(
|
|
["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{receipt}^{{commit}}"],
|
|
cwd=repo, capture_output=True, check=False).returncode == 0:
|
|
# Before the first publication the seed version's tag is the base.
|
|
base = receipt
|
|
commits = release_script.get_commits(since_tag=base, until=commit, cwd=repo)
|
|
return release_script.generate_changelog(
|
|
commits, tag, version, repo_url=f"https://github.com/{repository}",
|
|
prev_tag=receipt if base else None, no_changelog=no_changelog,
|
|
)
|
|
|
|
|
|
def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
|
execute, autopublish: bool = False, no_changelog: bool = False,
|
|
skip_bundles: bool = False, skip_tests: bool = False,
|
|
published: tuple[str, str | None] = (SEED, None),
|
|
run_wait: float = 0, sleep=time.sleep) -> 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. ``skip_bundles`` and ``skip_tests`` are
|
|
written into the claim, which is the one record every later job reads.
|
|
``run_wait`` is how many seconds to keep looking for the dispatched run;
|
|
GitHub lists it a moment after the dispatch returns.
|
|
"""
|
|
_refresh_claims(repo, remote)
|
|
_require_remote_main(repo, commit)
|
|
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)
|
|
if _git(repo, "ls-remote", remote, f"refs/tags/v{version}"):
|
|
# A final tag records a started publication. Until its GitHub release is
|
|
# public the published identity still names the previous version.
|
|
raise ReleaseRefused(
|
|
f"v{version} already has a final tag. Its publication is still finishing. "
|
|
"Wait for Stable Release Publication, then cut again.")
|
|
claims = _claims(repo, remote)
|
|
attempt = next_attempt(version, claims)
|
|
tag = attempt_ref(version, attempt)
|
|
# Built before the claim: a body GitHub refuses would otherwise burn the attempt.
|
|
body = draft_body(version=version, attempt_ref=tag, notes=_changelog(
|
|
repo, repository, commit=commit, tag=tag, version=version, published=published,
|
|
no_changelog=no_changelog,
|
|
))
|
|
if len(body) > GITHUB_BODY_LIMIT:
|
|
raise ReleaseRefused(
|
|
f"the {tag} draft body is {len(body)} characters; GitHub accepts at most "
|
|
f"{GITHUB_BODY_LIMIT}. Nothing was claimed.\n"
|
|
"Write the changelog by hand, in this order:\n"
|
|
f" 1. python scripts/release.py changelog --commit {commit} --bump {bump} "
|
|
f"--remote {remote} > changelog.md\n"
|
|
" Do this before step 2: once the retry claims this attempt, changelog numbers the next one.\n"
|
|
" 2. Re-run the same release command with --no-changelog.\n"
|
|
" 3. Edit changelog.md down in your editor and paste it into the draft release body.")
|
|
claim_epoch = _next_claim_epoch(repo, claims)
|
|
claim = json.dumps({
|
|
"schema": 1,
|
|
"version": version,
|
|
"attempt": attempt,
|
|
"commit": commit,
|
|
"autopublish": autopublish,
|
|
"skipBundles": skip_bundles,
|
|
"skipTests": skip_tests,
|
|
"claimEpoch": claim_epoch,
|
|
}, sort_keys=True, separators=(",", ":"))
|
|
subprocess.check_output(
|
|
# -f replaces a local-only tag of this name (not a claim, so never on
|
|
# the remote); the push below stays non-forcing.
|
|
["git", "tag", "-f", "-a", tag, commit, "-m", claim], cwd=repo,
|
|
text=True, encoding="utf-8",
|
|
env={**os.environ, "GIT_COMMITTER_DATE": f"@{claim_epoch} +0000"},
|
|
)
|
|
try:
|
|
_git(repo, "push", remote, f"refs/tags/{tag}")
|
|
except subprocess.CalledProcessError as error:
|
|
raise _claim_collision(repo, remote, tag, error) from error
|
|
ref = f"refs/tags/{tag}"
|
|
remote_ref = dict(line.split()[::-1] for line in _git(
|
|
repo, "ls-remote", remote, ref, f"{ref}^{{}}",
|
|
).splitlines())
|
|
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)
|
|
try:
|
|
# The body carries a warning at both ends, so it is handed over as a file.
|
|
file = tempfile.NamedTemporaryFile(
|
|
"w", suffix=".md", delete=False, encoding="utf-8", newline="\n")
|
|
try:
|
|
file.write(body)
|
|
finally:
|
|
file.close()
|
|
try:
|
|
# gh prints the new release's page. A draft is not reachable
|
|
# through releases/tag/<tag> (GitHub serves it at an untagged-*
|
|
# URL), so this printed page is the only link to it.
|
|
url = (execute([
|
|
"gh", "release", "create", tag, "--repo", repository,
|
|
"--verify-tag", "--draft", "--notes-file", file.name,
|
|
"--title", f"Hermes Agent v{version}",
|
|
]) or "").strip()
|
|
finally:
|
|
os.unlink(file.name)
|
|
execute([
|
|
"gh", "workflow", "run", WORKFLOW, "--ref", tag, "--repo", repository,
|
|
"--raw-field", f"tag={tag}",
|
|
])
|
|
run_url = _await_run(execute, repository, tag, wait=run_wait, sleep=sleep)
|
|
except Exception as exc:
|
|
raise ReleaseRefused(f"release {tag} never started: {exc}") from exc
|
|
return {"version": version, "tag": tag, "commit": commit, "url": url, "repository": repository,
|
|
"final_url": f"https://github.com/{repository}/releases/tag/v{version}",
|
|
"run_url": run_url, "autopublish": autopublish,
|
|
"skip_bundles": skip_bundles, "skip_tests": skip_tests}
|
|
|
|
|
|
RUN_POLL_SECONDS = 2
|
|
RUN_WAIT_SECONDS = 30
|
|
|
|
|
|
def _await_run(execute, repository: str, tag: str, *, wait: float, sleep) -> str:
|
|
"""The dispatched run's URL, polling up to ``wait`` seconds. Empty if it never lists."""
|
|
polls = int(wait // RUN_POLL_SECONDS)
|
|
for poll in range(polls + 1):
|
|
run_url = _dispatched_run(execute([
|
|
"gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
|
|
"--branch", tag, "--json", "databaseId,url,headBranch,status",
|
|
]), tag)
|
|
if run_url or poll == polls:
|
|
return run_url
|
|
sleep(RUN_POLL_SECONDS)
|
|
return ""
|
|
|
|
|
|
def _dispatched_run(raw: str, tag: str) -> str:
|
|
"""The run URL for the claim ref. Empty when the list has no such run yet."""
|
|
try:
|
|
rows = json.loads(raw or "[]")
|
|
except json.JSONDecodeError:
|
|
return ""
|
|
matched = [row for row in rows if isinstance(row, dict) and row.get("headBranch") == tag and row.get("url")]
|
|
if len(matched) != 1:
|
|
return ""
|
|
return str(matched[0]["url"])
|
|
|
|
|
|
def _latest_run(raw: str) -> str:
|
|
"""The URL of the newest listed run. Empty when the list names none."""
|
|
try:
|
|
rows = json.loads(raw or "[]")
|
|
except json.JSONDecodeError:
|
|
return ""
|
|
for row in rows:
|
|
if isinstance(row, dict) and row.get("url"):
|
|
return str(row["url"])
|
|
return ""
|
|
|
|
|
|
def _release_view(tag: str, repository: str, inspect) -> dict | None:
|
|
try:
|
|
return json.loads(inspect([
|
|
"gh", "release", "view", tag, "--repo", repository,
|
|
"--json", "tagName,isDraft,isPrerelease",
|
|
]))
|
|
except ReleaseRefused as exc:
|
|
if "not found" in str(exc).lower():
|
|
return None
|
|
raise
|
|
|
|
|
|
def _outstanding_ref(repo: Path, remote: str, version: str) -> str:
|
|
"""The attempt ref whose draft the publication pass must find."""
|
|
attempt = next_attempt(version, _claims(repo, remote)) - 1
|
|
if attempt < 1:
|
|
raise ReleaseRefused(f"stable {version} has no claimed attempt to publish")
|
|
return attempt_ref(version, attempt)
|
|
|
|
|
|
def _preflight_publish(version: str, repository: str, inspect, head_version,
|
|
repo: Path | None, remote: str | None) -> None:
|
|
requested = tuple(map(int, version.split(".")))
|
|
head = head_version()
|
|
if head and tuple(map(int, head.split("."))) >= requested:
|
|
raise ReleaseRefused(f"stable {version} is burned or superseded by {head}")
|
|
if repo is None or remote is None:
|
|
raise ValueError("preflight needs the release repository to find the attempt ref")
|
|
# The draft lives on the attempt ref, never on the final tag and never on
|
|
# the old v{version}-rc shape.
|
|
_refresh_claims(repo, remote)
|
|
attempt_ref = _outstanding_ref(repo, remote, version)
|
|
rows = [(tag, row) for tag in (f"v{version}", attempt_ref)
|
|
if (row := _release_view(tag, repository, inspect)) is not None]
|
|
if len(rows) != 1 or rows[0][0] != attempt_ref or rows[0][1].get("isDraft") is not True:
|
|
raise ReleaseRefused(f"stable {version} is burned or has no release draft")
|
|
|
|
|
|
def publish(version: str, *, repository: str, dispatch, inspect=None, head_version=None,
|
|
repo: Path | None = None, remote: str | None = None) -> dict:
|
|
"""Request ordered publication through the one production sequencer."""
|
|
tag = f"v{version}"
|
|
from hermes_cli.update_channel import STABLE_TAG_RE
|
|
|
|
if not STABLE_TAG_RE.fullmatch(tag):
|
|
raise ReleaseRefused(f"{version} is not a stable version")
|
|
if inspect is not None and head_version is not None:
|
|
_preflight_publish(version, repository, inspect, head_version, repo, remote)
|
|
dispatch([
|
|
"gh", "workflow", "run", "stable-release-publication.yml",
|
|
"--repo", repository, "--raw-field", f"version={version}",
|
|
])
|
|
return {"requested": tag, "version": version, "repository": repository}
|
|
|
|
|
|
def _in_progress_runs(inspect, repository: str, tag: str) -> list[dict]:
|
|
"""Release runs of ``tag`` that have not completed, as ``gh run list`` rows."""
|
|
rows = json.loads(inspect([
|
|
"gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
|
|
"--branch", tag, "--json", "databaseId,url,status", "--limit", "100",
|
|
]) or "[]")
|
|
return [row for row in rows if row["status"] != "completed"]
|
|
|
|
|
|
def _abandon_attempt(version: str, attempt: int, tag: str, *, repo: Path, remote: str,
|
|
repository: str, execute, inspect=None) -> dict:
|
|
cancelled: list[str] = []
|
|
if inspect is not None:
|
|
draft = _release_view(tag, repository, inspect)
|
|
if draft is not None and draft.get("isDraft") is not True:
|
|
raise ReleaseRefused(f"{tag} is published and cannot be abandoned")
|
|
for run in _in_progress_runs(inspect, repository, tag):
|
|
execute(["gh", "run", "cancel", str(run["databaseId"]), "--repo", repository, "--force"])
|
|
cancelled.append(run["url"])
|
|
if draft is not None:
|
|
execute(["gh", "release", "delete", tag, "--repo", repository, "--yes"])
|
|
marker = marker_ref(version, attempt)
|
|
message = json.dumps({"schema": 1, "version": version, "attempt": attempt, "attemptRef": tag},
|
|
sort_keys=True, separators=(",", ":"))
|
|
# -f replaces a local-only marker of this name; the push stays non-forcing.
|
|
_git(repo, "tag", "-f", "-a", marker, f"{tag}^{{commit}}", "-m", message)
|
|
try:
|
|
_git(repo, "push", remote, f"refs/tags/{marker}")
|
|
except subprocess.CalledProcessError as error:
|
|
raise _claim_collision(repo, remote, marker, error) from error
|
|
return {"version": version, "tag": tag, "marker": marker, "repository": repository,
|
|
"cancelled": cancelled}
|
|
|
|
|
|
def abandon(version: str, *, repo: Path, remote: str, repository: str, execute, inspect=None) -> list[dict]:
|
|
"""Clear every outstanding attempt of ``version``, oldest first. Attempt refs stay; markers are the record.
|
|
|
|
Each attempt's in-progress runs are cancelled and its draft deleted before
|
|
its marker exists: a live run can autopublish the draft, a cleared attempt
|
|
with a live draft could still be published by hand, and once the marker is
|
|
written there is no outstanding attempt left to retry the cleanup against.
|
|
This reads every outstanding attempt, not the one-attempt view, so it
|
|
clears all of them when a concurrent cut left more than one. An attempt
|
|
that fails stops the loop; the ones already cleared stay cleared and a
|
|
rerun picks up the rest.
|
|
"""
|
|
_refresh_claims(repo, remote)
|
|
matching = sorted(found for found in _outstanding_attempts(repo, remote) if found[0] == version)
|
|
if not matching:
|
|
raise ReleaseRefused(f"stable {version} has no outstanding attempt to abandon")
|
|
return [_abandon_attempt(version, attempt, tag, repo=repo, remote=remote,
|
|
repository=repository, execute=execute, inspect=inspect)
|
|
for _version, attempt, tag in matching]
|
|
|
|
|
|
def next_steps(result: dict, *, bold: bool = False) -> str:
|
|
"""Say what was attempted, where its CI runs, and the one command that publishes it.
|
|
|
|
``bold`` wraps the skipped-tests warning in ANSI bold; the caller decides
|
|
from the terminal, so piped output stays plain.
|
|
"""
|
|
version, tag = result["version"], result["tag"]
|
|
actions_url = f"https://github.com/{result['repository']}/actions/workflows/{WORKFLOW}"
|
|
lines = [
|
|
f"Attempting release {tag} for v{version}.",
|
|
"Release CI: " + (result.get("run_url")
|
|
or f"the run is not listed yet. Find {tag} at {actions_url}"),
|
|
]
|
|
if result["skip_bundles"]:
|
|
lines.append("Bundles are skipped. Only the tag, the GitHub release and the Docker image "
|
|
"ship; the desktop, Termux and Store channels stay on the previous release.")
|
|
if result["skip_tests"]:
|
|
warning = "Tests are skipped, since you passed --skip-tests"
|
|
lines.append(f"\033[1m{warning}\033[0m" if bold else warning)
|
|
lines.append(f"Draft release: {result['url']}")
|
|
if result["autopublish"]:
|
|
lines.append("Edit the notes in the draft release. Autopublish is on: "
|
|
f"when Release CI is green, v{version} publishes itself.")
|
|
else:
|
|
lines.append("Edit the notes in the draft release. "
|
|
f"When Release CI is green, to publish v{version}, run:")
|
|
lines.append(f" python scripts/release.py publish --version {version} --remote origin")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def cmd_release(args) -> None:
|
|
"""The ``release`` subcommand: claim, draft, dispatch."""
|
|
from scripts import release as release_script
|
|
|
|
repo = release_script.REPO_ROOT
|
|
remote = release_script.resolve_push_remote(args.remote)
|
|
repository = release_script.remote_github_repo(remote)
|
|
if not repository:
|
|
raise SystemExit(f"release: remote {remote!r} does not point at a GitHub repository")
|
|
commit = _git(repo, "rev-parse", "--verify", f"{args.commit}^{{commit}}")
|
|
|
|
def execute(command: list[str]) -> None:
|
|
completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False)
|
|
if completed.returncode != 0:
|
|
raise RuntimeError(completed.stderr.strip() or "release command failed")
|
|
return completed.stdout
|
|
|
|
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, no_changelog=args.no_changelog,
|
|
skip_bundles=args.skip_bundles, skip_tests=args.skip_tests,
|
|
published=published_stable_identity(repository), run_wait=RUN_WAIT_SECONDS,
|
|
)
|
|
print(next_steps(result, bold=sys.stdout.isatty() and "NO_COLOR" not in os.environ))
|
|
|
|
|
|
def cmd_changelog(args) -> None:
|
|
"""The ``changelog`` subcommand: print the notes the next draft would carry, claiming nothing."""
|
|
from scripts.releases.versioning import published_stable_identity
|
|
|
|
repo, remote, repository = _command_repository(args)
|
|
commit = _git(repo, "rev-parse", "--verify", f"{args.commit}^{{commit}}")
|
|
published = published_stable_identity(repository)
|
|
version = derive_next_version(published=published[0], bump=args.bump)
|
|
tag = attempt_ref(version, next_attempt(version, _claims(repo, remote)))
|
|
print(_changelog(repo, repository, commit=commit, tag=tag, version=version,
|
|
published=published, no_changelog=False))
|
|
|
|
|
|
def _command_repository(args) -> tuple[Path, str, str]:
|
|
from scripts import release as release_script
|
|
|
|
repo = release_script.REPO_ROOT
|
|
remote = release_script.resolve_push_remote(args.remote)
|
|
repository = release_script.remote_github_repo(remote)
|
|
if not repository:
|
|
raise SystemExit(f"release: remote {remote!r} does not point at a GitHub repository")
|
|
return repo, remote, repository
|
|
|
|
|
|
def _execute(repo: Path, command: list[str]) -> None:
|
|
completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False)
|
|
if completed.returncode != 0:
|
|
raise ReleaseRefused(completed.stderr.strip() or "release command failed")
|
|
|
|
|
|
def _inspect(repo: Path, command: list[str]) -> str:
|
|
completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False)
|
|
if completed.returncode != 0:
|
|
raise ReleaseRefused(completed.stderr.strip() or "release inspection failed")
|
|
return completed.stdout
|
|
|
|
|
|
def publish_steps(result: dict) -> str:
|
|
"""Say that publication was requested, and where to watch it."""
|
|
version = result["version"]
|
|
lines = [
|
|
f"Requested publication of v{version}.",
|
|
f"Workflow: {result['run_url']}" if result.get("run_url") else "Workflow: the run is not listed yet. Open the Actions tab.",
|
|
"Wait for that workflow to finish. It publishes the draft and moves the stable channel.",
|
|
f"The release page is https://github.com/{result['repository']}/releases/tag/v{version}.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def abandon_steps(results: list[dict]) -> str:
|
|
"""Say which attempts are cleared and which attempt the next cut takes."""
|
|
version = results[0]["version"]
|
|
lines = []
|
|
for result in results:
|
|
lines.append(f"Cleared {result['tag']}. The marker {result['marker']} records it.")
|
|
lines.extend(f"Cancelled {url}" for url in result.get("cancelled", []))
|
|
_version, last = parse_attempt_ref(results[-1]["tag"])
|
|
lines.append(f"v{version} is not spent. The next cut is rc.{last + 1}-v{version}.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def cmd_publish(args) -> None:
|
|
repo, remote, repository = _command_repository(args)
|
|
from scripts.releases.versioning import published_stable_version
|
|
result = publish(args.version, repository=repository,
|
|
dispatch=lambda command: _execute(repo, command),
|
|
inspect=lambda command: _inspect(repo, command),
|
|
head_version=lambda: published_stable_version(repository),
|
|
repo=repo, remote=remote)
|
|
listed = _inspect(repo, [
|
|
"gh", "run", "list", "--repo", repository, "--workflow", "stable-release-publication.yml",
|
|
"--json", "databaseId,url,headBranch,status", "--limit", "1",
|
|
])
|
|
print(publish_steps({**result, "run_url": _latest_run(listed)}))
|
|
|
|
|
|
def cmd_abandon(args) -> None:
|
|
repo, remote, repository = _command_repository(args)
|
|
result = abandon(args.version, repo=repo, remote=remote, repository=repository,
|
|
execute=lambda command: _execute(repo, command),
|
|
inspect=lambda command: _inspect(repo, command))
|
|
print(abandon_steps(result))
|