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 typing

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 IrisRelease(Progress):
    class ReleaseTypes(IntEnum):
        MAJOR = 0
        MINOR = 1
        PATCH = 2

    github_user: str = None
    release_type: ReleaseTypes = None
    git_tag: str = None  # v1.2.3rc0
    first_in_series: bool = None
    sha256: 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.get_github_user,
            cls.get_release_type,
            cls.get_release_tag,
            cls.check_release_candidate,
            cls.check_first_in_series,
            cls.update_standard_names,
            cls.check_deprecations,
            cls.create_release_branch,
            cls.finalise_whats_new,
            cls.cut_release,
            cls.check_rtd,
            cls.check_pypi,
            cls.update_conda_forge,
            cls.update_links,
            cls.twitter_announce,
            cls.merge_back,
        ]

    def get_github_user(self):
        def validate(input_user: str) -> str | None:
            if not re.fullmatch(r"[a-zA-Z0-9-]+", input_user):
                self.report_problem("Invalid GitHub username. Please try again ...")
            else:
                return input_user

        message = (
            "Please input your GitHub username.\n"
            "This is used in the URLs for creating pull requests."
        )
        self.set_value_from_input(
            key="github_user",
            message=message,
            expected_inputs="Username",
            post_process=validate,
        )
        self.print(f"GitHub username = {self.github_user}")

    def get_release_type(self):
        def validate(input_value: str) -> IrisRelease.ReleaseTypes | None:
            try:
                return self.ReleaseTypes(int(input_value))
            except ValueError:
                self.report_problem("Invalid release type. Please try again ...")

        self.set_value_from_input(
            key="release_type",
            message="What type of release are you preparing?\nhttps://semver.org/",
            expected_inputs=f"Choose a number {tuple(self.ReleaseTypes)}",
            post_process=validate,
        )
        self.print(f"{repr(self.release_type)} confirmed.")

    def get_release_tag(self):
        # TODO: automate using setuptools_scm.

        def validate(input_tag: str) -> str | None:
            # TODO: use the packaging library?
            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:
                problem_message = (
                    "Release tag does not match the input mask:\n"
                    f"{version_mask}\n"
                    f"({regex_101})"
                )
                self.report_problem(problem_message)
            else:
                return input_tag  # v1.2.3rc0

        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,
        )

    class Strings(typing.NamedTuple):
        series: str
        branch: str
        release: str

    @property
    def strings(self) -> Strings:
        series = ".".join(self.git_tag.split(".")[:2])  # v1.2
        return self.Strings(
            series=series,
            branch=series + ".x",  # v1.2.x
            release=self.git_tag[1:],  # 1.2.3rc0
        )

    @property
    def is_release_candidate(self) -> bool:
        return "rc" in self.git_tag

    def check_release_candidate(self):
        message = "Checking tag for release candidate: "
        if self.is_release_candidate:
            message += "DETECTED\nThis IS a release candidate."
        else:
            message += "NOT DETECTED\nThis IS NOT a release candidate."
        self.print(message)

        if self.release_type == self.ReleaseTypes.PATCH and self.is_release_candidate:
            message = (
                "Release candidates are not expected for PATCH releases. "
                "Are you sure you want to continue?"
            )
            if self.get_input(message, "y / [n]").casefold() != "y".casefold():
                exit()

    def check_first_in_series(self):
        if self.release_type != self.ReleaseTypes.PATCH:
            message = (
                f"Is this the first release in the {self.strings.series} "
                f"series, including any release candidates?"
            )
            self.set_value_from_input(
                key="first_in_series",
                message=message,
                expected_inputs="y / n",
                post_process=lambda x: x.casefold() == "y".casefold(),
            )
            if self.first_in_series:
                self.print("First in series confirmed.")
                if not self.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 self.get_input(message, "y / [n]").casefold() != "y".casefold():
                        exit()
            else:
                self.print("Existing series confirmed.")

    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 update_standard_names(self):
        if self.first_in_series:
            working_branch = self.strings.branch + ".standard_names"
            self._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};"
            )
            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 origin {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 == 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.strings.branch}`` release branch from ``main``."
            )
            self.wait_for_done(message)

        else:
            message = (
                "Cherry-pick any specific commits that are needed from ``main`` "
                f"onto {self.strings.branch} , to get the CI passing.\n"
                "E.g. a new dependency pin may have been introduced since "
                f"{self.strings.branch} was last updated from ``main``.\n"
                "DO NOT squash-merge - want to preserve the original commit "
                "SHA's."
            )
            self.wait_for_done(message)

    @staticmethod
    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};"
        )
        IrisRelease.wait_for_done(message)

    class WhatsNewRsts(typing.NamedTuple):
        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.strings.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.strings.branch + ".updates"
        self._delete_local_branch(working_branch)
        message = (
            f"Checkout a local branch from the official {self.strings.branch} "
            f"branch.\n"
            "git fetch upstream;\n"
            f"git checkout upstream/{self.strings.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 == self.ReleaseTypes.PATCH:
            whatsnew_title = (
                f"{self.strings.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.strings.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.strings.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.git_tag} .";\n'
            f"git push -u origin {working_branch};"
        )
        self.wait_for_done(message)

        self._create_pr(
            base_org="SciTools",
            base_repo="iris",
            base_branch=self.strings.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.strings.branch} as the Target.\n"
            f"Input {self.git_tag} 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.strings.release}\n"
                f"pip install scitools-iris=={self.strings.release}"
            )
            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:
            message = "Tick the box to set this as the latest release."
            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://readthedocs.org/projects/scitools-iris/versions/ "
            "and make sure you are logged in."
        )
        self.wait_for_done(message)

        message = f"Set {self.git_tag} to Active, un-Hidden."
        self.wait_for_done(message)

        message = f"Set {self.strings.branch} to Active, Hidden."
        self.wait_for_done(message)

        message = (
            "Keep only the latest 2 branch doc builds active - "
            f"'{self.strings.branch}' and the previous one - deactivate older "
            "ones."
        )
        self.wait_for_done(message)

        message = (
            f"Visit https://scitools-iris.readthedocs.io/en/{self.git_tag} "
            "to confirm:\n\n"
            "- The docs have rendered.\n"
            "- The version badge in the top left reads:\n"
            f"  'version (archived) | {self.git_tag}'\n"
            "   (this demonstrates that setuptools_scm has worked correctly).\n"
            "- The What's New looks correct.\n"
            f"- {self.git_tag} is available in RTD's version switcher.\n\n"
            "NOTE: the docs can take several minutes to finish building."
        )
        if not self.is_release_candidate:
            message += (
                "- Selecting 'stable' in the version switcher also brings up "
                f"the {self.git_tag} render."
            )
        self.wait_for_done(message)

        message = (
            f"Visit https://scitools-iris.readthedocs.io/en/{self.strings.branch} "
            "to confirm:\n\n"
            "- The docs have rendered\n"
            f"- The version badge in the top left includes: {self.strings.branch} .\n"
            f"- {self.strings.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.strings.release}/"
        )
        self.wait_for_done(message)

        message = (
            f"Confirm that {self.strings.release} 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.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 {self.strings.release} 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:
            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 ...")
            else:
                return sha256_string

        message = (
            f"Visit the below and click `view hashes` for the Source Distribution"
            f"(`.tar.gz`):\n"
            f"https://pypi.org/project/scitools-iris/{self.strings.release}#files\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"
            "conda create -y -n tmp_iris pip cf-units;\n"
            "conda activate tmp_iris;\n"
            f"pip install scitools-iris=={self.strings.release};\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.git_tag};\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.strings.release}\n"
            f"- The sha256 hash: {self.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"
            "- 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"
        )
        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"
            f"git add recipe/meta.yaml;\n"
            f'git commit -m "Recipe updates for {self.git_tag} .";\n'
            f"git push -u origin {self.git_tag};"
        )
        self.wait_for_done(message)

        self._create_pr(
            base_org="conda-forge",
            base_repo="iris-feedstock",
            base_branch=upstream_branch,
            head_branch=self.git_tag,
        )

        if self.is_release_candidate:
            readme_url = f"https://github.com/{self.github_user}/iris-feedstock/blob/{self.git_tag}/README.md"
            rc_evidence = (
                "\n\nConfirm 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.strings.release} appears in this list:\n"
            "https://anaconda.org/conda-forge/iris/files"
        )
        self.wait_for_done(message)

        if not self.is_release_candidate:
            message = (
                f"Confirm that {self.strings.release} is displayed on this "
                "page as the latest available:\n"
                "https://anaconda.org/conda-forge/iris"
            )
            self.wait_for_done(message)

        if self.is_release_candidate:
            channel_command = " -c conda-forge/label/rc_iris "
        else:
            channel_command = " -c conda-forge "

        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"
            "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.strings.release};"
        )
        self.wait_for_done(message)

        message = (
            "Confirm that conda (or mamba) install works as expected:\n"
            f"conda create -n tmp_iris{channel_command}iris="
            f"{self.strings.release};\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.git_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/{self.git_tag}/\n"
            f"https://pypi.org/project/scitools-iris/{self.strings.release}/\n"
            f"https://anaconda.org/conda-forge/iris?version={self.strings.release}\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.git_tag} has been released."
        )
        self.wait_for_done(message)

    def twitter_announce(self):
        message = (
            "Announce the release via https://twitter.com/scitools_iris, "
            "and any "
            "other appropriate message boards (e.g. Viva Engage).\n"
            "Any content used for the announcement should be stored in the "
            "SciTools/twitter-scitools-iris GitHub repo.\n"
        )
        if not self.first_in_series:
            message += (
                f"Consider replying within an existing "
                f"{self.strings.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."
        )

        if self.first_in_series:
            # TODO: automate

            working_branch = self.strings.branch + ".mergeback"
            self._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};"
            )
            self.wait_for_done(message)

            message = (
                f"Merge in the commits from {self.strings.branch}.\n"
                f"{merge_commit}\n"
                f"git merge upstream/{self.strings.branch} --no-ff "
                '-m "Merging release branch into main";'
            )
            self.wait_for_done(message)

            message = (
                "Recreate the What's New template from ``main``:\n"
                f"git checkout upstream/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"
                f"git add {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.index.absolute()};\n"
                'git commit -m "Restore latest Whats New files.";\n'
                f"git push -u origin {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.\n"
                "Make sure the documentation is previewed during this process.\n"
                f"{merge_commit}"
            )
            self.wait_for_done(message)

        else:
            message = (
                f"Propose a merge-back from {self.strings.branch} into "
                f"``main`` by "
                f"visiting this URL and clicking `Create pull request`:\n"
                f"https://github.com/SciTools/iris/compare/main..."
                f"{self.strings.branch}\n"
                f"{merge_commit}"
            )
            self.wait_for_done(message)
            message = (
                f"Once the pull request is merged ensure that the "
                f"{self.strings.branch} "
                "release branch is restored.\n"
                "GitHub automation rules may have automatically deleted the "
                "release branch."
            )
            self.wait_for_done(message)


if __name__ == "__main__":
    IrisRelease.main()