Skip to content

Commit 94b99d8

Browse files
committed
feat: Report release script failures to the tracking issue dashboard.
When `create_release.py` fails, it now catches the exception, updates the "Release progress" section of the tracking issue with a failure message, and reassigns the issue to the human actor.
1 parent 24fe8cf commit 94b99d8

3 files changed

Lines changed: 122 additions & 23 deletions

File tree

tools/create_release.py

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import re
77
import subprocess # nosec
8+
import traceback
89
from dataclasses import dataclass
910

1011
import create_tarballs
@@ -123,29 +124,58 @@ def __init__(self, config: Config, git_prov: git.Git, github_prov: github.GitHub
123124
self.config = config
124125
self.git = git_prov
125126
self.github = github_prov
127+
self.version = ""
126128

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

131133
def assign_to_user(
132134
self,
133-
s: stage.Stage,
135+
s: stage.Stage | None,
134136
version: str,
135137
task: str | None = None,
136138
action: str = "",
137139
instruction: str | None = None,
138140
) -> stage.UserAbort:
139141
"""Assign the issue to the acting user for them to take some action."""
140-
self.github.issue_unassign(self.config.issue, ["toktok-releaser"])
141-
self.github.issue_assign(self.config.issue, [self.github.actor()])
142+
if self.config.issue:
143+
self.github.issue_unassign(self.config.issue, ["toktok-releaser"])
144+
self.github.issue_assign(self.config.issue, [self.github.actor()])
142145
self.update_dashboard(version, current_task=task, instruction=instruction)
143-
s.ok(f"Assigned to {self.github.actor()}")
146+
if s:
147+
s.ok(f"Assigned to {self.github.actor()}")
144148
return stage.UserAbort(f"Returning to the user to {action}")
145149

150+
def report_failure(self, version: str, exception: Exception) -> None:
151+
"""Report a failure to the release tracking issue."""
152+
if not self.config.issue:
153+
return
154+
155+
instruction = f"❌ **Failure:** {exception}"
156+
self.assign_to_user(
157+
None,
158+
version,
159+
action="fix the failure",
160+
instruction=instruction,
161+
)
162+
163+
def run(self) -> None:
164+
"""Run the release process."""
165+
try:
166+
self.run_stages()
167+
except stage.UserAbort as e:
168+
print(e.message)
169+
except Exception as e:
170+
traceback.print_exc()
171+
self.report_failure(self.version, e)
172+
raise e
173+
146174
def compute_done_milestones(self, version: str) -> set[str]:
147175
"""Heuristics to determine which milestones are completed."""
148-
done = set()
176+
done: set[str] = set()
177+
if not version:
178+
return done
149179

150180
# 1. Preparation
151181
if self.github.find_pr_for_branch(
@@ -200,15 +230,20 @@ def render_progress_list(
200230
]
201231

202232
lines = []
233+
instruction_rendered = False
203234
for name, desc in milestones:
204235
status = "[x]" if name in done else "[ ]"
205236
if current_task == name:
206237
lines.append(f"- {status} **Current Step: {desc}**")
207238
if instruction:
208239
lines.append(f" > ℹ️ **Action Required:** {instruction}")
240+
instruction_rendered = True
209241
else:
210242
lines.append(f"- {status} {desc}")
211243

244+
if instruction and not instruction_rendered:
245+
lines.append(f"\nℹ️ **Action Required:** {instruction}")
246+
212247
return "\n".join(lines)
213248

214249
def update_dashboard(
@@ -272,6 +307,7 @@ def stage_version(self) -> str:
272307
if self.config.version == "latest":
273308
version = self.github.latest_release()
274309
s.ok(f"Using latest release {version}")
310+
self.version = version
275311
return version
276312

277313
self.require(
@@ -280,6 +316,7 @@ def stage_version(self) -> str:
280316
f"(expected: {git.VERSION_REGEX.pattern})",
281317
)
282318
s.ok(f"Accepting override version {self.config.version}")
319+
self.version = self.config.version
283320
return self.config.version
284321
version = self.github.next_milestone().title
285322
if not self.config.production:
@@ -288,6 +325,7 @@ def stage_version(self) -> str:
288325
version = f"{version}-rc.{rc + 1}"
289326
self.require(re.match(git.VERSION_REGEX, version) is not None)
290327
s.ok(version)
328+
self.version = version
291329
return version
292330

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

934-
def run_stages(self) -> None:
935-
self.require(self.git.current_branch() == self.config.branch)
936-
self.require(self.git.is_clean())
972+
def run_stages(self, version: str | None = None) -> None:
973+
if version is None:
974+
self.require(self.git.current_branch() == self.config.branch)
975+
self.require(self.git.is_clean())
976+
977+
self.stage_init()
937978

938-
self.stage_init()
979+
version = self.stage_version()
939980

940-
version = self.stage_version()
941981
self.stage_rename_issue(version)
942982
self.stage_assign_milestone(version)
943983
self.stage_production_ready(version)
@@ -995,19 +1035,15 @@ def main(config: Config) -> None:
9951035
git_prov = git.DEFAULT_GIT
9961036
github_prov = github.DEFAULT_GITHUB
9971037

998-
try:
999-
# Stash any local changes for the user to later resume working on.
1000-
with git.Stash(prov=git_prov):
1001-
# We need to be on the main branch to create a release, but we
1002-
# want to return to the original branch afterwards.
1003-
with git.Checkout(config.branch, prov=git_prov):
1004-
# Undo any partial changes if the script is aborted.
1005-
with git.ResetOnExit(prov=git_prov):
1006-
releaser = Releaser(config, git_prov, github_prov)
1007-
releaser.run_stages()
1008-
except stage.UserAbort as e:
1009-
print(e.message)
1010-
return
1038+
# Stash any local changes for the user to later resume working on.
1039+
with git.Stash(prov=git_prov):
1040+
# We need to be on the main branch to create a release, but we
1041+
# want to return to the original branch afterwards.
1042+
with git.Checkout(config.branch, prov=git_prov):
1043+
# Undo any partial changes if the script is aborted.
1044+
with git.ResetOnExit(prov=git_prov):
1045+
releaser = Releaser(config, git_prov, github_prov)
1046+
releaser.run()
10111047

10121048

10131049
if __name__ == "__main__":

tools/create_release_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,42 @@ def test_render_all_done(self) -> None:
5050
self.assertIn("[x] Finalize release", rendered)
5151

5252

53+
class TestReleaserLogic(unittest.TestCase):
54+
def setUp(self) -> None:
55+
self.config = Config(
56+
branch="master",
57+
main_branch="master",
58+
dryrun=False,
59+
force=True,
60+
github_actions=True,
61+
issue=1,
62+
production=True,
63+
rebase=True,
64+
resume=False,
65+
verify=False,
66+
version="v1.0.0",
67+
upstream="origin",
68+
)
69+
self.github = MagicMock()
70+
self.git = MagicMock()
71+
self.releaser = Releaser(self.config, self.git, self.github)
72+
73+
def test_report_failure(self) -> None:
74+
self.github.actor.return_value = "human"
75+
self.github.get_issue.return_value = MagicMock(body="### Release progress\n[ ] ...")
76+
77+
self.releaser.report_failure("v1.0.0", Exception("Something went wrong"))
78+
79+
# Check that issue was reassigned
80+
self.github.issue_unassign.assert_called_with(1, ["toktok-releaser"])
81+
self.github.issue_assign.assert_called_with(1, ["human"])
82+
83+
# Check that dashboard was updated
84+
self.github.change_issue.assert_called()
85+
args, kwargs = self.github.change_issue.call_args
86+
self.assertEqual(args[0], 1)
87+
self.assertIn("❌ **Failure:** Something went wrong", args[1]["body"])
88+
89+
5390
if __name__ == "__main__":
5491
unittest.main()

tools/release_e2e_test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,32 @@ def test_release_ci_failure(self) -> None:
741741

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

744+
def test_run_reports_failure(self) -> None:
745+
config = self.make_config()
746+
gh = FakeGitHub()
747+
gh.add_issue(
748+
1,
749+
"Release tracking issue: v1.0.0",
750+
"### Release progress\n[ ] ...\nProduction release",
751+
)
752+
gh.add_milestone(1, "v1.0.0")
753+
754+
gt = FakeGit()
755+
# Mock git.is_clean to fail to trigger an exception in run()
756+
gt.is_clean = MagicMock(return_value=False)
757+
758+
releaser = Releaser(config, gt, gh)
759+
760+
with self.release_mocks(gh, gt):
761+
with self.assertRaises(Exception):
762+
releaser.run()
763+
764+
# Verify reassignment to human
765+
self.assertEqual(gh._issues[1]["assignees"], [{"login": "human"}])
766+
767+
# Verify failure message in dashboard
768+
self.assertIn("❌ **Failure:** Requirement not met", gh._issues[1]["body"])
769+
744770
def test_release_ci_timeout(self) -> None:
745771
config = self.make_config()
746772
gh = FakeGitHub()

0 commit comments

Comments
 (0)