Release Do-Nothing Script#
Rendered from the original <Iris repo root>/tools/release_do_nothing.py.
Read more about do-nothing scripts
#!/usr/bin/env python3
# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""A do-nothing script to hand-hold through the Iris release process.
https://blog.danslimmon.com/2019/07/15/do-nothing-scripting-the-key-to-gradual-automation/
"""
from datetime import datetime
from enum import IntEnum
from pathlib import Path
import re
import shlex
import subprocess
import typing
from packaging.version import InvalidVersion, Version
try:
from nothing import Progress
except ImportError:
install_message = (
"This script requires the `nothing` package to be installed:\n"
"pip install git+https://github.com/SciTools-incubator/nothing.git"
)
raise ImportError(install_message)
class IrisVersion(Version):
def __str__(self):
return f"v{super().__str__()}"
@property
def minor_series(self) -> str:
return f"v{self.major}.{self.minor}"
@property
def branch(self) -> str:
return f"{self.minor_series}.x"
@property
def short(self) -> str:
return str(self)[1:]
class IrisRelease(Progress):
class ReleaseTypes(IntEnum):
"""Enumeration of semantic versioning types."""
MAJOR = 0
MINOR = 1
PATCH = 2
github_scitools: str = "upstream"
github_fork: str = "origin"
github_user: typing.Optional[str] = None
patch_min_max_tag: typing.Optional[tuple[str, str]] = None
git_tag: typing.Optional[str] = None # v1.2.3rc0
sha256: typing.Optional[str] = None
@classmethod
def get_cmd_description(cls) -> str:
return "Do-nothing workflow for the Iris release process."
@classmethod
def get_steps(cls) -> list[typing.Callable[..., None]]:
return [
cls.analyse_remotes,
cls.get_release_tag,
cls.get_all_patches,
cls.apply_patches,
cls.validate,
cls.release_highlights,
cls.update_standard_names,
cls.check_deprecations,
cls.create_release_branch,
cls.finalise_whats_new,
cls.cut_release,
cls.check_pypi,
cls.update_conda_forge,
cls.check_rtd,
cls.update_links,
cls.bluesky_announce,
cls.revisit_conda_forge,
cls.merge_back,
cls.next_release,
]
@staticmethod
def _git_remote_v() -> str:
# Factored out to assist with testing.
return subprocess.check_output(shlex.split("git remote -v"), text=True)
def _git_remote_get_url(self) -> str:
# Factored out to assist with testing.
return subprocess.check_output(
shlex.split(f"git remote get-url {self.github_fork}"), text=True
)
def analyse_remotes(self):
self.print("Analysing Git remotes ...")
class Remote(typing.NamedTuple):
name: str
url: str
fetch: bool
remotes_raw = self._git_remote_v().splitlines()
remotes_split = [line.split() for line in remotes_raw]
remotes = [
Remote(name=parts[0], url=parts[1], fetch=parts[2] == "(fetch)")
for parts in remotes_split
]
scitools_regex = re.compile(r"github\.com[:/]SciTools/iris")
self.github_scitools = [
r.name for r in remotes if r.fetch and scitools_regex.search(r.url)
][0]
possible_forks = [
r for r in remotes if not r.fetch and r.name != self.github_scitools
]
assert len(possible_forks) > 0
def number_to_fork(input_number: str) -> str | None:
try:
result = possible_forks[int(input_number)].name
except (ValueError, IndexError):
result = None
self.report_problem("Invalid number. Please try again ...")
return result
numbered_forks = " | ".join(
[f"{ix}: {r.name}" for ix, r in enumerate(possible_forks)]
)
self.set_value_from_input(
key="github_fork",
message="Which remote is your Iris fork?",
expected_inputs=f"Choose a number {numbered_forks}",
post_process=number_to_fork,
)
fork_url = self._git_remote_get_url()
search_result = re.search(
r"(?<=github\.com[:/])([a-zA-Z0-9-]+)(?=/)",
fork_url,
)
if search_result is None:
message = f"Error deriving GitHub username from URL: {fork_url}"
raise RuntimeError(message)
else:
self.github_user = search_result.group(0)
def _git_ls_remote_tags(self) -> str:
# Factored out to assist with testing.
return subprocess.check_output(
shlex.split(f"git ls-remote --tags {self.github_scitools}"),
text=True,
)
def _get_tagged_versions(self) -> list[IrisVersion]:
tag_regex = re.compile(r"(?<=refs/tags/).*$")
scitools_tags_raw = self._git_ls_remote_tags().splitlines()
scitools_tags_searched = [tag_regex.search(line) for line in scitools_tags_raw]
scitools_tags = [
search.group(0) for search in scitools_tags_searched if search is not None
]
def get_version(tag: str) -> IrisVersion | None:
try:
return IrisVersion(tag)
except InvalidVersion:
return None
versions = [get_version(tag) for tag in scitools_tags]
tagged_versions = [v for v in versions if v is not None]
if len(tagged_versions) == 0:
message = (
"Error: unable to find any valid version tags in the "
f"{self.github_scitools} remote."
)
raise RuntimeError(message)
return tagged_versions
def get_release_tag(self):
def validate(input_tag: str) -> str | None:
result = None
try:
version = IrisVersion(input_tag)
except InvalidVersion as err:
self.report_problem(f"Packaging error: {err}\nPlease try again ...")
else:
if version in self._get_tagged_versions():
self.report_problem(
f"Version {version} already exists as a git tag. "
"Please try again ..."
)
else:
result = input_tag # v1.2.3rc0
return result
message = (
"Input the release tag you are creating today, including any "
"release "
"candidate suffix.\n"
"https://semver.org/\n"
"https://scitools-iris.readthedocs.io/en/latest/developers_guide"
"/release.html?highlight=candidate#release-candidate"
)
self.set_value_from_input(
key="git_tag",
message=message,
expected_inputs="e.g. v1.2.3rc0",
post_process=validate,
)
@property
def version(self) -> IrisVersion:
# Implemented like this since the Version class cannot be JSON serialised.
return IrisVersion(self.git_tag)
@property
def is_latest_tag(self) -> bool:
return all(self.version >= v for v in self._get_tagged_versions())
@property
def release_type(self) -> ReleaseTypes:
if self.version.micro == 0:
if self.version.minor == 0:
release_type = self.ReleaseTypes.MAJOR
else:
release_type = self.ReleaseTypes.MINOR
else:
release_type = self.ReleaseTypes.PATCH
return release_type
@property
def is_release_candidate(self) -> bool:
return self.version.is_prerelease and self.version.pre[0] == "rc"
@property
def first_in_series(self) -> bool:
release_step = IrisRelease.get_steps().index(IrisRelease.cut_release)
release_complete = self.latest_complete_step >= release_step
same_series = [
v
for v in self._get_tagged_versions()
if v.minor_series == self.version.minor_series
]
result = len(same_series) == 0 or (
release_complete and same_series == [self.version]
)
return result
def get_all_patches(self):
if self.release_type is self.ReleaseTypes.PATCH:
message = (
"PATCH release detected. Sometimes a patch needs to be applied "
"to multiple minor_series."
)
self.print(message)
tagged_versions = self._get_tagged_versions()
series_all = [v.minor_series for v in sorted(tagged_versions)]
series_unique = sorted(set(series_all), key=series_all.index)
series_numbered = "\n".join(
f"{i}: {s}" for i, s in enumerate(series_unique)
)
def numbers_to_new_patches(input_numbers: str) -> tuple[str, str] | None:
try:
first_str, last_str = input_numbers.split(",")
first, last = int(first_str), int(last_str)
except ValueError:
self.report_problem(
"Invalid input, expected two integers comma-separated. "
"Please try again ..."
)
return None
try:
series_min = series_unique[first]
series_max = series_unique[last]
except IndexError:
self.report_problem("Invalid numbers. Please try again ...")
return None
def series_new_patch(series: str) -> str:
latest = max(v for v in tagged_versions if v.minor_series == series)
iris_version = IrisVersion(
f"{latest.major}.{latest.minor}.{latest.micro + 1}"
)
return str(iris_version)
return (series_new_patch(series_min), series_new_patch(series_max))
self.set_value_from_input(
key="patch_min_max_tag",
message=(
f"{series_numbered}\n\n"
"Input the earliest and latest minor_series that need patching."
),
expected_inputs=f"Choose two numbers from above e.g. 0,2",
post_process=numbers_to_new_patches,
)
first_patch = self.patch_min_max[0]
if self.version > first_patch:
message = (
f"Starting with {first_patch}. ({self.version} will be "
"covered in sequence)"
)
self.print(message)
self.git_tag = str(first_patch)
@property
def patch_min_max(self) -> tuple[IrisVersion, IrisVersion] | None:
if self.patch_min_max_tag is None:
result = None
else:
assert len(self.patch_min_max_tag) == 2
result = (
IrisVersion(self.patch_min_max_tag[0]),
IrisVersion(self.patch_min_max_tag[1]),
)
return result
@property
def more_patches_after_this_one(self) -> bool:
return (
self.release_type is self.ReleaseTypes.PATCH
and self.patch_min_max is not None
and self.version.minor_series < self.patch_min_max[1].minor_series
)
def apply_patches(self):
if self.release_type is self.ReleaseTypes.PATCH:
message = (
f"Input the {self.github_scitools} branch name where the patch "
"change commit(s) exist, or make no input if nothing has been "
"merged yet."
)
patch_branch = self.get_input(
message=message,
expected_inputs="",
)
match patch_branch:
case self.version.branch:
message = (
"The patch change(s) are on the ideal branch to avoid later "
f"Git conflicts: {self.version.branch} . Continue ..."
)
case "":
message = (
f"Propose the patch change(s) against {self.version.branch} via "
f"pull request(s). Targeting {self.version.branch} will "
"avoid later Git conflicts."
)
case _:
message = (
"Create pull request(s) cherry-picking the patch change(s) "
f"from {patch_branch} into {self.version.branch} .\n"
"cherry-picking will cause Git conflicts later in the "
"release process; in future consider targeting the patch "
"change(s) directly at the release branch."
)
self.wait_for_done(message)
def validate(self) -> None:
self.print("Validating release details ...")
message_template = (
f"{self.version} corresponds to a {{}} release. This script cannot "
"handle such releases."
)
if self.version.is_devrelease:
message = message_template.format("development")
raise RuntimeError(message)
if self.version.is_postrelease:
message = message_template.format("post")
raise RuntimeError(message)
if self.version.is_prerelease and self.version.pre[0] != "rc":
message = (
"The only pre-release type that this script can handle is 'rc' "
f"(for release candidate), but got '{self.version.pre[0]}'."
)
raise RuntimeError(message)
if self.release_type is self.ReleaseTypes.PATCH and self.is_release_candidate:
message = (
f"{self.version} corresponds to a PATCH release AND a release "
"candidate. This script cannot handle that combination."
)
raise RuntimeError(message)
if self.first_in_series:
message_pre = f"No previous releases found in the {self.version.minor_series} minor_series."
if self.release_type is self.ReleaseTypes.PATCH:
message = (
f"{message_pre} This script cannot handle a PATCH release "
f"that is the first in a minor_series."
)
raise RuntimeError(message)
if not self.is_release_candidate:
message = (
f"{message_pre} The first release in a minor_series is expected "
f"to be a release candidate, but this is not. Are you sure "
f"you want to continue?"
)
if self.get_input(message, "y / [n]").casefold() != "y".casefold():
exit()
status = {
"GitHub user": self.github_user,
"SciTools remote": self.github_scitools,
"Fork remote": self.github_fork,
"Release tag": self.git_tag,
"Release type": self.release_type.name,
"Release candidate?": self.is_release_candidate,
f"First release in {self.version.minor_series} minor_series?": self.first_in_series,
"Current latest Iris release": max(self._get_tagged_versions()),
}
if (
self.release_type is self.ReleaseTypes.PATCH
and self.patch_min_max is not None
):
status["Minor series being patched"] = (
f"{self.patch_min_max[0].minor_series} to {self.patch_min_max[1].minor_series}"
)
message = (
"\n".join(f"- {k}: {v}" for k, v in status.items()) + "\n\n"
"Confirm that the details above are correct.\n"
"Consider temporary/permanent edits to the do-nothing script if "
"necessary."
)
self.wait_for_done(message)
def _create_pr(
self, base_org: str, base_repo: str, base_branch: str, head_branch: str
) -> None:
"""Instruct user to create a PR with a specified base and head.
Parameters
----------
base_org : str
The name of the GitHub organisation that owns the `base_repo` that
owns the `base_branch`.
base_repo : str
The name of the GitHub repository (within the `base_org`) that owns
the `base_branch`.
base_branch : str
The name of the branch (within the `base_repo`) that will be the
base of the PR.
head_branch : str
The name of the branch (within the user's fork of `base_repo`) that
will be the head of the PR.
"""
repo_url = f"https://github.com/{base_org}/{base_repo}"
diff_url = f"{base_branch}...{self.github_user}:{base_repo}:{head_branch}"
full_url = f"{repo_url}/compare/{diff_url}"
pr_message = (
"Create a Pull Request for your changes by visiting this URL "
"and clicking `Create pull request`:\n"
f"{full_url}"
)
self.wait_for_done(pr_message)
def release_highlights(self):
if self.first_in_series:
message = (
"Assemble some bullet points summarising the highlights of "
"this release. Share with the development team for feedback.\n"
"The finalised highlights will be included in the What's New "
"page later in this process."
)
self.wait_for_done(message)
def update_standard_names(self):
if self.first_in_series:
working_branch = self.version.branch + ".standard_names"
self._delete_local_branch(working_branch)
message = (
"Checkout a local branch from the official ``main`` branch.\n"
f"git fetch {self.github_scitools};\n"
f"git checkout {self.github_scitools}/main -b {working_branch};"
)
self.wait_for_done(message)
url = "https://cfconventions.org/Data/cf-standard-names/current/src/cf-standard-name-table.xml"
file = Path(__file__).parents[1] / "etc" / "cf-standard-name-table.xml"
message = (
"Update the CF standard names table to the latest version:\n"
f'wget "{url}" -O {file};\n'
f"git add {file};\n"
"git commit -m 'Update CF standard names table.';\n"
f"git push -u {self.github_fork} {working_branch};"
)
self.wait_for_done(message)
self._create_pr(
base_org="SciTools",
base_repo="iris",
base_branch="main",
head_branch=working_branch,
)
message = "Work with the development team to get the PR merged."
self.wait_for_done(message)
def check_deprecations(self):
if self.release_type is self.ReleaseTypes.MAJOR:
message = (
"This is a MAJOR release - be sure to finalise all deprecations "
"and FUTUREs from previous releases, via a new Pull Request.\n"
"https://scitools-iris.readthedocs.io/en/latest/developers_guide"
"/contributing_deprecations.html"
)
self.wait_for_done(message)
def create_release_branch(self):
# TODO: automate
print("Release branch management ...")
if self.first_in_series:
message = (
"Visit https://github.com/SciTools/iris and create the "
f"``{self.version.branch}`` release branch from ``main``."
)
self.wait_for_done(message)
else:
message = (
"If necessary: "
"cherry-pick any specific commits that are needed from ``main`` "
f"onto {self.version.branch} , to get the CI passing.\n"
"E.g. a new dependency pin may have been introduced since "
f"{self.version.branch} was last updated from ``main``.\n"
"Note that cherry-picking will cause Git conflicts later in "
"the release process."
)
self.wait_for_done(message)
def _delete_local_branch(self, branch_name: str):
message = (
"Before the next step, avoid a name clash by deleting any "
"existing local branch, if one exists.\n"
f"git branch -D {branch_name};\n"
f"git push -d {self.github_fork} {branch_name};"
)
IrisRelease.wait_for_done(message)
class WhatsNewRsts(typing.NamedTuple):
"""The various paths that make up the What's New structure."""
latest: Path
release: Path
index_: Path
template: Path
@property
def whats_news(self) -> WhatsNewRsts:
src_dir = Path(__file__).parents[1] / "docs" / "src"
whatsnew_dir = src_dir / "whatsnew"
assert whatsnew_dir.is_dir()
latest = whatsnew_dir / "latest.rst"
return self.WhatsNewRsts(
latest=latest,
release=whatsnew_dir / (self.version.minor_series[1:] + ".rst"),
index_=whatsnew_dir / "index.rst",
template=latest.with_suffix(".rst.template"),
)
def finalise_whats_new(self):
self.print("What's New finalisation ...")
working_branch = self.version.branch + ".updates"
self._delete_local_branch(working_branch)
message = (
f"Checkout a local branch from the official {self.version.branch} "
f"branch.\n"
f"git fetch {self.github_scitools};\n"
f"git checkout {self.github_scitools}/{self.version.branch} -b "
f"{working_branch};"
)
self.wait_for_done(message)
# TODO: automate
if self.first_in_series:
message = (
"'Cut' the What's New for the release.\n"
f"git mv {self.whats_news.latest.absolute()} "
f"{self.whats_news.release.absolute()};"
)
self.wait_for_done(message)
message = (
f"In {self.whats_news.index_.absolute()}:\n"
f"Replace references to {self.whats_news.latest.name} with "
f"{self.whats_news.release.name}"
)
self.wait_for_done(message)
self.print(f"What's New file path = {self.whats_news.release}")
if not self.release_type is self.ReleaseTypes.PATCH:
whatsnew_title = (
f"{self.version.minor_series} ({datetime.today().strftime('%d %b %Y')}"
)
if self.is_release_candidate:
whatsnew_title += " [release candidate]"
whatsnew_title += ")"
# TODO: automate
message = (
f"In {self.whats_news.release.name}: set the page title to:\n"
f"{whatsnew_title}\n"
)
if not self.is_release_candidate:
message += (
"\nBe sure to remove any existing mentions of release "
"candidate from the title.\n"
)
self.wait_for_done(message)
message = (
f"In {self.whats_news.release.name}: ensure the page title "
"underline is the exact same length as the page title text."
)
self.wait_for_done(message)
dropdown_title = f"\n{self.version.minor_series} Release Highlights\n"
message = (
f"In {self.whats_news.release.name}: set the sphinx-design "
f"dropdown title to:{dropdown_title}"
)
self.wait_for_done(message)
message = (
f"Review {self.whats_news.release.name} to ensure it is a good "
f"reflection of what is new in {self.version.minor_series}.\n"
"I.e. all significant work you are aware of should be "
"present, such as a major dependency pin, a big new feature, "
"a known performance change. You can not be expected to know "
"about every single small change."
)
self.wait_for_done(message)
message = (
"Work with the development team to populate the Release "
f"Highlights dropdown section at the top of "
f"{self.whats_news.release.name}."
)
self.wait_for_done(message)
else:
message = (
"Create a patch dropdown section at the top of "
f"{self.whats_news.release.name}.\n"
f"See {self.whats_news.template} for how this should be written."
)
self.wait_for_done(message)
if self.first_in_series:
# TODO: automate
message = (
"Remove the What's New template file.\n"
f"git rm {self.whats_news.template.absolute()};"
)
self.wait_for_done(message)
message = (
"Commit and push all the What's New changes.\n"
f"git add {self.whats_news.release.absolute()};\n"
f"git add {self.whats_news.index_.absolute()};\n"
f'git commit -m "Whats-New updates for {self.version} .";\n'
f"git push -u {self.github_fork} {working_branch};"
)
self.wait_for_done(message)
self._create_pr(
base_org="SciTools",
base_repo="iris",
base_branch=self.version.branch,
head_branch=working_branch,
)
message = (
"Work with the development team to get the PR merged.\n"
"Make sure the documentation is previewed during this process.\n"
"Make sure you are NOT targeting the `main` branch."
)
self.wait_for_done(message)
def cut_release(self):
self.print("The release ...")
message = (
"Visit https://github.com/SciTools/iris/releases/new to open "
"a blank new-release web page."
)
self.wait_for_done(message)
message = (
f"Select {self.version.branch} as the Target.\n"
f"Input {self.version} as the new tag to create, and also as "
"the Release title.\n"
"Make sure you are NOT targeting the `main` branch."
)
self.wait_for_done(message)
message = (
"Populate the main text box.\n"
"- Usual approach: copy from the last similar release, and "
"THOROUGHLY check for all references to the old release - change "
"these.\n"
"- Alternatively: craft a new release description from scratch. "
"Be sure to mention the What's New entry, conda-forge and PyPI; "
"note that you will need to return later to make these into "
"links.\n"
)
self.wait_for_done(message)
if self.is_release_candidate:
message = (
"This is a release candidate - include the following "
"instructions for installing with conda or pip:\n"
f"conda install -c conda-forge/label/rc_iris iris={self.version.short}\n"
f"pip install scitools-iris=={self.version.short}"
)
self.wait_for_done(message)
message = (
"This is a release candidate - tick the box to set this as a "
"pre-release."
)
self.wait_for_done(message)
else:
if self.is_latest_tag:
message = "Tick the box to set this as the latest release."
else:
message = "Un-tick the latest release box."
self.wait_for_done(message)
message = "Click: Publish release !"
self.wait_for_done(message)
message = (
"The CI will now run against this new tag, including automatically "
"publishing to PyPI."
)
self.print(message)
url = "https://github.com/SciTools/iris/actions/workflows/ci-wheels.yml"
message = (
f"Visit {url} to monitor the building, testing and publishing of "
"the Iris sdist and binary wheel to PyPI."
)
self.wait_for_done(message)
def check_rtd(self):
self.print("Read the Docs checks ...")
message = (
"Visit https://app.readthedocs.org/projects/scitools-iris/ "
"and make sure you are logged in."
)
self.wait_for_done(message)
add_version = (
"You may need to click `Add version` if it is not already in the list"
)
message = f"Set {self.version} to Active, un-Hidden.\n{add_version}"
self.wait_for_done(message)
message = f"Set {self.version.branch} to Active, Hidden.\n{add_version}"
self.wait_for_done(message)
message = (
"Keep only the latest 2 branch doc builds active - "
f"'{self.version.branch}' and the previous one - deactivate older "
"ones."
)
self.wait_for_done(message)
message = (
f"Visit https://scitools-iris.readthedocs.io/en/{self.version} "
"to confirm:\n\n"
"- The docs have rendered.\n"
"- The version badge in the top left reads:\n"
f" 'version (archived) | {self.version}'\n"
" (this demonstrates that setuptools_scm has worked correctly).\n"
"- The What's New looks correct.\n"
f"- {self.version} is available in RTD's version switcher.\n"
)
if not self.is_release_candidate and self.is_latest_tag:
message += (
"- Selecting 'stable' in the version switcher also brings up "
f"the {self.version} render.\n"
)
message += "\nNOTE: the docs can take several minutes to finish building."
self.wait_for_done(message)
message = (
f"Visit https://scitools-iris.readthedocs.io/en/{self.version.branch} "
"to confirm:\n\n"
"- The docs have rendered\n"
f"- The version badge in the top left includes: {self.version.branch} .\n"
f"- {self.version.branch} is NOT available in RTD's version switcher.\n\n"
"NOTE: the docs can take several minutes to finish building."
)
self.wait_for_done(message)
def check_pypi(self):
self.print("PyPI checks ...")
self.print("If anything goes wrong, manual steps are in the documentation.")
message = (
"Confirm that the following URL is correctly populated:\n"
f"https://pypi.org/project/scitools-iris/{self.version.short}/"
)
self.wait_for_done(message)
if self.is_latest_tag:
message = (
f"Confirm that {self.version.short} is at the top of this page:\n"
"https://pypi.org/project/scitools-iris/#history"
)
self.wait_for_done(message)
if self.is_release_candidate:
message = (
f"Confirm that {self.version.short} is marked as a "
f"pre-release on this page:\n"
"https://pypi.org/project/scitools-iris/#history"
)
self.wait_for_done(message)
elif self.is_latest_tag:
message = (
f"Confirm that {self.version.short} is the tag shown on the "
"scitools-iris PyPI homepage:\n"
"https://pypi.org/project/scitools-iris/"
)
self.wait_for_done(message)
def validate(sha256_string: str) -> str | None:
valid = True
try:
_ = int(sha256_string, 16)
except ValueError:
valid = False
valid = valid and len(sha256_string) == 64
if not valid:
self.report_problem("Invalid SHA256 hash. Please try again ...")
result = None
else:
result = sha256_string
return result
message = (
f"Visit the below to view the details for the Source Distribution"
f"(`.tar.gz`):\n"
f"https://pypi.org/project/scitools-iris/{self.version.short}##scitools_iris-{self.version.short}.tar.gz\n"
)
self.set_value_from_input(
key="sha256",
message=message,
expected_inputs="Input the SHA256 hash",
post_process=validate,
)
message = (
"Confirm that pip install works as expected:\n"
"Beware of any Python pin Iris might have when creating your Conda environment!\n"
"conda create -y -n tmp_iris pip cf-units;\n"
"conda activate tmp_iris;\n"
f"pip install scitools-iris=={self.version.short};\n"
'python -c "import iris; print(iris.__version__)";\n'
"conda deactivate;\n"
"conda remove -n tmp_iris --all;\n"
)
self.wait_for_done(message)
def update_conda_forge(self):
self.print("conda-forge checks ...")
if not self.is_release_candidate:
message = (
"NOTE: after several hours conda-forge automation will "
"create a "
"Pull Request against conda-forge/iris-feedstock (via the "
"regro-cf-autotick-bot). Quicker to sort it now, manually ..."
)
self.print(message)
message = (
"Make sure you have a GitHub fork of:\n"
"https://github.com/conda-forge/iris-feedstock"
)
self.wait_for_done(message)
if self.is_release_candidate:
message = (
"Visit the conda-forge feedstock branches page:\n"
"https://github.com/conda-forge/iris-feedstock/branches"
)
self.wait_for_done(message)
message = (
"Find the release candidate branch - typical names:\n"
"`rc` / `release-candidate` / similar .\n"
)
rc_branch = self.get_input(
message, "Input the name of the release candidate branch"
)
message = f"Is the latest commit on {rc_branch} over 1 month ago?"
archive_rc = None
while archive_rc is None:
valid_entries = ["y", "n"]
age_check = self.get_input(message, " / ".join(valid_entries))
match = [age_check.casefold() == e.casefold() for e in valid_entries]
if not any(match):
self.report_problem("Invalid entry. Please try again ...")
else:
archive_rc = match[0]
if archive_rc:
# We chose this odd handling of release candidate branches because
# a persistent branch will gradually diverge as `main` receives
# automatic and manual maintenance (where recreating these on
# another branch is often beyond Iris dev expertise). Advised
# practice from conda-forge is also liable to evolve over time.
# Since there is no benefit to a continuous Git history on the
# release candidate branch, the simplest way to keep it aligned
# with best practice is to regularly create a fresh branch from
# `main`.
date_string = datetime.today().strftime("%Y%m%d")
message = (
f"Archive the {rc_branch} branch by appending _"
f"{date_string} "
"to its name.\n"
f"e.g. rc_{date_string}\n\n"
f"({__file__} includes an explanation of this in the "
f"comments)."
)
self.wait_for_done(message)
message = (
"Follow the latest conda-forge guidance for creating a new "
"release candidate branch from the `main` branch:\n"
"https://conda-forge.org/docs/maintainer/knowledge_base.html#pre-release-builds\n\n"
"If you need to change any feedstock files: a pull "
"request is coming in the the next steps so you can make "
"those changes at that point.\n\n"
"DEVIATION FROM GUIDANCE: config file(s) should point to "
"the `rc_iris` label (this is not the name that "
"conda-forge suggest).\n"
)
rc_branch = self.get_input(message, "Input the name of your new branch")
upstream_branch = rc_branch
else:
upstream_branch = "main"
# TODO: automate
message = (
"Checkout a new branch for the conda-forge changes for this "
"release:\n"
"git fetch upstream;\n"
f"git checkout upstream/{upstream_branch} -b "
f"{self.version};\n"
)
self.wait_for_done(message)
message = (
"Update ./recipe/meta.yaml:\n\n"
f"- The version at the very top of the file: "
f"{self.version.short}\n"
f"- The sha256 hash: {self.sha256}\n"
"- Build number: reset to 0 (or advance it if this is not a new release).\n"
"- Requirements: align the packages and pins with those in the "
"Iris repo\n"
"- Maintainers: update with any changes to the dev team\n"
"- Skim read the entire file to see if anything else is out of"
"date, e.g. is the licence info still correct? Ask the lead "
"Iris developers if unsure.\n"
)
if not self.is_latest_tag:
message += (
f"\nNOTE: {self.version} is not the latest Iris release, so "
"you may need to restore settings from an earlier version "
f"(check previous {self.version.minor_series} releases)."
)
self.wait_for_done(message)
# TODO: automate
message = (
"No other file normally needs changing in iris-feedstock, "
"so push up "
"the changes to prepare for a Pull Request:\n"
"WARNING: accidentally pushing straight to conda-forge (instead "
"of your fork) will instantly trigger a release!\n"
f"git add recipe/meta.yaml;\n"
f'git commit -m "Recipe updates for {self.version} .";\n'
f"git push -u origin {self.version};"
)
self.wait_for_done(message)
self._create_pr(
base_org="conda-forge",
base_repo="iris-feedstock",
base_branch=upstream_branch,
head_branch=f"{self.version}",
)
if self.is_release_candidate:
readme_url = f"https://github.com/{self.github_user}/iris-feedstock/blob/{self.version}/README.md"
rc_evidence = (
"\n\nAfter conda-forge has committed the re-render: "
"confirm that conda-forge knows your changes are for the "
"release candidate channel by checking the below README file. "
"This should make multiple references to the `rc_iris` label:\n"
f"{readme_url}"
)
else:
rc_evidence = ""
message = (
"Follow the automatic conda-forge guidance for further populating "
f"your Pull Request.{rc_evidence}"
)
self.wait_for_done(message)
message = "Work with your fellow feedstock maintainers to get the PR merged."
self.wait_for_done(message)
message = (
"After the PR is merged, wait for the CI to complete, after which "
"the new version of Iris will be on conda-forge's servers.\n"
"https://dev.azure.com/conda-forge/feedstock-builds/_build?definitionId=464"
)
self.wait_for_done(message)
message = (
f"Confirm that {self.version.short} appears in this list:\n"
"https://anaconda.org/conda-forge/iris/files"
)
self.wait_for_done(message)
if not self.is_release_candidate and self.is_latest_tag:
message = (
f"Confirm that {self.version.short} is displayed on this "
"page as the latest available:\n"
"https://anaconda.org/conda-forge/iris"
)
self.wait_for_done(message)
message = (
"The new release will now undergo testing and validation in the "
"cf-staging channel. Once this is complete, the release will be "
"available in the standard conda-forge channel. This can "
"sometimes take minutes, or up to an hour.\n"
"We'll come back later."
)
self.print(message)
if not self.is_latest_tag and not self.more_patches_after_this_one:
latest_version = max(self._get_tagged_versions())
message = (
f"{self.version} is not the latest Iris release, so the "
f"{upstream_branch} branch needs to be restored to reflect "
f"{latest_version}, to minimise future confusion.\n"
"Do this via a new pull request. So long as the version number "
"and build number match the settings from the latest release, "
"no new conda-forge release will be triggered.\n"
)
self.wait_for_done(message)
# As many steps as possible will be put between update_conda_forge and
# revisit_conda_forge, given the delay.
def revisit_conda_forge(self):
self.print("Revisiting conda-forge ...")
if self.is_release_candidate:
channel_command = " -c conda-forge/label/rc_iris "
else:
channel_command = " -c conda-forge "
message = (
"Confirm that the new release is available for use from "
"conda-forge by running the following command:\n"
f"conda search{channel_command}iris=={self.version.short};"
)
self.wait_for_done(message)
message = (
"Confirm that conda (or mamba) install works as expected:\n"
"If anything is wrong: consider whether announcement(s) might need "
"undoing/updating.\n"
f"conda create -n tmp_iris{channel_command}iris="
f"{self.version.short};\n"
"conda activate tmp_iris;\n"
'python -c "import iris; print(iris.__version__)";\n'
"conda deactivate;\n"
f"conda remove -n tmp_iris --all;"
)
self.wait_for_done(message)
def update_links(self):
self.print("Link updates ...")
message = (
"Revisit the GitHub release:\n"
f"https://github.com/SciTools/iris/releases/tag/{self.version}\n"
"You have confirmed that Read the Docs, PyPI and conda-forge have all "
"updated correctly. Include the following links in the release "
"notes:\n\n"
f"https://scitools-iris.readthedocs.io/en/{self.version}/\n"
f"https://pypi.org/project/scitools-iris/{self.version.short}/\n"
f"https://anaconda.org/channels/conda-forge/packages/iris/files?file_q={self.version.short}\n"
)
self.wait_for_done(message)
message = (
"What is the URL for the GitHub discussions page of this "
"release?\n"
"https://github.com/SciTools/iris/discussions\n"
)
discussion_url = self.get_input(message, "Input the URL")
message = (
f"Update {discussion_url}, with the above "
"links and anything else appropriate.\n"
"The simplest way is to copy appropriate content from a previous "
"release, then edit it to match the current release."
)
self.wait_for_done(message)
message = (
f"Comment on {discussion_url} to notify anyone watching that "
f"{self.version} has been released."
)
self.wait_for_done(message)
def bluesky_announce(self):
message = (
"Announce the release via https://bsky.app/profile/scitools.bsky.social, "
"and any "
"other appropriate message boards (e.g. Viva Engage).\n"
"Visuals like plots or screenshots are GREAT!\n"
"Any content used for the announcement should be stored in the "
"SciTools/bluesky-scitools GitHub repo.\n"
)
if not self.first_in_series:
message += (
f"Consider replying within an existing "
f"{self.version.minor_series} "
"announcement thread, if appropriate."
)
self.wait_for_done(message)
def merge_back(self):
self.print("Branch merge-back ...")
merge_commit = (
"BE SURE TO MERGE VIA A MERGE-COMMIT (not a squash-commit), to "
"preserve the commit SHA's."
)
def next_series_patch() -> IrisVersion:
tagged_versions = self._get_tagged_versions()
series_all = sorted(set(v.minor_series for v in tagged_versions))
try:
next_series = series_all[
series_all.index(self.version.minor_series) + 1
]
except (IndexError, ValueError):
message = f"Error finding next minor_series after {self.version.minor_series} ."
raise RuntimeError(message)
series_latest = max(
v for v in tagged_versions if v.minor_series == next_series
)
return IrisVersion(
f"{series_latest.major}.{series_latest.minor}.{series_latest.micro + 1}"
)
if self.more_patches_after_this_one:
message = "More minor_series need patching. Merge into the next minor_series' branch ..."
self.print(message)
next_patch = next_series_patch()
target_branch = next_patch.branch
working_branch = f"{self.version}-to-{target_branch}"
else:
next_patch = None
target_branch = "main"
working_branch = self.version.branch + ".mergeback"
# TODO: automate
self._delete_local_branch(working_branch)
message = (
"Checkout a local branch from the official branch.\n"
f"git fetch {self.github_scitools};\n"
f"git checkout {self.github_scitools}/{target_branch} -b {working_branch};"
)
self.wait_for_done(message)
message = (
f"Merge in the commits from {self.version.branch}.\n"
f"{merge_commit}\n"
f"git merge {self.github_scitools}/{self.version.branch} --no-ff "
f'-m "Merging {self.version.branch} into {target_branch}";'
)
self.wait_for_done(message)
if self.first_in_series:
message = (
"Recreate the What's New template from ``main``:\n"
f"git checkout {self.github_scitools}/main {self.whats_news.template.absolute()};\n"
)
self.wait_for_done(message)
message = (
"Recreate the What's New latest from the template:\n"
f"cp {self.whats_news.template.absolute()} "
f"{self.whats_news.latest.absolute()};\n"
)
self.wait_for_done(message)
message = (
f"Follow any guidance in {self.whats_news.latest.name} to "
"complete the recreation-from-template.\n"
"E.g. removing the bugfix section."
)
self.wait_for_done(message)
message = (
f"In {self.whats_news.index_.absolute()}:\n"
f"Add {self.whats_news.latest.name} to the top of the list of .rst "
f"files, "
f"and set the top include:: to be {self.whats_news.latest.name} ."
)
self.wait_for_done(message)
message = (
"Commit and push all the What's New changes.\n"
f"git add {self.whats_news.latest.absolute()};\n"
f"git add {self.whats_news.index_.absolute()};\n"
'git commit -m "Restore latest Whats-New files.";\n'
f"git push -u {self.github_fork} {working_branch};"
)
self.wait_for_done(message)
self._create_pr(
base_org="SciTools",
base_repo="iris",
base_branch=target_branch,
head_branch=working_branch,
)
message = (
"COMBINING BRANCHES CAN BE RISKY; confirm that only the expected "
"commits are in the PR."
)
self.wait_for_done(message)
message = (
"Work with the development team to get the PR merged.\n"
f"If {self.version.branch} includes any cherry-picks, there may be "
"merge conflicts to resolve.\n"
"Make sure the documentation is previewed during this process.\n"
f"{merge_commit}"
)
self.wait_for_done(message)
if self.more_patches_after_this_one:
self.print("Moving on to the next patch ...")
assert self.version != next_patch
# Create a special new progress file which is set up for stepping
# through the next patch release.
next_patch_str = str(next_patch).replace(".", "_")
next_patch_stem = self._get_file_stem().with_stem(next_patch_str)
class NextPatch(IrisRelease):
@classmethod
def _get_file_stem(cls) -> Path:
return next_patch_stem
def run(self):
pass
next_patch_kwargs = self.__getstate__() | dict(
git_tag=str(next_patch),
sha256=None,
latest_complete_step=NextPatch.get_steps().index(NextPatch.validate)
- 1,
)
next_patch_script = NextPatch(**next_patch_kwargs)
next_patch_script.save()
new_command = (
f"python {Path(__file__).absolute()} load "
f"{next_patch_script._file_path}"
)
message = (
"Run the following command in a new terminal to address "
f"{next_patch} next:\n"
f"{new_command}"
)
self.wait_for_done(message)
def next_release(self):
if (
self.release_type is not self.ReleaseTypes.PATCH
and not self.is_release_candidate
):
self.print("Prep next release ...")
message = (
"Confirm that there is a release manager in place for the "
"next minor (or major) release."
)
self.wait_for_done(message)
message = (
"Confirm that the next release manager has set up a "
"milestone for their release.\n"
"https://github.com/SciTools/iris/milestones"
)
self.wait_for_done(message)
message = (
"Confirm that the next release manager has set up a "
"discussion page for their release.\n"
"https://github.com/SciTools/iris/discussions/categories/releases"
)
self.wait_for_done(message)
message = (
"Confirm that the next release manager has arranged "
"some team development time (e.g. sprints) for "
"delivering Iris improvements in their release.\n\n"
"The UK Met Office has some Confluence guidance for this."
)
self.wait_for_done(message)
message = (
"Remind the next release manager about the importance "
"of regularly championing their release (e.g. during "
"Peloton meetings).\n\n"
"Relying solely on a few focussed weeks cannot deliver "
"many improvements."
)
self.wait_for_done(message)
if __name__ == "__main__":
IrisRelease.main()