lean4-htt/script/release/checklist.py

739 lines
26 KiB
Python

from argparse import ArgumentParser
from dataclasses import dataclass
from pathlib import Path
import repos
from github import Github, UnknownObjectException
from github.GitRef import GitRef
from rich import print
from rich.markup import escape as e
import util
from util import Checklist, CMakeVersion, ReleaseRepo, Version
@dataclass
class Config:
version: Version
interactive: bool
skip_weak_deps: bool
skip_mathlib_checks: bool
github: Github
class RepoChecker:
def __init__(self, config: Config, rrepo: ReleaseRepo) -> None:
self.config = config
self.cl = Checklist()
self.rrepo = rrepo
self.grepo = self.github.get_repo(self.rrepo.gh_full_name)
self.lrepo = self.rrepo.local
@property
def github(self) -> Github:
return self.config.github
@property
def version(self) -> Version:
return self.config.version
def prompt(self, message: str) -> bool:
if not self.config.interactive:
return False
return util.prompt(message) == "y"
def check_pr(self, base: str, head: str, title: str) -> bool:
pr = util.find_pr(self.grepo, head=head, base=base, title=title)
if not pr:
if not self.prompt("PR not found. Create?"):
self.cl.fatal("PR not found")
return False
if pr.state == "open":
self.cl.blocked(f"PR open: {util.fmt_pr(pr)}")
if pr.merged:
self.cl.success(f"PR merged: {util.fmt_pr(pr)}")
else:
self.cl.success(f"PR closed: {util.fmt_pr(pr)}")
return True
def create_pr(
self, base: str, head: str, title: str, nightly: ReleaseRepo | None = None
) -> None:
if not self.prompt(f"Push branch [b]{e(head)}[/b]?"):
self.cl.fatal("Branch not pushed")
self.lrepo.push(head, remote="nightly" if nightly else "origin")
# Mathlib bump PRs are opened from the nightly-testing repo, which
# pygithub doesn't support because both belong to the same organization:
# https://github.com/PyGithub/PyGithub/issues/2942
# So we just give the user a link instead.
if nightly:
url = util.create_pr_url(
base=self.rrepo,
base_branch=base,
head=nightly,
head_branch=head,
title=title,
)
self.cl.blocked(f"[u link={url}]Create PR manually[/]")
if not self.prompt(f"Create PR for branch [b]{e(head)}[/b]?"):
self.cl.fatal("PR not created")
pr = util.create_pr(self.grepo, head=head, base=base, title=title)
self.cl.blocked(f"PR created: {util.fmt_pr(pr)}")
class DownstreamChecker(RepoChecker):
def check_dependencies_completed(self) -> None:
deps = list(self.rrepo.strong_deps)
if not self.config.skip_weak_deps:
deps += self.rrepo.weak_deps
for dep in deps:
if not dep.completed:
self.cl.wait(
f"Awaiting completion of dependency [b]{e(dep.gh_full_name)}[/b]"
)
self.cl.ensure_success()
def check_toolchain(self) -> bool:
expected = util.get_toolchain_for(self.version)
actual = util.get_toolchain(self.grepo, self.grepo.default_branch)
if expected == actual:
self.cl.success(f"Toolchain is [b]{e(actual)}[/b]")
return True
else:
self.cl.fail(f"Toolchain is [b]{e(actual)}[/b]")
return False
def _bump_toolchain(self, path: Path) -> None:
util.set_toolchain(path, self.version.tag)
def _bump_toolchain_deps(self, path: Path) -> None:
if (path / "lakefile.toml").exists():
util.edit(
path / "lakefile.toml",
r'rev = "v4\.[0-9]+(\.[0-9]+)?(-rc[0-9]+)?"',
f'rev = "{self.version}"',
)
else:
util.edit(
path / "lakefile.lean",
r'git "v4\.[0-9]+(\.[0-9]+)?(-rc[0-9]+)?"',
f'git "{self.version}"',
)
util.run("lake", "update", cwd=path)
def _bump_toolchain_mathlib4(self) -> None:
pw = self.github.get_repo(repos.PROOFWIDGETS4.gh_full_name)
tag = util.get_proofwidgets_release_for(pw, self.version)
if not tag:
raise SystemExit(1)
# For both normal and rc1 PRs
util.edit(
self.lrepo.path / "lakefile.lean",
r'"proofwidgets" @ git ".*"',
f'"proofwidgets" @ git "{tag.name}"',
)
# For rc1 PRs
util.edit(
self.lrepo.path / "lakefile.lean",
r' @ git "nightly-testing"',
f' @ git "{self.version}"',
)
self._bump_toolchain_deps(self.lrepo.path)
def _bump_toolchain_repl(self) -> None:
self._bump_toolchain_deps(self.lrepo.path)
mathlib = self.lrepo.path / "test" / "Mathlib"
self._bump_toolchain(mathlib)
self._bump_toolchain_deps(mathlib)
if self.prompt("Run tests?"):
try:
util.run("./test.sh", cwd=self.lrepo.path)
print("#####################")
print("## Tests succeeded ##")
print("#####################")
except SystemExit as e:
print("###################")
print("## Tests failed! ##")
print("###################")
raise e
def _bump_toolchain_verso(self) -> None:
self._bump_toolchain_deps(self.lrepo.path)
util.run("./update-subverso.sh", cwd=self.lrepo.path)
def _bump_toolchain_reference_manual(self) -> None:
self._bump_toolchain_deps(self.lrepo.path)
if not self.prompt("Run release notes update script"):
self.cl.fatal("Release notes update script not run")
here = Path(__file__).parent
util.run(
"uv", "run", "release_notes.py", self.version.tag, self.lrepo.path, cwd=here
)
self.prompt("Check release notes before commit")
def _bump_toolchain_lean_fro_org(self) -> None:
self._bump_toolchain_deps(self.lrepo.path)
hero = self.lrepo.path / "examples" / "hero"
self._bump_toolchain(hero)
self._bump_toolchain_deps(hero)
util.run("scripts/update.sh", cwd=self.lrepo.path)
def _bump_toolchain_bibtex_query(self) -> None:
lub = self.github.get_repo(repos.LEAN4_UNICODE_BASIC.gh_full_name)
tag = util.get_lean_unicode_basic_release_for(lub, self.version)
rev = str(tag) if tag else "main"
util.edit(
self.lrepo.path / "lakefile.toml",
r'(name = "UnicodeBasic"[\s\S]*?rev =) ".+?"',
rf'\1 "{rev}"',
)
self._bump_toolchain_deps(self.lrepo.path)
def _bump_toolchain_in_worktree(self) -> None:
self._bump_toolchain(self.lrepo.path)
# Special cases
if self.rrepo.gh_full_name == repos.MATHLIB4.gh_full_name:
self._bump_toolchain_mathlib4()
elif self.rrepo.gh_full_name == repos.REPL.gh_full_name:
self._bump_toolchain_repl()
elif self.rrepo.gh_full_name == repos.VERSO.gh_full_name:
self._bump_toolchain_verso()
elif self.rrepo.gh_full_name == repos.REFERENCE_MANUAL.gh_full_name:
self._bump_toolchain_reference_manual()
elif self.rrepo.gh_full_name == repos.LEAN_FRO_ORG.gh_full_name:
self._bump_toolchain_lean_fro_org()
elif self.rrepo.gh_full_name == repos.BIBTEX_QUERY.gh_full_name:
self._bump_toolchain_bibtex_query()
elif self.rrepo.strong_deps:
self._bump_toolchain_deps(self.lrepo.path)
def _bump_toolchain_unicode_basic(self) -> None:
base = self.grepo.default_branch
head = f"update-toolchain-{self.version}"
title = f"chore: update toolchain {self.version}"
if self.check_pr(base=base, head=head, title=title):
return
workflow = self.grepo.get_workflow("update-toolchain.yml")
for run in workflow.get_runs(branch=base).get_page(0):
if not run.conclusion:
what = f"[b u link={run.html_url}]Bump workflow run {run.id}[/]"
self.cl.blocked(f"{what} is still running")
return
if not self.prompt("No PR or running bump workflow run found. Trigger?"):
self.cl.fatal("No PR or running bump workflow run found")
workflow.create_dispatch(ref=base)
self.cl.blocked("Triggered bump workflow run")
def check_bump_pr(self) -> None:
if self.rrepo.gh_full_name == repos.LEAN4_UNICODE_BASIC.gh_full_name:
self._bump_toolchain_unicode_basic()
return
# Normally:
# 1. Create bump branch from origin/main
# 2. Edit files 'n stuff and commit
# 3. Push branch to origin
# 4. Create PR to origin/main
# Nightly:
# 1. Create bump branch from origin/main
# 2. Edit files 'n stuff and commit
# 3. Push branch to nightly
# 4. Create PR to origin/main
# Normally, with RC1 and bump branch:
# 1. Switch to origin/bump/v*
# 2. Edit files 'n stuff and commit
# 3. Push branch to origin
# 4. Create PR to origin/main
# Nightly, with RC1 and bump branch:
# 1. Switch to nightly/bump/v*
# 2. Edit files 'n stuff and commit
# 3. Push branch to nightly
# 4. Create PR to origin/main
base = self.grepo.default_branch
head = f"bump-to-{self.version}"
nightly = None
use_bump_branch = self.rrepo.bump_branch and self.version.rc == 1
if use_bump_branch:
head = util.get_bump_branch(self.version)
nightly = self.rrepo.nightly
title = util.get_toolchain_bump_message(self.version)
if self.check_pr(base=base, head=head, title=title):
return
self.lrepo.prepare(nightly=nightly)
if use_bump_branch:
self.lrepo.switch(head, remote="nightly" if nightly else "origin")
else:
self.lrepo.create_branch(head)
self._bump_toolchain_in_worktree()
self.lrepo.commit(title)
self.create_pr(base=base, head=head, title=title, nightly=nightly)
def check_next_bump_branch(self) -> None:
if not self.rrepo.bump_branch:
return
grepo = self.grepo
if self.rrepo.nightly:
grepo = self.github.get_repo(self.rrepo.nightly.gh_full_name)
branch_name = util.get_bump_branch(self.version.next_minor)
what = f"Bump branch [b]{e(branch_name)}[/b]"
try:
grepo.get_branch(branch_name)
self.cl.success(f"{what} exists")
return
except UnknownObjectException:
pass
if not self.prompt(f"{what} not found. Create?"):
self.cl.fail(f"{what} not found")
return
lean4_nightly = self.github.get_repo(repos.LEAN4_NIGHTLY.gh_full_name)
latest_nightly_tag = util.get_latest_nightly_tag(lean4_nightly)
self.lrepo.prepare(nightly=self.rrepo.nightly)
self.lrepo.create_branch(branch_name)
util.set_toolchain(self.lrepo.path, latest_nightly_tag.name)
message = f"chore: bump toolchain to {latest_nightly_tag.name}"
self.lrepo.commit(message)
if not self.prompt(f"Push branch [b]{e(branch_name)}[/b]?"):
self.cl.fail(f"{what} not found")
return
remote = "nightly" if self.rrepo.nightly else "origin"
self.lrepo.push(branch_name, remote=remote)
self.cl.success(f"{what} created")
def _check_lean_release_tag(self) -> GitRef | None:
tag_name = self.version.tag
what = f"Toolchain tag [b]{tag_name}[/b]"
try:
tag = self.grepo.get_git_ref(f"tags/{tag_name}")
self.cl.success(f"{what} exists")
return tag
except UnknownObjectException:
pass
if not self.prompt(f"{what} not found. Create?"):
self.cl.fail(f"{what} not found")
return
self.lrepo.prepare()
bump_sha = util.find_merged_toolchain_bump_sha(self.lrepo, self.version)
self.lrepo.create_tag(tag_name, bump_sha)
if not self.prompt(f"Push tag [b]{tag_name}[/b]?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.push(tag_name, upstream=False)
tag = self.grepo.get_git_ref(f"tags/{tag_name}")
self.cl.success(f"{what} created")
return tag
def _check_proofwidgets_release_tag(self) -> GitRef | None:
what = f"Proofwidgets release with toolchain {self.version}"
tag = util.get_proofwidgets_release_for(self.grepo, self.version)
if tag:
self.cl.success(f"{what} found: [b]{e(tag.name)}[/b]")
return
tag_name = util.get_next_proofwidgets_release(self.grepo)
if not self.prompt(f"{what} not found. Create [b]{e(tag_name)}[/b]?"):
self.cl.fail(f"{what} not found")
return
self.lrepo.prepare()
bump_sha = util.find_merged_toolchain_bump_sha(self.lrepo, self.version)
self.lrepo.create_tag(tag_name, bump_sha)
if not self.prompt(f"Push tag [b]{tag_name}[/b]?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.push(tag_name, upstream=False)
tag = self.grepo.get_git_ref(f"tags/{tag_name}")
self.cl.success(f"{what} created")
return tag
def check_release_tag(self) -> GitRef | None:
match self.rrepo.release_tag:
case "lean":
return self._check_lean_release_tag()
case "proofwidgets":
return self._check_proofwidgets_release_tag()
case None:
pass
def check_stable_branch_points_to_release_tag(self, tag: GitRef) -> None:
if not self.rrepo.stable_branch:
return
if not self.version.is_stable:
return
what = "Stable branch"
branch = self.grepo.get_branch("stable")
if branch.commit.sha == tag.object.sha:
self.cl.success(f"{what} points to toolchain tag")
return
if not self.prompt(f"{what} does not point to toolchain tag. Update?"):
self.cl.fail(f"{what} does not point to toolchain tag")
return
self.lrepo.prepare()
self.lrepo.switch("stable")
self.lrepo.git("merge", "--ff-only", self.version.tag)
if not self.prompt("Push branch [b]stable[/b] to origin?"):
self.cl.fail(f"{what} does not point to toolchain tag")
return
self.lrepo.push("stable")
self.cl.success(f"{what} updated to point to toolchain tag")
def check_mathlib4_version_tags(self) -> None:
if self.rrepo.gh_full_name != repos.MATHLIB4.gh_full_name:
return
if self.config.skip_mathlib_checks:
return
# At this point, the PR has been merged
self.lrepo.prepare()
self.lrepo.switch(self.grepo.default_branch)
script = "scripts/verify_version_tags.py"
try:
self.lrepo.run("python", script, self.version.tag)
self.cl.success(f"Version tags verified by [b]{e(script)}[/b]")
except Exception:
self.cl.fatal(f"Version tag verification by [b]{e(script)}[/b] failed")
def check(self) -> None:
self.check_dependencies_completed()
toolchain = self.check_toolchain()
if not toolchain:
self.check_bump_pr()
self.cl.ensure_success()
self.check_next_bump_branch()
release_tag = self.check_release_tag()
if release_tag:
self.check_stable_branch_points_to_release_tag(release_tag)
if toolchain:
self.check_mathlib4_version_tags()
self.cl.ensure_success()
self.rrepo.completed = True
class LeanChecker(RepoChecker):
def __init__(self, config: Config) -> None:
super().__init__(config=config, rrepo=repos.LEAN4)
def _check_label_exists(
self, name: str, color: str, description: str | None = None
) -> None:
what = f"Label [b]{e(name)}[/b]"
try:
self.grepo.get_label(name)
self.cl.success(f"{what} exists")
return
except UnknownObjectException:
pass
if not self.prompt(f"{what} does not exist. Create?"):
self.cl.fail(f"{what} does not exist")
return
if description is None:
self.grepo.create_label(name=name, color=color)
else:
self.grepo.create_label(name=name, color=color, description=description)
self.cl.success(f"{what} created")
def check_backport_label_exists(self, version: Version) -> None:
self._check_label_exists(
name=util.get_backport_label(version),
color="1d76db",
)
def check_blocking_label_exists(self, version: Version) -> None:
self._check_label_exists(
name=util.get_blocking_label(version),
color="b60205",
description=f"Blocks the next {version.base} release candidate or release from being published.",
)
def check_release_branch_exists(self) -> None:
branch_name = util.get_releases_branch(self.version)
what = f"Release branch [b]{e(branch_name)}[/b]"
try:
self.grepo.get_branch(branch_name)
self.cl.success(f"{what} exists")
return
except UnknownObjectException:
pass
if not self.prompt(f"{what} does not exist. Create?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.prepare()
self.lrepo.create_branch(branch_name)
if not self.prompt(f"Push branch [b]{e(branch_name)}[/b]?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.push(branch_name)
self.grepo.get_branch(branch_name)
self.cl.success(f"{what} created")
def check_release_branch_cmake_version(self) -> None:
branch_name = util.get_releases_branch(self.version)
what = f"CMake version settings on [b]{e(branch_name)}[/b]"
target = CMakeVersion(self.version.stable, is_release=True)
cur = util.get_cmake_version(self.grepo, branch_name)
if cur == target:
self.cl.success(f"{what} are correct")
return
if not self.prompt(f"{what} are incorrect. Update?"):
self.cl.fail(f"{what} are incorrect")
return
self.lrepo.prepare()
self.lrepo.switch(branch_name)
util.set_cmake_version(self.lrepo, target)
self.lrepo.commit("chore: prepare release")
if not self.prompt(f"Push branch [b]{e(branch_name)}[/b]?"):
self.cl.fail(f"{what} are incorrect")
return
self.lrepo.push(branch_name)
self.cl.success(f"{what} updated")
def check_master_branch_cmake_version(self) -> None:
branch_name = self.grepo.default_branch
what = f"CMake version settings on [b]{e(branch_name)}[/b]"
target = CMakeVersion(self.version.next_minor, is_release=False)
cur = util.get_cmake_version(self.grepo, branch_name)
if cur == target:
self.cl.success(f"{what} are correct")
return
self.cl.fail(f"{what} are incorrect")
head = f"dev-cycle-{self.version.next_minor}"
title = f"chore: prepare development cycle for {self.version.next_minor}"
if self.check_pr(base=branch_name, head=head, title=title):
return
self.lrepo.prepare()
self.lrepo.create_branch(head, branch_name)
util.set_cmake_version(self.lrepo, target)
self.lrepo.commit(title)
self.create_pr(base=branch_name, head=head, title=title)
def _check_no_open_prs_labeled(self, label: str) -> None:
success = True
for issue in self.grepo.get_issues(state="open", labels=[label]):
kind = "PR" if issue.pull_request else "issue"
self.cl.fail(f"Found {kind} {util.fmt_pr(issue)} labeled [b]{e(label)}[/b]")
success = False
if success:
self.cl.success(f"Found no open PRs labeled [b]{e(label)}[/b]")
def check_no_open_prs_labeled_backport(self) -> None:
self._check_no_open_prs_labeled(util.get_backport_label(self.version))
def check_no_open_prs_labeled_blocking(self) -> None:
self._check_no_open_prs_labeled(util.get_blocking_label(self.version))
def check_no_open_backport_prs(self) -> None:
base = util.get_releases_branch(self.version)
success = True
for pr in self.grepo.get_pulls(state="open", base=base):
if "backport" in pr.title.lower():
self.cl.fail(f"Found backport PR #{pr.number} {util.fmt_pr(pr)}")
success = False
if success:
self.cl.success("Found no open backport PRs")
def check_release_tag(self) -> GitRef:
tag_name = self.version.tag
what = f"Tag [b]{tag_name}[/b]"
try:
ref = self.grepo.get_git_ref(f"tags/{tag_name}")
self.cl.success(f"{what} exists")
return ref
except UnknownObjectException:
pass
if not self.prompt(f"{what} does not exist. Create?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.prepare()
self.lrepo.create_tag(tag_name, util.get_releases_branch(self.version))
if not self.prompt(f"Push tag [b]{tag_name}[/b]?"):
self.cl.fatal(f"{what} does not exist")
self.lrepo.push(tag_name, upstream=False)
tag = self.grepo.get_git_ref(f"tags/{tag_name}")
self.cl.success(f"{what} created")
return tag
def check_release_ci(self, release_tag: GitRef) -> None:
tag_sha = release_tag.object.sha
runs = self.grepo.get_workflow_runs(event="push", head_sha=tag_sha).get_page(0)
if len(runs) == 0:
self.cl.fail("Release workflow run not found")
return
run = runs[0]
what = f"[b u link={run.html_url}]Release workflow run {run.id}[/]"
if not run.conclusion:
self.cl.blocked(f"{what} is still running")
if run.conclusion != "success":
self.cl.fatal(f"{what} failed")
self.cl.success(f"{what} finished")
def check_release_page(self) -> None:
url = f"https://github.com/leanprover/lean4/releases/tag/{self.version}"
what = f"[b u link={url}]Release page for {self.version.tag}[/]"
try:
release = self.grepo.get_release(self.version.tag)
except UnknownObjectException:
self.cl.blocked(f"{what} not found")
target = "This is"
if not self.version.is_stable:
target += f" release candidate {self.version.rc} for"
target += f" the {self.version.stable} release of Lean."
relnotes = f"https://lean-lang.org/doc/reference/latest/releases/{self.version.stable}/"
target += f" View the [release notes]({relnotes}) for more information."
incorrect = []
if release.name != str(self.version):
incorrect.append("name")
if release.body is None or release.body.strip() != target:
incorrect.append("message")
if not incorrect:
self.cl.success(f"{what} exists")
return
if not self.prompt(f"{what} has incorrect {'/'.join(incorrect)}. Update?"):
self.cl.fail(f"{what} has incorrect {'/'.join(incorrect)}")
return
release.update_release(
name=str(self.version),
message=target,
prerelease=not self.version.is_stable,
)
self.cl.success(f"{what} updated")
def check(self) -> None:
self.cl.section("Prepare release cycle")
self.check_backport_label_exists(self.version)
self.check_blocking_label_exists(self.version)
self.check_blocking_label_exists(self.version.next_minor)
self.check_release_branch_exists()
self.check_release_branch_cmake_version()
self.check_master_branch_cmake_version()
self.cl.section("Release")
self.check_no_open_prs_labeled_backport()
self.check_no_open_prs_labeled_blocking()
self.check_no_open_backport_prs()
release_tag = self.check_release_tag()
self.check_release_ci(release_tag)
self.check_release_page()
for drepo in repos.ALL:
self.cl.section(f"[u link={drepo.gh_url}]{e(drepo.gh_full_name)}[/u link]")
try:
DownstreamChecker(config=self.config, rrepo=drepo).check()
except SystemExit:
self.cl.failed = True
self.cl.ensure_success()
class Args:
version: Version
interactive: bool
skip_weak_deps: bool
skip_mathlib_checks: bool
graph: bool
if __name__ == "__main__":
parser = ArgumentParser()
parser.add_argument("version", type=Version.parse)
parser.add_argument("-i", "--interactive", action="store_true")
parser.add_argument("-W", "--skip-weak-deps", action="store_true")
parser.add_argument("-M", "--skip-mathlib-checks", action="store_true")
parser.add_argument("-g", "--graph", action="store_true")
args = parser.parse_args(namespace=Args())
util.initialize_rich()
github = util.get_github_instance()
config = Config(
version=args.version,
interactive=args.interactive,
skip_weak_deps=args.skip_weak_deps,
skip_mathlib_checks=args.skip_mathlib_checks,
github=github,
)
try:
LeanChecker(config=config).check()
finally:
if args.graph:
print()
repos.print_graphviz_dot()