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 Enum
from pathlib import Path
import re
from sys import stderr
from time import sleep
import typing
class ReleaseTypes(Enum):
MAJOR = 0
MINOR = 1
PATCH = 2
valid_release_types = typing.Literal["major", "minor", "patch"]
class ReleaseStrings:
"""An easy way to pass the various flavours of release string between functions."""
def __init__(self, input_tag: str):
version_mask = r"v\d+\.\d+\.\d+\D*.*"
regex_101 = "https://regex101.com/r/dLVaNH/1"
if re.fullmatch(version_mask, input_tag) is None:
message = (
"Release tag does not match the input mask:\n"
f"{version_mask}\n"
f"({regex_101})"
)
raise ValueError(message)
else:
self.tag = input_tag # v1.2.3rc0
self.series = ".".join(self.tag.split(".")[:2]) # v1.2
self.branch = self.series + ".x" # v1.2.x
self.release = self.tag[1:] # 1.2.3rc0
class WhatsNewRsts:
"""An easy way to pass the paths of various What's New files between functions."""
def __init__(self, release_strings: ReleaseStrings):
src_dir = Path(__file__).parents[1] / "docs" / "src"
whatsnew_dir = src_dir / "whatsnew"
assert whatsnew_dir.is_dir()
self.latest = whatsnew_dir / "latest.rst"
self.release = whatsnew_dir / (release_strings.series[1:] + ".rst")
self.index = whatsnew_dir / "index.rst"
self.template = self.latest.with_suffix(".rst.template")
def _break_print(message: str):
print()
print(message)
# Help with flow/visibility by waiting 1secs before proceeding.
sleep(1)
def _mark_section(section_number: int):
_break_print(f"SECTION {section_number} ...")
def _get_input(message: str, expected_inputs: str) -> str:
_break_print(message)
return input(expected_inputs + " : ")
def _wait_for_done(message: str):
_break_print(message)
done = False
while not done:
done = (
input("Step complete? y / [n] : ").casefold() == "y".casefold()
)
def _report_problem(message: str):
print(message, file=stderr)
# To ensure correct sequencing of messages.
sleep(0.5)
def get_release_type() -> ReleaseTypes:
release_type = None
release_types_str = " ".join(
[f"{m.name}={m.value}" for m in ReleaseTypes.__members__.values()]
)
message = "What type of release are you preparing?\nhttps://semver.org/"
while release_type is None:
input_type = _get_input(message, release_types_str)
try:
release_type = ReleaseTypes(int(input_type))
except ValueError:
_report_problem("Invalid release type. Please try again ...")
_break_print(f"{release_type} confirmed.")
return release_type
def get_release_tag() -> ReleaseStrings:
# TODO: automate using setuptools_scm.
release_strings = None
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"
)
while release_strings is None:
input_tag = _get_input(message, "e.g. v1.2.3rc0")
try:
release_strings = ReleaseStrings(input_tag)
except ValueError as err:
_report_problem(str(err))
return release_strings
def check_release_candidate(
release_type: ReleaseTypes, release_strings: ReleaseStrings
) -> bool:
is_release_candidate = "rc" in release_strings.tag
message = "Checking tag for release candidate: "
if is_release_candidate:
message += "DETECTED\nThis IS a release candidate."
else:
message += "NOT DETECTED\nThis IS NOT a release candidate."
_break_print(message)
if release_type == ReleaseTypes.PATCH and is_release_candidate:
message = (
"Release candidates are not expected for PATCH releases. "
"Are you sure you want to continue?"
)
if _get_input(message, "y / [n]").casefold() != "y".casefold():
exit()
return is_release_candidate
def check_first_in_series(
release_type: ReleaseTypes,
release_strings: ReleaseStrings,
is_release_candidate: bool,
) -> bool:
first_in_series = False
if release_type != ReleaseTypes.PATCH:
message = (
"Have there been any prior releases in the "
f"{release_strings.series} series, including release candidates?"
)
first_in_series = (
_get_input(message, "[y] / n").casefold() == "n".casefold()
)
if first_in_series:
_break_print("First in series confirmed.")
if not is_release_candidate:
message = (
"The first release in a series is expected to be a "
"release candidate, but this is not. Are you sure you "
"want to continue?"
)
if _get_input(message, "y / [n]").casefold() != "y".casefold():
exit()
else:
_break_print("Existing series confirmed.")
return first_in_series
def update_standard_names(first_in_series: bool) -> None:
if first_in_series:
message = (
"Update the file ``etc/cf-standard-name-table.xml`` to the latest CF "
"standard names, via a new Pull Request.\n"
"(This is used during build to automatically generate the sourcefile "
"``lib/iris/std_names.py``).\n"
"Latest standard names:\n"
'wget "https://cfconventions.org/Data/cf-standard-names/current/src/cf-standard-name-table.xml";'
)
_wait_for_done(message)
def check_deprecations(release_type: ReleaseTypes) -> None:
if release_type == 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"
)
_wait_for_done(message)
def _delete_local_branch(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 origin {branch_name};"
)
_wait_for_done(message)
def create_release_branch(
release_strings: ReleaseStrings, first_in_series: bool
) -> None:
# TODO: automate
_break_print("Release branch management ...")
if first_in_series:
message = (
"Visit https://github.com/SciTools/iris and create the"
f"``{release_strings.branch}`` release branch from ``main``."
)
_wait_for_done(message)
else:
message = (
"Cherry-pick any specific commits that are needed from ``main`` "
f"onto {release_strings.branch} , to get the CI passing.\n"
"E.g. a new dependency pin may have been introduced since "
f"{release_strings.branch} was last updated from ``main``.\n"
"DO NOT squash-merge - want to preserve the original commit SHA's."
)
_wait_for_done(message)
def finalise_whats_new(
release_type: ReleaseTypes,
release_strings: ReleaseStrings,
is_release_candidate: bool,
first_in_series: bool,
) -> WhatsNewRsts:
_break_print("What's New finalisation ...")
working_branch = release_strings.branch + ".updates"
_delete_local_branch(working_branch)
message = (
f"Checkout a local branch from the official {release_strings.branch} branch.\n"
"git fetch upstream;\n"
f"git checkout upstream/{release_strings.branch} -b "
f"{working_branch};"
)
_wait_for_done(message)
rsts = WhatsNewRsts(release_strings)
# TODO: automate
if first_in_series:
message = (
"'Cut' the What's New for the release.\n"
f"git mv {rsts.latest.absolute()} {rsts.release.absolute()};"
)
_wait_for_done(message)
message = (
f"In {rsts.index.absolute()}:\n"
f"Replace references to {rsts.latest.name} with {rsts.release.name}"
)
_wait_for_done(message)
_break_print(f"What's New file path = {rsts.release}")
if not release_type == ReleaseTypes.PATCH:
whatsnew_title = f"{release_strings.series} ({datetime.today().strftime('%d %b %Y')})"
if is_release_candidate:
whatsnew_title += " [release candidate]"
# TODO: automate
message = f"In {rsts.release.name}: set the page title to:\n{whatsnew_title}\n"
if not is_release_candidate:
message += (
"\nBe sure to remove any existing mentions of release "
"candidate from the title.\n"
)
_wait_for_done(message)
message = (
f"In {rsts.release.name}: ensure the page title underline is "
"the exact same length as the page title text."
)
_wait_for_done(message)
dropdown_title = f"\n{release_strings.series} Release Highlights\n"
message = (
f"In {rsts.release.name}: set the sphinx-design dropdown title to:{dropdown_title}"
)
_wait_for_done(message)
message = (
f"Review {rsts.release.name} to ensure it is a good reflection of "
f"what is new in {release_strings.series}."
)
_wait_for_done(message)
message = (
"Work with the development team to populate the Release "
f"Highlights dropdown section at the top of {rsts.release.name}."
)
_wait_for_done(message)
else:
message = (
"Create a patch dropdown section at the top of "
f"{rsts.release.name}.\n"
f"See {rsts.template} for how this should be written."
)
_wait_for_done(message)
if first_in_series:
# TODO: automate
message = (
"Remove the What's New template file.\n"
f"git rm {rsts.template.absolute()};"
)
_wait_for_done(message)
message = (
"Commit and push all the What's New changes.\n"
f'git commit -am "Whats new updates for {release_strings.tag} .";\n'
f"git push -u origin {working_branch};"
)
_wait_for_done(message)
message = (
f"Follow the Pull Request process to get {working_branch} "
f"merged into upstream/{release_strings.branch} .\n"
"Make sure the documentation is previewed during this process."
)
_wait_for_done(message)
return rsts
def cut_release(
release_strings: ReleaseStrings, is_release_candidate: bool
) -> None:
_break_print("The release ...")
message = (
"Visit https://github.com/SciTools/iris/releases/new to open "
"a blank new-release web page."
)
_wait_for_done(message)
message = (
f"Select {release_strings.branch} as the Target.\n"
f"Input {release_strings.tag} as the new tag to create, and also as "
"the Release title."
)
_wait_for_done(message)
message = (
"Craft an appropriate release description in the main text box.\n"
"Be sure to mention the What's New entry, conda-forge and PyPI - you "
"will need to return later to make these into links.\n"
"Be careful to change the appropriate words if copying from a "
"previous release description."
)
_wait_for_done(message)
if 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={release_strings.release}\n"
f"pip install scitools-iris=={release_strings.release}"
)
_wait_for_done(message)
message = (
"This is a release candidate - tick the box to set this as a "
"pre-release."
)
_wait_for_done(message)
else:
message = "Tick the box to set this as the latest release."
_wait_for_done(message)
message = "Click: Publish release !"
_wait_for_done(message)
message = (
"The CI will now run against this new tag, including automatically "
"publishing to PyPI."
)
_break_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."
)
_wait_for_done(message)
def check_rtd(
release_strings: ReleaseStrings, is_release_candidate: bool
) -> None:
_break_print("Read the Docs checks ...")
message = (
"Visit https://readthedocs.org/projects/scitools-iris/versions/ and "
"make sure you are logged in."
)
_wait_for_done(message)
message = f"Set {release_strings.tag} to Active, un-Hidden."
_wait_for_done(message)
message = f"Set {release_strings.branch} to Active, Hidden."
_wait_for_done(message)
message = (
"Keep the latest 2 branch doc builds active - those formatted 0.0.x - "
"deactivate older ones."
)
_wait_for_done(message)
message = (
f"Visit https://scitools-iris.readthedocs.io/en/{release_strings.tag} "
"to confirm:\n\n"
"- The docs have rendered.\n"
f"- The version badge in the top left reads: {release_strings.tag} .\n"
" (this demonstrates that setuptools_scm has worked correctly).\n"
"- The What's New looks correct.\n"
f"- {release_strings.tag} is available in RTD's version switcher.\n\n"
"NOTE: the docs can take several minutes to finish building."
)
if not is_release_candidate:
message += (
"- Selecting 'stable' in the version switcher also brings up the "
f"{release_strings.tag} render."
)
_wait_for_done(message)
message = (
f"Visit https://scitools-iris.readthedocs.io/en/{release_strings.branch} "
"to confirm:\n\n"
"- The docs have rendered\n"
f"- The version badge in the top left includes: {release_strings.branch} .\n"
f"- {release_strings.branch} is NOT available in RTD's version switcher.\n\n"
"NOTE: the docs can take several minutes to finish building."
)
_wait_for_done(message)
def check_pypi(
release_strings: ReleaseStrings, is_release_candidate: bool
) -> str:
_break_print("PyPI checks ...")
_break_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/{release_strings.release}/"
)
_wait_for_done(message)
message = (
f"Confirm that {release_strings.release} is at the top of this page:\n"
"https://pypi.org/project/scitools-iris/#history"
)
_wait_for_done(message)
if is_release_candidate:
message = (
f"Confirm that {release_strings.release} is marked as a "
f"pre-release on this page:\n"
"https://pypi.org/project/scitools-iris/#history"
)
else:
message = (
f"Confirm that {release_strings.release} is the tag shown on the "
"scitools-iris PyPI homepage:\n"
"https://pypi.org/project/scitools-iris/"
)
_wait_for_done(message)
message = (
f"Visit the below and click `view hashes` for the Source Distribution"
f"(`.tar.gz`):\n"
f"https://pypi.org/project/scitools-iris/{release_strings.release}#files\n"
)
sha256 = _get_input(message, "Input the SHA256 hash")
message = (
"Confirm that pip install works as expected:\n"
f"pip install scitools-iris=={release_strings.release};"
)
_wait_for_done(message)
return sha256
def update_conda_forge(
release_strings: ReleaseStrings, is_release_candidate: bool, sha256: str
) -> None:
_break_print("conda-forge updates ...")
if not 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 ..."
)
_break_print(message)
message = (
"Make sure you have a GitHub fork of:\n"
"https://github.com/conda-forge/iris-feedstock"
)
_wait_for_done(message)
message = (
"Make sure you have a local clone of your iris-feedstock fork.\n"
"`cd` into your clone."
)
_wait_for_done(message)
if is_release_candidate:
message = (
"Visit the conda-forge feedstock branches page:\n"
"https://github.com/conda-forge/iris-feedstock/branches"
)
_wait_for_done(message)
message = (
"Find the release candidate branch - "
"`rc`/`release-candidate`/similar.\n"
)
rc_branch = _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:
age_check = _get_input(message, "y / n")
if age_check.casefold() == "y".casefold():
archive_rc = True
elif age_check.casefold() == "n".casefold():
archive_rc = False
else:
_report_problem("Invalid entry. Please try again ...")
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 _{date_string} "
"to its name.\n"
f"e.g. rc_{date_string}\n\n"
f"({__file__} includes an explanation of this in the comments)."
)
_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"
"Config file(s) should point to the `rc_iris` label.\n"
)
rc_branch = _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 {release_strings.tag};\n"
)
_wait_for_done(message)
message = (
"Update ./recipe/meta.yaml:\n\n"
f"- The version at the very top of the file: {release_strings.release}\n"
f"- The sha256 hash: {sha256}\n"
"- Requirements: align the packages and pins with those in the Iris repo\n"
"- Maintainers: update with any changes to the dev team\n"
"- MAKE SURE everything else is correct - plenty of other things "
"might need one-off changes.\n"
)
_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"
f'git commit -am "Recipe updates for {release_strings.tag} .";\n'
f"git push -u origin {release_strings.tag};"
)
_wait_for_done(message)
message = (
f"Follow the Pull Request process to get {release_strings.tag} branch "
f"merged into upstream/{upstream_branch} .\n"
"Specific conda-forge guidance will be automatically given once the "
"PR is created."
)
_wait_for_done(message)
message = (
f"Confirm that {release_strings.release} appears in this list:\n"
"https://anaconda.org/conda-forge/iris/files"
)
_wait_for_done(message)
if not is_release_candidate:
message = (
f"Confirm that {release_strings.release} is displayed on this "
"page as the latest available:\n"
"https://anaconda.org/conda-forge/iris"
)
_wait_for_done(message)
if is_release_candidate:
channel_command = " -c conda-forge/label/rc_iris "
else:
channel_command = " "
message = (
"Confirm that conda (or mamba) install works as expected:\n"
f"conda create -n tmp_iris{channel_command}iris={release_strings.release};\n"
f"conda remove -n tmp_iris --all;"
)
_wait_for_done(message)
def update_links(release_strings: ReleaseStrings) -> None:
_break_print("Link updates ...")
message = (
"Revisit the GitHub release:\n"
f"https://github.com/SciTools/iris/releases/tag/{release_strings.tag}\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/{release_strings.tag}/\n"
f"https://pypi.org/project/scitools-iris/{release_strings.release}/\n"
f"https://anaconda.org/conda-forge/iris?version={release_strings.release}\n"
)
_wait_for_done(message)
message = (
"Update the release page in GitHub discussions, with the above links "
"and anything else appropriate.\n"
"https://github.com/SciTools/iris/discussions"
)
_wait_for_done(message)
def twitter_announce(
release_strings: ReleaseStrings, first_in_series: bool
) -> None:
message = (
"Announce the release via https://twitter.com/scitools_iris, and any "
"other appropriate message boards (e.g. Yammer).\n"
"Any content used for the announcement should be stored in the "
"SciTools/twitter-scitools-iris GitHub repo.\n"
)
if not first_in_series:
message += (
f"Consider replying within an existing {release_strings.series} "
"announcement thread, if appropriate."
)
_wait_for_done(message)
def update_citation(
release_strings: ReleaseStrings, is_release_candidate: bool
) -> None:
if not is_release_candidate:
src_dir = Path(__file__).parents[1] / "docs" / "src"
citation_rst = src_dir / "userguide" / "citation.rst"
assert citation_rst.is_file()
message = (
f"Follow the Pull Request process to update {citation_rst.name} "
"with the correct dates, DOI and version string for "
f"{release_strings.tag}.\n"
f"{citation_rst.absolute()}\n\n"
f"The PR should target {release_strings.branch} (prior to merge-back)."
)
_wait_for_done(message)
def merge_back(
release_strings: ReleaseStrings, first_in_series: bool, rsts: WhatsNewRsts
) -> None:
_break_print("Branch merge-back ...")
merge_commit = (
"BE SURE TO MERGE VIA A MERGE-COMMIT (not a squash-commit), to "
"preserve the commit SHA's."
)
if first_in_series:
# TODO: automate
working_branch = release_strings.branch + ".mergeback"
_delete_local_branch(working_branch)
message = (
"Checkout a local branch from the official ``main`` branch.\n"
"git fetch upstream;\n"
f"git checkout upstream/main -b {working_branch};"
)
_wait_for_done(message)
message = (
f"Merge in the commits from {release_strings.branch}.\n"
f"{merge_commit}\n"
f"git merge upstream/{release_strings.branch} --no-ff "
'-m "Merging release branch into main";'
)
_wait_for_done(message)
message = (
"Recreate the following files, which are present in ``main``, but "
f"are currently deleted from {working_branch}:\n"
f"{rsts.latest.absolute()}\n"
f"{rsts.template.absolute()}\n"
"THEN:\n"
f"git add {rsts.latest.absolute()};\n"
f"git add {rsts.template.absolute()};\n"
)
_wait_for_done(message)
message = (
f"In {rsts.index.absolute()}:\n"
f"Add {rsts.latest.name} to the top of the list of .rst files, "
f"and set the top include:: to be {rsts.latest.name} ."
)
_wait_for_done(message)
message = (
"Commit and push all the What's New changes.\n"
'git commit -am "Restore latest Whats New files.";\n'
f"git push -u origin {working_branch};"
)
_wait_for_done(message)
message = (
"Follow the Pull Request process to get "
f"{working_branch} merged into upstream/main .\n"
"Make sure the documentation is previewed during this process.\n"
f"{merge_commit}"
)
_wait_for_done(message)
else:
message = (
f"Propose a merge-back from {release_strings.branch} into ``main`` by "
f"visiting this URL and clicking `Create pull request`:\n"
f"https://github.com/SciTools/iris/compare/main...{release_strings.branch}\n"
f"{merge_commit}"
)
_wait_for_done(message)
message = (
f"Once the pull request is merged ensure that the {release_strings.branch} "
"release branch is restored.\n"
"GitHub automation rules may have automatically deleted the release branch."
)
_wait_for_done(message)
def main():
_mark_section(1)
release_type = get_release_type()
_mark_section(2)
release_strings = get_release_tag()
_mark_section(3)
is_release_candidate = check_release_candidate(
release_type,
release_strings,
)
_mark_section(4)
is_first_in_series = check_first_in_series(
release_type,
release_strings,
is_release_candidate,
)
_mark_section(5)
update_standard_names(
is_first_in_series,
)
_mark_section(6)
check_deprecations(
release_type,
)
_mark_section(7)
create_release_branch(
release_strings,
is_first_in_series,
)
_mark_section(8)
whats_new_rsts = finalise_whats_new(
release_type,
release_strings,
is_release_candidate,
is_first_in_series,
)
_mark_section(9)
cut_release(
release_strings,
is_release_candidate,
)
_mark_section(10)
check_rtd(
release_strings,
is_release_candidate,
)
_mark_section(11)
sha256 = check_pypi(
release_strings,
is_release_candidate,
)
_mark_section(12)
update_conda_forge(
release_strings,
is_release_candidate,
sha256,
)
_mark_section(13)
update_links(
release_strings,
)
_mark_section(14)
twitter_announce(
release_strings,
is_first_in_series,
)
_mark_section(15)
update_citation(
release_strings,
is_release_candidate,
)
_mark_section(16)
merge_back(
release_strings,
is_first_in_series,
whats_new_rsts,
)
_break_print("RELEASE COMPLETE. Congratulations! 🎉")
if __name__ == "__main__":
main()