Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 59 additions & 23 deletions tools/create_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import re
import subprocess # nosec
import traceback
from dataclasses import dataclass

import create_tarballs
Expand Down Expand Up @@ -123,29 +124,58 @@ def __init__(self, config: Config, git_prov: git.Git, github_prov: github.GitHub
self.config = config
self.git = git_prov
self.github = github_prov
self.version = ""

def require(self, condition: bool, message: str | None = None) -> None:
if not condition:
raise stage.InvalidState(message or "Requirement not met")

def assign_to_user(
self,
s: stage.Stage,
s: stage.Stage | None,
version: str,
task: str | None = None,
action: str = "",
instruction: str | None = None,
) -> stage.UserAbort:
"""Assign the issue to the acting user for them to take some action."""
self.github.issue_unassign(self.config.issue, ["toktok-releaser"])
self.github.issue_assign(self.config.issue, [self.github.actor()])
if self.config.issue:
self.github.issue_unassign(self.config.issue, ["toktok-releaser"])
self.github.issue_assign(self.config.issue, [self.github.actor()])
self.update_dashboard(version, current_task=task, instruction=instruction)
s.ok(f"Assigned to {self.github.actor()}")
if s:
s.ok(f"Assigned to {self.github.actor()}")
return stage.UserAbort(f"Returning to the user to {action}")

def report_failure(self, version: str, exception: Exception) -> None:
"""Report a failure to the release tracking issue."""
if not self.config.issue:
return

instruction = f"❌ **Failure:** {exception}"
self.assign_to_user(
None,
version,
action="fix the failure",
instruction=instruction,
)

def run(self) -> None:
"""Run the release process."""
try:
self.run_stages()
except stage.UserAbort as e:
print(e.message)
except Exception as e:
traceback.print_exc()
self.report_failure(self.version, e)
raise e

def compute_done_milestones(self, version: str) -> set[str]:
"""Heuristics to determine which milestones are completed."""
done = set()
done: set[str] = set()
if not version:
return done

# 1. Preparation
if self.github.find_pr_for_branch(
Expand Down Expand Up @@ -200,15 +230,20 @@ def render_progress_list(
]

lines = []
instruction_rendered = False
for name, desc in milestones:
status = "[x]" if name in done else "[ ]"
if current_task == name:
lines.append(f"- {status} **Current Step: {desc}**")
if instruction:
lines.append(f" > ℹ️ **Action Required:** {instruction}")
instruction_rendered = True
else:
lines.append(f"- {status} {desc}")

if instruction and not instruction_rendered:
lines.append(f"\nℹ️ **Action Required:** {instruction}")

return "\n".join(lines)

def update_dashboard(
Expand Down Expand Up @@ -272,6 +307,7 @@ def stage_version(self) -> str:
if self.config.version == "latest":
version = self.github.latest_release()
s.ok(f"Using latest release {version}")
self.version = version
return version

self.require(
Expand All @@ -280,6 +316,7 @@ def stage_version(self) -> str:
f"(expected: {git.VERSION_REGEX.pattern})",
)
s.ok(f"Accepting override version {self.config.version}")
self.version = self.config.version
return self.config.version
version = self.github.next_milestone().title
if not self.config.production:
Expand All @@ -288,6 +325,7 @@ def stage_version(self) -> str:
version = f"{version}-rc.{rc + 1}"
self.require(re.match(git.VERSION_REGEX, version) is not None)
s.ok(version)
self.version = version
return version

def stage_rename_issue(self, version: str) -> None:
Expand Down Expand Up @@ -931,13 +969,15 @@ def stage_close_issue(self) -> None:
self.github.close_issue(self.config.issue)
s.ok(f"Issue {self.config.issue} closed")

def run_stages(self) -> None:
self.require(self.git.current_branch() == self.config.branch)
self.require(self.git.is_clean())
def run_stages(self, version: str | None = None) -> None:
if version is None:
self.require(self.git.current_branch() == self.config.branch)
self.require(self.git.is_clean())

self.stage_init()

self.stage_init()
version = self.stage_version()

version = self.stage_version()
self.stage_rename_issue(version)
self.stage_assign_milestone(version)
self.stage_production_ready(version)
Expand Down Expand Up @@ -995,19 +1035,15 @@ def main(config: Config) -> None:
git_prov = git.DEFAULT_GIT
github_prov = github.DEFAULT_GITHUB

try:
# Stash any local changes for the user to later resume working on.
with git.Stash(prov=git_prov):
# We need to be on the main branch to create a release, but we
# want to return to the original branch afterwards.
with git.Checkout(config.branch, prov=git_prov):
# Undo any partial changes if the script is aborted.
with git.ResetOnExit(prov=git_prov):
releaser = Releaser(config, git_prov, github_prov)
releaser.run_stages()
except stage.UserAbort as e:
print(e.message)
return
# Stash any local changes for the user to later resume working on.
with git.Stash(prov=git_prov):
# We need to be on the main branch to create a release, but we
# want to return to the original branch afterwards.
with git.Checkout(config.branch, prov=git_prov):
# Undo any partial changes if the script is aborted.
with git.ResetOnExit(prov=git_prov):
releaser = Releaser(config, git_prov, github_prov)
releaser.run()


if __name__ == "__main__":
Expand Down
39 changes: 39 additions & 0 deletions tools/create_release_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,44 @@ def test_render_all_done(self) -> None:
self.assertIn("[x] Finalize release", rendered)


class TestReleaserLogic(unittest.TestCase):
def setUp(self) -> None:
self.config = Config(
branch="master",
main_branch="master",
dryrun=False,
force=True,
github_actions=True,
issue=1,
production=True,
rebase=True,
resume=False,
verify=False,
version="v1.0.0",
upstream="origin",
)
self.github = MagicMock()
self.git = MagicMock()
self.releaser = Releaser(self.config, self.git, self.github)

def test_report_failure(self) -> None:
self.github.actor.return_value = "human"
self.github.get_issue.return_value = MagicMock(
body="### Release progress\n[ ] ..."
)

self.releaser.report_failure("v1.0.0", Exception("Something went wrong"))

# Check that issue was reassigned
self.github.issue_unassign.assert_called_with(1, ["toktok-releaser"])
self.github.issue_assign.assert_called_with(1, ["human"])

# Check that dashboard was updated
self.github.change_issue.assert_called()
args, kwargs = self.github.change_issue.call_args
self.assertEqual(args[0], 1)
self.assertIn("❌ **Failure:** Something went wrong", args[1]["body"])


if __name__ == "__main__":
unittest.main()
29 changes: 28 additions & 1 deletion tools/release_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ def __init__(self) -> None:
self._log: list[str] = ["initial commit"]
self._up_to_date = False
self._rebase_success = True
self._is_clean = True

def current_branch(self) -> str:
return self._current_branch
Expand All @@ -298,7 +299,7 @@ def log(self, branch: str, count: int = 100) -> list[str]:
return self._log

def is_clean(self) -> bool:
return True
return self._is_clean

def branch_sha(self, branch: str) -> str:
return "sha123"
Expand Down Expand Up @@ -741,6 +742,32 @@ def test_release_ci_failure(self) -> None:

self.assertIn("checks failed", str(cm.exception))

def test_run_reports_failure(self) -> None:
config = self.make_config()
gh = FakeGitHub()
gh.add_issue(
1,
"Release tracking issue: v1.0.0",
"### Release progress\n[ ] ...\nProduction release",
)
gh.add_milestone(1, "v1.0.0")

gt = FakeGit()
# Mock git.is_clean to fail to trigger an exception in run()
gt._is_clean = False

releaser = Releaser(config, gt, gh)

with self.release_mocks(gh, gt):
with self.assertRaises(Exception):
releaser.run()

# Verify reassignment to human
self.assertEqual(gh._issues[1]["assignees"], [{"login": "human"}])

# Verify failure message in dashboard
self.assertIn("❌ **Failure:** Requirement not met", gh._issues[1]["body"])

def test_release_ci_timeout(self) -> None:
config = self.make_config()
gh = FakeGitHub()
Expand Down
Loading