Files
hermes-agent/scripts/releases/entrypoint.py
ethernet dc11e3b3bc feat(release): add --skip-bundles and --skip-tests to stable releases
`release.py release` gains two flags. They can be used together.

--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.

--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.

The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.

The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.

A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:

- The next version was derived from it, so the next cut would reuse the
  version. It now takes the newer of the R2 head and the newest
  published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
  tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
  their publication pass, so a bundle-less release would re-advance
  every 15 minutes. The head is now the newer of the R2 head and the
  published release whose final tag binds the Docker stable alias
  digest.

`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.

Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:

- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]

Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
2026-09-24 13:31:33 -04:00

512 lines
23 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) -> list[str]:
"""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:
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,
)
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)
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), 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)
if ancestor.returncode != 0:
raise ReleaseRefused(
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/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)
# 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).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)) -> 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.
"""
_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.")
attempt = next_attempt(version, _claims(repo))
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. Re-run with --no-changelog.")
claim_epoch = _next_claim_epoch(repo)
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(
["git", "tag", "-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}",
])
found = execute([
"gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
"--branch", tag, "--json", "databaseId,url,headBranch,status",
])
except Exception as exc:
raise ReleaseRefused(f"release {tag} never started: {exc}") from exc
run_url = _dispatched_run(found, tag)
return {"version": version, "tag": tag, "commit": commit, "url": url,
"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}
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, version: str) -> str:
"""The attempt ref whose draft the publication pass must find."""
attempt = next_attempt(version, _claims(repo)) - 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, 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 abandon(version: str, *, repo: Path, remote: str, repository: str, delete, inspect=None) -> dict:
"""Clear the outstanding attempt of ``version``. The attempt ref stays; the marker is the record.
The draft goes first: a cleared attempt with a live draft could still be
published by hand, while a draftless outstanding attempt is just abandoned
again. This reads every outstanding attempt, not the one-attempt view, so
it still clears one when a concurrent cut left two.
"""
_refresh_claims(repo, remote)
matching = [found for found in _outstanding_attempts(repo, remote) if found[0] == version]
if len(matching) != 1:
raise ReleaseRefused(f"stable {version} has no outstanding attempt to abandon")
_version, attempt, tag = matching[0]
if inspect is not None:
draft = _release_view(tag, repository, inspect)
if draft is not None:
if draft.get("isDraft") is not True:
raise ReleaseRefused(f"{tag} is published and cannot be abandoned")
delete(["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=(",", ":"))
_git(repo, "tag", "-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}
def next_steps(result: dict) -> str:
"""Say what started, what the operator waits for, and the next action."""
version = result["version"]
lines = [
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.",
f"Draft release: {result['url']}",
"The draft exists now. Edit its notes while the workflow runs; the edits carry through to publication.",
"Wait for that workflow to finish. It builds and tests this commit.",
]
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"]:
lines.append("Tests are skipped. No CI, E2E, native smoke or upgrade acceptance job runs. "
"The build is published untested.")
if result["autopublish"]:
lines.append("Autopublish is on. A green workflow publishes the release. You do not run publish.")
else:
lines.append("Autopublish is off. The release stays a draft after the workflow is green.")
lines.append("When it is green, publish the release to push this build live:")
lines.append(f" python scripts/release.py publish --version {version} --remote origin")
lines.append(f"After publication the release is at {result['final_url']}.")
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")
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),
)
print(next_steps(result))
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")
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")
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(result: dict) -> str:
"""Say which attempt is cleared and which attempt the next cut takes."""
version, tag = result["version"], result["tag"]
_version, attempt = parse_attempt_ref(tag)
return "\n".join([
f"Cleared {tag}. The marker {result['marker']} records it.",
f"v{version} is not spent. The next cut is rc.{attempt + 1}-v{version}.",
])
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,
delete=lambda command: _execute(repo, command),
inspect=lambda command: _inspect(repo, command))
print(abandon_steps(result))