fix: release cut output waits for its CI run and leads with the next action

The cut printed before GitHub listed the dispatched run, so it almost always
said "the run is not listed yet". It now polls up to 30s for the run, and when
none lists it links the stable-release workflow page. The copy is shorter:
Release CI, the skipped-tests warning (bold on a tty), the draft link, and the
publish command on its own line.
This commit is contained in:
ethernet
2026-09-29 15:12:04 -04:00
parent dccb84b924
commit e336fbcf79
2 changed files with 92 additions and 36 deletions

View File

@@ -180,12 +180,15 @@ def _changelog(repo: Path, repository: str, *, commit: str, tag: str, version: s
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:
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)
@@ -267,19 +270,33 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
"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",
])
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
run_url = _dispatched_run(found, tag)
return {"version": version, "tag": tag, "commit": commit, "url": url,
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:
@@ -389,29 +406,33 @@ def abandon(version: str, *, repo: Path, remote: str, repository: str, delete, i
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"]
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"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.",
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"]:
lines.append("Tests are skipped. No CI, E2E, native smoke or upgrade acceptance job runs. "
"The build is published untested.")
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("Autopublish is on. A green workflow publishes the release. You do not run publish.")
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("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("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")
lines.append(f"After publication the release is at {result['final_url']}.")
return "\n".join(lines)
@@ -437,9 +458,9 @@ def cmd_release(args) -> None:
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),
published=published_stable_identity(repository), run_wait=RUN_WAIT_SECONDS,
)
print(next_steps(result))
print(next_steps(result, bold=sys.stdout.isatty() and "NO_COLOR" not in os.environ))
def _command_repository(args) -> tuple[Path, str, str]:

View File

@@ -133,28 +133,63 @@ def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source)
]
def test_release_output_names_the_wait_and_the_publish_step():
def test_release_output_names_the_ci_run_and_the_publish_step():
from scripts.releases.entrypoint import next_steps
result = {"version": "0.21.5", "tag": "rc.1-v0.21.5", "autopublish": False,
"skip_bundles": False, "skip_tests": False,
"skip_bundles": False, "skip_tests": False, "repository": "example/hermes-agent",
"run_url": "https://github.com/example/hermes-agent/actions/runs/7",
"url": "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd",
"final_url": "https://github.com/example/hermes-agent/releases/tag/v0.21.5"}
"url": "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd"}
text = next_steps(result)
assert "Workflow: " + result["run_url"] in text
assert "The release workflow started on rc.1-v0.21.5." in text
# The draft is reachable before the workflow is green, not only after it.
assert text.index(result["url"]) < text.index("Wait for that workflow to finish.")
assert result["final_url"] in text
assert "python scripts/release.py publish --version 0.21.5 --remote origin" in text
assert "Attempting release rc.1-v0.21.5 for v0.21.5." in text
assert "Release CI: " + result["run_url"] in text
assert "Draft release: " + result["url"] in text
# The publish command sits on its own line so it can be copied whole.
assert "\n python scripts/release.py publish --version 0.21.5 --remote origin" in text
assert "skipped" not in text
automatic = next_steps({**result, "autopublish": True})
assert "Autopublish is on." in automatic
assert "Autopublish is on" in automatic
assert "publish --version" not in automatic
assert "skipped" not in text
skipped = next_steps({**result, "skip_bundles": True, "skip_tests": True})
assert "Bundles are skipped." in skipped and "Tests are skipped." in skipped
assert "Bundles are skipped." in skipped
assert "Tests are skipped, since you passed --skip-tests" in skipped
assert "\033[" not in skipped
assert "\033[1mTests are skipped" in next_steps({**result, "skip_tests": True}, bold=True)
def test_an_unlisted_run_points_at_the_workflow_page():
from scripts.releases.entrypoint import next_steps
text = next_steps({"version": "0.21.5", "tag": "rc.1-v0.21.5", "autopublish": False,
"skip_bundles": False, "skip_tests": False,
"repository": "example/hermes-agent", "run_url": "",
"url": "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd"})
assert "the run is not listed yet" in text
assert "https://github.com/example/hermes-agent/actions/workflows/stable-release.yml" in text
@pytest.mark.parametrize(("lists_on", "wait", "run_url", "naps"), [
(3, 30, "https://github.com/example/hermes-agent/actions/runs/7", 2),
(None, 4, "", 2),
])
def test_release_looks_for_the_dispatched_run_only_within_its_wait(source, lists_on, wait, run_url, naps):
listed = json.dumps([{"databaseId": 7, "url": "https://github.com/example/hermes-agent/actions/runs/7",
"headBranch": "rc.1-v0.21.5", "status": "queued"}])
lists, slept = [], []
def execute(command):
if command[:3] == ["gh", "run", "list"]:
lists.append(command)
return listed if len(lists) == lists_on else "[]"
return ""
result = _release(source, git(source, "rev-parse", "HEAD"), execute=execute,
run_wait=wait, sleep=slept.append)
assert result["run_url"] == run_url
assert len(slept) == naps and len(lists) == naps + 1
def test_a_final_tag_for_the_next_version_refuses_the_cut(source):