From 133614e253ebc2b31d8457335f32b5cd7ba6ec8a Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Fri, 22 Aug 2025 13:44:50 +0100 Subject: [PATCH 01/26] Note about merging directly to release branch. --- docs/src/developers_guide/release.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/developers_guide/release.rst b/docs/src/developers_guide/release.rst index 2dcbd03ea1..43e648ff80 100644 --- a/docs/src/developers_guide/release.rst +++ b/docs/src/developers_guide/release.rst @@ -88,6 +88,11 @@ New features shall not be included in a patch release, these are for bug fixes. A patch release does not require a release candidate, but the rest of the release process is to be followed. +As mentioned in :ref:`release_branch`: branch/commit management is much simpler +if the patch changes are **first merged into the release branch** - +e.g. ``v1.9.x`` - and are only added to ``main`` during :ref:`merge_back` (post +release). + Before Release -------------- @@ -111,6 +116,8 @@ from the `latest CF standard names`_. The Release ----------- +.. _release_branch: + Release Branch ~~~~~~~~~~~~~~ @@ -193,6 +200,8 @@ of the new release. Ideally this would be updated before the release, but the DOI for the new version is only available once the release has been created in GitHub. +.. _merge_back: + Merge Back ~~~~~~~~~~ From 905130ee8f99d08742f22963667563ce1f5ec22b Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Fri, 22 Aug 2025 18:37:06 +0100 Subject: [PATCH 02/26] Large updates to release-do-nothing for correct handling of patch releases. --- tools/release_do_nothing.py | 712 ++++++++++++++++++++++++++---------- 1 file changed, 511 insertions(+), 201 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 34700ebb87..da22a84848 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -13,8 +13,12 @@ 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: @@ -25,16 +29,30 @@ raise ImportError(install_message) +class IrisVersion(Version): + def __str__(self): + return f"v{super().__str__()}" + + @property + def series(self) -> str: + return f"v{self.major}.{self.minor}" + + @property + def branch(self) -> str: + return f"{self.series}.x" + + class IrisRelease(Progress): class ReleaseTypes(IntEnum): MAJOR = 0 MINOR = 1 PATCH = 2 + github_scitools: str = "upstream" + github_fork: str = "origin" github_user: str = None - release_type: ReleaseTypes = None + patch_min_max_tag: tuple[str, str] = None git_tag: str = None # v1.2.3rc0 - first_in_series: bool = None sha256: str = None @classmethod @@ -44,11 +62,12 @@ def get_cmd_description(cls) -> str: @classmethod def get_steps(cls) -> list[typing.Callable[..., None]]: return [ - cls.get_github_user, - cls.get_release_type, + cls.analyse_remotes, + # cls.parse_tags, cls.get_release_tag, - cls.check_release_candidate, - cls.check_first_in_series, + cls.get_all_patches, + cls.apply_patches, + cls.validate, cls.update_standard_names, cls.check_deprecations, cls.create_release_branch, @@ -63,56 +82,118 @@ def get_steps(cls) -> list[typing.Callable[..., None]]: cls.next_release, ] - 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 + @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 + ) - message = ( - "Please input your GitHub username.\n" - "This is used in the URLs for creating pull requests." + 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\.git") + 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_user", - message=message, - expected_inputs="Username", - post_process=validate, + key="github_fork", + message="Which remote is your Iris fork?", + expected_inputs=f"Choose a number {numbered_forks}", + post_process=number_to_fork, ) - self.print(f"GitHub username = {self.github_user}") - def get_release_type(self): - def validate(input_value: str) -> IrisRelease.ReleaseTypes | None: + fork_url = self._git_remote_get_url() + self.github_user = re.search( + r"(?<=github\.com[:/])([a-zA-Z0-9-]+)(?=/)", + fork_url, + ).group(0) + if self.github_user is None: + message = f"Error deriving GitHub username from URL: {fork_url}" + raise RuntimeError(message) + + 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 = [ + tag_regex.search(line).group(0) for line in scitools_tags_raw + ] + + def get_version(tag: str) -> IrisVersion | None: try: - return self.ReleaseTypes(int(input_value)) - except ValueError: - self.report_problem("Invalid release type. Please try again ...") + return IrisVersion(tag) + except InvalidVersion: + return None - 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.") + 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): - # 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})" + try: + version = IrisVersion(input_tag) + except InvalidVersion as err: + self.report_problem( + f"Packaging error: {err}\n" + "Please try again ..." ) - self.report_problem(problem_message) else: - return input_tag # v1.2.3rc0 + if version in self._get_tagged_versions(): + self.report_problem( + f"Version {version} already exists as a git tag. " + "Please try again ..." + ) + else: + return input_tag # v1.2.3rc0 message = ( "Input the release tag you are creating today, including any " @@ -129,64 +210,217 @@ def validate(input_tag: str) -> str | None: post_process=validate, ) - class Strings(typing.NamedTuple): - series: str - branch: str - release: str + @property + def version(self) -> IrisVersion: + # Implemented like this since the Version class cannot be JSON serialised. + return IrisVersion(self.git_tag) @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 - ) + 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 "rc" in self.git_tag + return self.version.is_prerelease and self.version.pre[0] == "rc" - 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) + @property + def first_in_series(self) -> bool: + return self.version.series not in [v.series for v in self._get_tagged_versions()] - if self.release_type == self.ReleaseTypes.PATCH and self.is_release_candidate: + def get_all_patches(self): + if self.release_type is self.ReleaseTypes.PATCH: message = ( - "Release candidates are not expected for PATCH releases. " - "Are you sure you want to continue?" + "PATCH release detected. Sometimes a patch needs to be applied " + "to multiple series." + ) + self.print(message) + + tagged_versions = self._get_tagged_versions() + series_all = [v.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.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 series that need patching." + ), + expected_inputs=f"Choose two numbers from above e.g. 0,2", + post_process=numbers_to_new_patches, ) - if self.get_input(message, "y / [n]").casefold() != "y".casefold(): - exit() - def check_first_in_series(self): - if self.release_type != self.ReleaseTypes.PATCH: + 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: + if self.release_type is self.ReleaseTypes.PATCH: + return self.version < self.patch_min_max[1] + else: + return False + + def apply_patches(self): + if self.release_type is self.ReleaseTypes.PATCH: message = ( - f"Is this the first release in the {self.strings.series} " - f"series, including any release candidates?" + 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." ) - self.set_value_from_input( - key="first_in_series", + patch_branch = self.get_input( message=message, - expected_inputs="y / n", - post_process=lambda x: x.casefold() == "y".casefold(), + expected_inputs="", ) - if self.first_in_series: - self.print("First in series confirmed.") - if not self.is_release_candidate: + match patch_branch: + case self.version.branch: 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?" + "The patch change(s) are on the ideal branch to avoid later" + f"Git conflicts: {self.version.branch} . Continue ..." ) - if self.get_input(message, "y / [n]").casefold() != "y".casefold(): - exit() - else: - self.print("Existing series confirmed.") + case "": + message = ( + f"Propose the patch change(s) against {self.version.branch} via " + f"pull request(s). Targetting {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 targetting 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.series} 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 series." + ) + raise RuntimeError(message) + + if not self.is_release_candidate: + message = ( + f"{message_pre} The first release in a 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.series} series?": self.first_in_series, + "Current latest Iris release": max(self._get_tagged_versions()), + } + if self.release_type is self.ReleaseTypes.PATCH: + status["Series being patched"] = ( + f"{self.patch_min_max[0].series} to {self.patch_min_max[1].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, @@ -225,12 +459,12 @@ def _create_pr( def update_standard_names(self): if self.first_in_series: - working_branch = self.strings.branch + ".standard_names" + working_branch = self.version.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};" + f"git fetch {self.github_scitools};\n" + f"git checkout {self.github_scitools}/main -b {working_branch};" ) self.wait_for_done(message) @@ -241,7 +475,7 @@ def update_standard_names(self): 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};" + f"git push -u {self.github_fork} {working_branch};" ) self.wait_for_done(message) @@ -255,7 +489,7 @@ def update_standard_names(self): self.wait_for_done(message) def check_deprecations(self): - if self.release_type == self.ReleaseTypes.MAJOR: + 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" @@ -271,28 +505,28 @@ def create_release_branch(self): if self.first_in_series: message = ( "Visit https://github.com/SciTools/iris and create the" - f"``{self.strings.branch}`` release branch from ``main``." + f"``{self.version.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" + "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.strings.branch} was last updated from ``main``.\n" - "DO NOT squash-merge - want to preserve the original commit " - "SHA's." + 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) - @staticmethod - def _delete_local_branch(branch_name: str): + 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 origin {branch_name};" + f"git push -d {self.github_fork} {branch_name};" ) IrisRelease.wait_for_done(message) @@ -311,7 +545,7 @@ def whats_news(self) -> WhatsNewRsts: return self.WhatsNewRsts( latest=latest, - release=whatsnew_dir / (self.strings.series[1:] + ".rst"), + release=whatsnew_dir / (self.version.series[1:] + ".rst"), index=whatsnew_dir / "index.rst", template=latest.with_suffix(".rst.template"), ) @@ -319,13 +553,13 @@ def whats_news(self) -> WhatsNewRsts: def finalise_whats_new(self): self.print("What's New finalisation ...") - working_branch = self.strings.branch + ".updates" + working_branch = self.version.branch + ".updates" self._delete_local_branch(working_branch) message = ( - f"Checkout a local branch from the official {self.strings.branch} " + f"Checkout a local branch from the official {self.version.branch} " f"branch.\n" - "git fetch upstream;\n" - f"git checkout upstream/{self.strings.branch} -b " + 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) @@ -348,9 +582,9 @@ def finalise_whats_new(self): self.print(f"What's New file path = {self.whats_news.release}") - if not self.release_type == self.ReleaseTypes.PATCH: + if not self.release_type is self.ReleaseTypes.PATCH: whatsnew_title = ( - f"{self.strings.series} ({datetime.today().strftime('%d %b %Y')}" + f"{self.version.series} ({datetime.today().strftime('%d %b %Y')}" ) if self.is_release_candidate: whatsnew_title += " [release candidate]" @@ -373,7 +607,7 @@ def finalise_whats_new(self): ) self.wait_for_done(message) - dropdown_title = f"\n{self.strings.series} Release Highlights\n" + dropdown_title = f"\n{self.version.series} Release Highlights\n" message = ( f"In {self.whats_news.release.name}: set the sphinx-design " f"dropdown title to:{dropdown_title}" @@ -382,7 +616,7 @@ def finalise_whats_new(self): 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" + f"reflection of what is new in {self.version.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 " @@ -417,15 +651,15 @@ def finalise_whats_new(self): "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};" + 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.strings.branch, + base_branch=self.version.branch, head_branch=working_branch, ) message = ( @@ -445,8 +679,8 @@ def cut_release(self): 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 " + 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." ) @@ -468,8 +702,8 @@ def cut_release(self): 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}" + f"conda install -c conda-forge/label/rc_iris iris={self.version.public}\n" + f"pip install scitools-iris=={self.version.public}" ) self.wait_for_done(message) @@ -480,7 +714,10 @@ def cut_release(self): self.wait_for_done(message) else: - message = "Tick the box to set this as the latest release." + 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 !" @@ -508,43 +745,43 @@ def check_rtd(self): ) self.wait_for_done(message) - message = f"Set {self.git_tag} to Active, un-Hidden." + message = f"Set {self.version} to Active, un-Hidden." self.wait_for_done(message) - message = f"Set {self.strings.branch} to Active, Hidden." + message = f"Set {self.version.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 " + 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.git_tag} " + 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.git_tag}'\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.git_tag} is available in RTD's version switcher.\n\n" - "NOTE: the docs can take several minutes to finish building." + f"- {self.version} is available in RTD's version switcher.\n" ) - if not self.is_release_candidate: + if not self.is_release_candidate and self.is_latest_tag: message += ( "- Selecting 'stable' in the version switcher also brings up " - f"the {self.git_tag} render." + 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.strings.branch} " + 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.strings.branch} .\n" - f"- {self.strings.branch} is NOT available in RTD's version switcher.\n\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) @@ -555,31 +792,33 @@ def check_pypi(self): message = ( "Confirm that the following URL is correctly populated:\n" - f"https://pypi.org/project/scitools-iris/{self.strings.release}/" + f"https://pypi.org/project/scitools-iris/{self.version.public}/" ) 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_latest_tag: + message = ( + f"Confirm that {self.version.public} 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"Confirm that {self.version.public} is marked as a " f"pre-release on this page:\n" "https://pypi.org/project/scitools-iris/#history" ) - else: + self.wait_for_done(message) + elif self.is_latest_tag: message = ( - f"Confirm that {self.strings.release} is the tag shown on the " + f"Confirm that {self.version.public} is the tag shown on the " "scitools-iris PyPI homepage:\n" "https://pypi.org/project/scitools-iris/" ) - self.wait_for_done(message) + self.wait_for_done(message) - def validate(sha256_string: str) -> str: + def validate(sha256_string: str) -> str | None: valid = True try: _ = int(sha256_string, 16) @@ -595,7 +834,7 @@ def validate(sha256_string: str) -> str: 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" + f"https://pypi.org/project/scitools-iris/{self.version.public}#files\n" ) self.set_value_from_input( key="sha256", @@ -608,7 +847,7 @@ def validate(sha256_string: str) -> str: "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" + f"pip install scitools-iris=={self.version.public};\n" 'python -c "import iris; print(iris.__version__)";\n' "conda deactivate;\n" "conda remove -n tmp_iris --all;\n" @@ -707,14 +946,14 @@ def update_conda_forge(self): "release:\n" "git fetch upstream;\n" f"git checkout upstream/{upstream_branch} -b " - f"{self.git_tag};\n" + 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.strings.release}\n" + f"{self.version.public}\n" f"- The sha256 hash: {self.sha256}\n" "- Requirements: align the packages and pins with those in the " "Iris repo\n" @@ -723,6 +962,12 @@ def update_conda_forge(self): "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.series} releases)." + ) self.wait_for_done(message) # TODO: automate @@ -731,8 +976,8 @@ def update_conda_forge(self): "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};" + f'git commit -m "Recipe updates for {self.version} .";\n' + f"git push -u origin {self.version};" ) self.wait_for_done(message) @@ -740,11 +985,11 @@ def update_conda_forge(self): base_org="conda-forge", base_repo="iris-feedstock", base_branch=upstream_branch, - head_branch=self.git_tag, + head_branch=f"{self.version}", ) if self.is_release_candidate: - readme_url = f"https://github.com/{self.github_user}/iris-feedstock/blob/{self.git_tag}/README.md" + readme_url = f"https://github.com/{self.github_user}/iris-feedstock/blob/{self.version}/README.md" rc_evidence = ( "\n\nConfirm that conda-forge knows your changes are for the " "release candidate channel by checking the below README file. " @@ -770,14 +1015,14 @@ def update_conda_forge(self): self.wait_for_done(message) message = ( - f"Confirm that {self.strings.release} appears in this list:\n" + f"Confirm that {self.version.public} appears in this list:\n" "https://anaconda.org/conda-forge/iris/files" ) self.wait_for_done(message) - if not self.is_release_candidate: + if not self.is_release_candidate and self.is_latest_tag: message = ( - f"Confirm that {self.strings.release} is displayed on this " + f"Confirm that {self.version.public} is displayed on this " "page as the latest available:\n" "https://anaconda.org/conda-forge/iris" ) @@ -795,14 +1040,14 @@ def update_conda_forge(self): "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};" + f"conda search{channel_command}iris=={self.version.public};" ) 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" + f"{self.version.public};\n" "conda activate tmp_iris;\n" 'python -c "import iris; print(iris.__version__)";\n' "conda deactivate;\n" @@ -810,18 +1055,30 @@ def update_conda_forge(self): ) self.wait_for_done(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) + 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" + 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.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" + f"https://scitools-iris.readthedocs.io/en/{self.version}/\n" + f"https://pypi.org/project/scitools-iris/{self.version.public}/\n" + f"https://anaconda.org/conda-forge/iris?version={self.version.public}\n" ) self.wait_for_done(message) @@ -842,7 +1099,7 @@ def update_links(self): message = ( f"Comment on {discussion_url} to notify anyone watching that " - f"{self.git_tag} has been released." + f"{self.version} has been released." ) self.wait_for_done(message) @@ -857,7 +1114,7 @@ def bluesky_announce(self): if not self.first_in_series: message += ( f"Consider replying within an existing " - f"{self.strings.series} " + f"{self.version.series} " "announcement thread, if appropriate." ) self.wait_for_done(message) @@ -870,29 +1127,56 @@ def merge_back(self): "preserve the commit SHA's." ) - if self.first_in_series: - # TODO: automate + def next_series_patch() -> IrisVersion: + tagged_versions = self._get_tagged_versions() + series_all = sorted(set(v.series for v in tagged_versions)) + try: + next_series = series_all[series_all.index(self.version.series) + 1] + except (IndexError, ValueError): + message = f"Error finding next series after {self.version.series} ." + raise RuntimeError(message) - 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};" + series_latest = max( + v for v in tagged_versions if v.series == next_series + ) + return IrisVersion( + f"{series_latest.major}.{series_latest.minor}.{series_latest.micro + 1}" ) - self.wait_for_done(message) + if self.more_patches_after_this_one: 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";' + "More series need patching. Merge into the next series' branch ..." ) - self.wait_for_done(message) + 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 upstream/main {self.whats_news.template.absolute()};\n" + f"git checkout {self.github_scitools}/main {self.whats_news.template.absolute()};\n" ) self.wait_for_done(message) @@ -923,44 +1207,70 @@ def merge_back(self): "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};" + 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.\n" - "Make sure the documentation is previewed during this process.\n" - f"{merge_commit}" + 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.state | dict( + git_tag=str(next_patch), + sha256=None, + latest_complete_step=NextPatch.get_steps().index(NextPatch.validate) - 1, ) - self.wait_for_done(message) + next_patch_script = NextPatch(**next_patch_kwargs) + next_patch_script.save() - 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}" + new_command = ( + f"python {Path(__file__).absolute()} load " + f"{next_patch_script._file_path}" ) - 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." + "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 != self.ReleaseTypes.PATCH and not self.is_release_candidate: + if self.release_type is not self.ReleaseTypes.PATCH and not self.is_release_candidate: self.print("Prep next release ...") message = ( From 41ee980fb613f9234bf757a658b5598ef3b96b0b Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 28 Aug 2025 14:13:10 +0100 Subject: [PATCH 03/26] Pre-commit compliance. --- .pre-commit-config.yaml | 3 ++- tools/release_do_nothing.py | 51 ++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fef9811a07..37429735d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,8 @@ files: | setup\.py| docs\/.+\.py| lib\/.+\.py| - benchmarks\/.+\.py + benchmarks\/.+\.py| + tools\/.+\.py ) minimum_pre_commit_version: 1.21.0 diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index da22a84848..040b4f6219 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -50,10 +50,10 @@ class ReleaseTypes(IntEnum): github_scitools: str = "upstream" github_fork: str = "origin" - github_user: str = None - patch_min_max_tag: tuple[str, str] = None - git_tag: str = None # v1.2.3rc0 - sha256: str = None + 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: @@ -157,8 +157,12 @@ def _git_ls_remote_tags(self) -> str: 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 = [ - tag_regex.search(line).group(0) for line in scitools_tags_raw + search.group(0) for search in scitools_tags_searched + if search is not None ] def get_version(tag: str) -> IrisVersion | None: @@ -179,6 +183,7 @@ def get_version(tag: str) -> IrisVersion | None: def get_release_tag(self): def validate(input_tag: str) -> str | None: + result = None try: version = IrisVersion(input_tag) except InvalidVersion as err: @@ -193,7 +198,8 @@ def validate(input_tag: str) -> str | None: "Please try again ..." ) else: - return input_tag # v1.2.3rc0 + result= input_tag # v1.2.3rc0 + return result message = ( "Input the release tag you are creating today, including any " @@ -313,10 +319,11 @@ def patch_min_max(self) -> tuple[IrisVersion, IrisVersion] | None: @property def more_patches_after_this_one(self) -> bool: - if self.release_type is self.ReleaseTypes.PATCH: - return self.version < self.patch_min_max[1] - else: - return False + return( + self.release_type is self.ReleaseTypes.PATCH and + self.patch_min_max is not None and + self.version < self.patch_min_max[1] + ) def apply_patches(self): if self.release_type is self.ReleaseTypes.PATCH: @@ -338,7 +345,7 @@ def apply_patches(self): case "": message = ( f"Propose the patch change(s) against {self.version.branch} via " - f"pull request(s). Targetting {self.version.branch} will " + f"pull request(s). Targeting {self.version.branch} will " "avoid later Git conflicts." ) case _: @@ -346,7 +353,7 @@ def apply_patches(self): "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 targetting the patch " + "release process; in future consider targeting the patch " "change(s) directly at the release branch." ) @@ -410,7 +417,7 @@ def validate(self) -> None: f"First release in {self.version.series} series?": self.first_in_series, "Current latest Iris release": max(self._get_tagged_versions()), } - if self.release_type is self.ReleaseTypes.PATCH: + if self.release_type is self.ReleaseTypes.PATCH and self.patch_min_max is not None: status["Series being patched"] = ( f"{self.patch_min_max[0].series} to {self.patch_min_max[1].series}" ) @@ -533,7 +540,7 @@ def _delete_local_branch(self, branch_name: str): class WhatsNewRsts(typing.NamedTuple): latest: Path release: Path - index: Path + index_: Path template: Path @property @@ -574,7 +581,7 @@ def finalise_whats_new(self): self.wait_for_done(message) message = ( - f"In {self.whats_news.index.absolute()}:\n" + f"In {self.whats_news.index_.absolute()}:\n" f"Replace references to {self.whats_news.latest.name} with " f"{self.whats_news.release.name}" ) @@ -650,8 +657,8 @@ def finalise_whats_new(self): 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 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) @@ -828,8 +835,10 @@ def validate(sha256_string: str) -> str | None: if not valid: self.report_problem("Invalid SHA256 hash. Please try again ...") + result = None else: - return sha256_string + result = sha256_string + return result message = ( f"Visit the below and click `view hashes` for the Source Distribution" @@ -1196,7 +1205,7 @@ def next_series_patch() -> IrisVersion: self.wait_for_done(message) message = ( - f"In {self.whats_news.index.absolute()}:\n" + 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} ." @@ -1205,8 +1214,8 @@ def next_series_patch() -> IrisVersion: 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 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) From 604effdcfd52339113fd2ac9d8c1ffc859b12ed6 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 28 Aug 2025 14:15:02 +0100 Subject: [PATCH 04/26] test_release_do_nothing. --- noxfile.py | 1 + tools/test_release_do_nothing.py | 143 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tools/test_release_do_nothing.py diff --git a/noxfile.py b/noxfile.py index 415e4fc3d5..a8ce41c236 100644 --- a/noxfile.py +++ b/noxfile.py @@ -186,6 +186,7 @@ def tests(session: nox.sessions.Session): "-n", "auto", "lib/iris/tests", + "tools", ] if "-c" in session.posargs or "--coverage" in session.posargs: run_args[-1:-1] = ["--cov=lib/iris", "--cov-report=xml"] diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py new file mode 100644 index 0000000000..0ad0332166 --- /dev/null +++ b/tools/test_release_do_nothing.py @@ -0,0 +1,143 @@ +# 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. +"""Tests for the ``release_do_nothing.py`` file.""" + +from typing import NamedTuple + +import pytest + +import nothing +from release_do_nothing import IrisRelease + + +@pytest.fixture(autouse=True) +def mock_fast_print(mocker) -> None: + """Prevent the mod:`nothing` print methods from sleeping.""" + mocker.patch.object(nothing, "sleep", return_value=None) + + +@pytest.fixture(autouse=True) +def mock_git_commands(mocker) -> None: + """Detach testing from reliance on .git directory.""" + mocker.patch.object( + IrisRelease, + "_git_remote_v", + return_value="origin\nupstream\nfoo\n", + ) + + mocker.patch.object( + IrisRelease, + "_git_remote_get_url", + return_value="git@github.com:foo/iris.git", + ) + + mocker.patch.object( + IrisRelease, + "_git_ls_remote_tags", + # TODO: make this as minimal as possible while still enabling the tests. + return_value=( + "abcd1234 refs/tags/1.0.0\n" + "abcd1235 refs/tags/1.0.1\n" + "abcd1236 refs/tags/1.0.2\n" + "abcd1237 refs/tags/1.1.0rc1\n" + "abcd1238 refs/tags/1.1.0rc2\n" + "abcd1239 refs/tags/1.1.0\n" + "abcd1240 refs/tags/1.2.0rc0\n" + ), + ) + + +def mock_input(mocker, input_str: str) -> None: + """Mock :func:`input` to return a specific value.""" + mocker.patch("builtins.input", return_value=input_str) + + +class TestValidate: + """Tests for the :func:`release_do_nothing.validate` function.""" + @pytest.fixture(autouse=True) + def _setup(self) -> None: + self.instance = IrisRelease( + _dry_run=True, + latest_complete_step=IrisRelease.get_steps().index(IrisRelease.validate) - 1, + github_user="user", + patch_min_max_tag=("8.0.0", "9.0.0") + ) + + class Case(NamedTuple): + git_tag: str + match: str + + @pytest.fixture(params=[ + pytest.param( + Case("9.1.dev0", "development release.*cannot handle"), + id="dev release", + ), + pytest.param( + Case("9.1.post0", "post release.*cannot handle"), + id="post release", + ), + pytest.param( + Case("9.1.alpha0", "release candidate.*got 'a'"), + id="pre-release non-rc", + ), + pytest.param( + Case("9.1.1rc0", "PATCH release AND a release candidate.*cannot handle"), + id="patch release rc", + ), + pytest.param( + Case("9.1.1", "No previous releases.*cannot handle a PATCH"), + id="first in series patch", + ), + ]) + def unhandled_cases(self, request) -> Case: + case = request.param + self.instance.git_tag = case.git_tag + return case + + def test_unhandled_cases(self, unhandled_cases): + case = unhandled_cases + with pytest.raises(RuntimeError, match=case.match): + self.instance.validate() + pass + + @pytest.fixture + def first_in_series_not_rc(self) -> None: + self.instance.git_tag = "9.1.0" + + def test_first_in_series_not_rc_message(self, first_in_series_not_rc, capfd, mocker): + mock_input(mocker, "y") + self.instance.validate() + out, err = capfd.readouterr() + assert "No previous releases" in out + assert "expected to be a release candidate" in out + assert "sure you want to continue" in out + + def test_first_in_series_not_rc_exit(self, first_in_series_not_rc, mocker): + mock_input(mocker, "n") + with pytest.raises(SystemExit): + self.instance.validate() + + def test_first_in_series_not_rc_continue(self, first_in_series_not_rc, mocker): + mock_input(mocker, "y") + self.instance.validate() + + # Not an exhaustive list, just the inverse of the unhandled cases. + @pytest.fixture(params=[ + pytest.param("9.0.0rc0", id="major release RC"), + pytest.param("9.1.0rc0", id="minor release RC"), + pytest.param("1.2.0", id="minor release existing series"), + pytest.param("1.1.1", id="patch release existing series"), + pytest.param("9.1.0", id="first in series not RC"), + ]) + def handled_cases(self, request) -> None: + self.instance.git_tag = request.param + + def test_handled_cases(self, handled_cases, mocker): + message = "Confirm that the details above are correct" + mock_input(mocker, "y") + mocked = mocker.patch.object(IrisRelease, "wait_for_done") + self.instance.validate() + mocked.assert_called_once() + assert message in mocked.call_args[0][0] From f794bb60c080d89316e9b51e9f3f86a8b719d248 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 28 Aug 2025 14:16:01 +0100 Subject: [PATCH 05/26] Correct use of WhatsNewRsts.index_ . --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 040b4f6219..61caba0b21 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -553,7 +553,7 @@ def whats_news(self) -> WhatsNewRsts: return self.WhatsNewRsts( latest=latest, release=whatsnew_dir / (self.version.series[1:] + ".rst"), - index=whatsnew_dir / "index.rst", + index_=whatsnew_dir / "index.rst", template=latest.with_suffix(".rst.template"), ) From c08b94573adcf8db778d6b4ccb28384a55ac6d86 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 28 Aug 2025 14:17:50 +0100 Subject: [PATCH 06/26] Correct phrasing for PyPI SHA256. --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 61caba0b21..26b09ccf89 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -841,7 +841,7 @@ def validate(sha256_string: str) -> str | None: return result message = ( - f"Visit the below and click `view hashes` for the Source Distribution" + f"Visit the below and click `view details` for the Source Distribution" f"(`.tar.gz`):\n" f"https://pypi.org/project/scitools-iris/{self.version.public}#files\n" ) From bb248bc918e86cd1d2f0589c76973ce52f25876e Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 28 Aug 2025 14:25:50 +0100 Subject: [PATCH 07/26] Series to-do. --- tools/release_do_nothing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 26b09ccf89..4047ba8d88 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -35,6 +35,8 @@ def __str__(self): @property def series(self) -> str: + # TODO: find an alternative word which is meaningful to everyone + # while not being ambiguous. return f"v{self.major}.{self.minor}" @property From e51ec42e9c7b2f6992e7424d7615b7bf8e3e2cde Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Fri, 30 Jan 2026 19:13:44 +0000 Subject: [PATCH 08/26] Progress on test_release_do_nothing. --- tools/release_do_nothing.py | 10 +- tools/test_release_do_nothing.py | 401 +++++++++++++++++++++++++++++-- 2 files changed, 390 insertions(+), 21 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 4047ba8d88..29dae2100e 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -141,13 +141,15 @@ def number_to_fork(input_number: str) -> str | None: ) fork_url = self._git_remote_get_url() - self.github_user = re.search( + search_result = re.search( r"(?<=github\.com[:/])([a-zA-Z0-9-]+)(?=/)", fork_url, - ).group(0) - if self.github_user is None: + ) + 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. @@ -324,7 +326,7 @@ 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 < self.patch_min_max[1] + self.version.series < self.patch_min_max[1].series ) def apply_patches(self): diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 0ad0332166..c3d474852e 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -4,12 +4,14 @@ # See LICENSE in the root of the repository for full licensing details. """Tests for the ``release_do_nothing.py`` file.""" +from pathlib import Path from typing import NamedTuple import pytest +from pytest_mock import MockType import nothing -from release_do_nothing import IrisRelease +from release_do_nothing import IrisRelease, IrisVersion @pytest.fixture(autouse=True) @@ -21,16 +23,24 @@ def mock_fast_print(mocker) -> None: @pytest.fixture(autouse=True) def mock_git_commands(mocker) -> None: """Detach testing from reliance on .git directory.""" + return_value = ( + "origin git@github.com:myself/iris.git (fetch)\n" + "origin git@github.com:myself/iris.git (push)\n" + "upstream git@github.com:SciTools/iris.git (fetch)\n" + "upstream no_push (push)\n" + "foo git@github.com:foo/iris.git (fetch)\n" + "foo git@github.com:foo/iris.git (push)\n" + ) mocker.patch.object( IrisRelease, "_git_remote_v", - return_value="origin\nupstream\nfoo\n", + return_value=return_value, ) mocker.patch.object( IrisRelease, "_git_remote_get_url", - return_value="git@github.com:foo/iris.git", + return_value="git@github.com:myself/iris.git", ) mocker.patch.object( @@ -49,18 +59,314 @@ def mock_git_commands(mocker) -> None: ) -def mock_input(mocker, input_str: str) -> None: - """Mock :func:`input` to return a specific value.""" - mocker.patch("builtins.input", return_value=input_str) +@pytest.fixture +def mock_wait_for_done(mocker) -> MockType: + """Mock :meth:`IrisRelease.wait_for_done` to not wait, and to count calls.""" + return mocker.patch.object(IrisRelease, "wait_for_done", return_value=None) + + +@pytest.fixture +def mock_report_problem(mocker) -> MockType: + return mocker.patch.object(IrisRelease, "report_problem") + + +def mock_inputs(mocker, *inputs: str) -> None: + """Mock :func:`input` to return chosen values, specified in a sequence.""" + mocker.patch("builtins.input", side_effect=inputs) + + +class TestIrisVersion: + """Tests for the :class:`IrisVersion` class.""" + @pytest.fixture(params=["9.0.0", "9.0.1", "9.1.0"], autouse=True) + def _setup(self, request): + self.version = IrisVersion(request.param) + self.input_str = request.param + + def test_str(self): + expecteds = {"9.0.0": "v9.0.0", "9.0.1": "v9.0.1", "9.1.0": "v9.1.0"} + assert str(self.version) == expecteds[self.input_str] + + def test_series(self): + expecteds = {"9.0.0": "v9.0", "9.0.1": "v9.0", "9.1.0": "v9.1"} + assert self.version.series == expecteds[self.input_str] + + def test_branch(self): + expecteds = {"9.0.0": "v9.0.x", "9.0.1": "v9.0.x", "9.1.0": "v9.1.x"} + assert self.version.branch == expecteds[self.input_str] + + +class TestProperties: + """Tests for the properties of the :class:`IrisRelease` class.""" + @pytest.fixture(autouse=True) + def _setup(self) -> None: + self.instance = IrisRelease( + _dry_run=True, + latest_complete_step=len(IrisRelease.get_steps()) - 1, + github_scitools="foo", + github_fork="bar", + github_user="user", + patch_min_max_tag=("8.0.0", "9.0.0"), + git_tag="9.1.1", + sha256="abcd1234", + ) + + def test_version(self): + assert self.instance.version == IrisVersion("9.1.1") + + @pytest.mark.parametrize("git_tag", ["0.0.1", "9.1.1"]) + def test_is_latest_tag(self, git_tag): + expecteds = {"0.0.1": False, "9.1.1": True} + expected = expecteds[git_tag] + self.instance.git_tag = git_tag + assert self.instance.is_latest_tag is expected + + @pytest.mark.parametrize("git_tag", ["9.0.0", "9.1.0", "9.1.1"]) + def test_release_type(self, git_tag): + expecteds = { + "9.0.0": IrisRelease.ReleaseTypes.MAJOR, + "9.1.0": IrisRelease.ReleaseTypes.MINOR, + "9.1.1": IrisRelease.ReleaseTypes.PATCH, + } + expected = expecteds[git_tag] + self.instance.git_tag = git_tag + assert self.instance.release_type is expected + + @pytest.mark.parametrize("git_tag", ["9.1.0rc1", "9.1.0"]) + def test_is_release_candidate(self, git_tag): + expecteds = {"9.1.0rc1": True, "9.1.0": False} + expected = expecteds[git_tag] + self.instance.git_tag = git_tag + assert self.instance.is_release_candidate is expected + + @pytest.mark.parametrize("git_tag", ["9.1.0", "1.1.1"]) + def test_first_in_series(self, git_tag): + expecteds = {"9.1.0": True, "1.1.1": False} + expected = expecteds[git_tag] + self.instance.git_tag = git_tag + assert self.instance.first_in_series is expected + + def test_patch_min_max(self): + assert self.instance.patch_min_max == ( + IrisVersion("8.0.0"), + IrisVersion("9.0.0"), + ) + with pytest.raises(AssertionError, match="^$"): + self.instance.patch_min_max_tag = ("9.0.0",) + _ = self.instance.patch_min_max + + @pytest.mark.parametrize("git_tag", ["8.1.0", "8.1.1", "9.0.1", "9.1.1"]) + def test_more_patches_after_this_one(self, git_tag): + expecteds = { + "8.1.0": False, # Not a PATCH release. + "8.1.1": True, # 9.0.0 still to patch. + "9.0.1": False, # Last PATCH in series. + "9.1.1": False, # Beyond max series. + } + expected = expecteds[git_tag] + self.instance.git_tag = git_tag + assert self.instance.more_patches_after_this_one is expected + + + def test_whats_news(self): + whatsnew_dir = Path(__file__).parents[1] / "docs" / "src" / "whatsnew" + expected = IrisRelease.WhatsNewRsts( + latest=whatsnew_dir / "latest.rst", + release=whatsnew_dir / "9.1.rst", + index_=whatsnew_dir / "index.rst", + template=whatsnew_dir / "latest.rst.template", + ) + assert self.instance.whats_news == expected + + +class TestAnalyseRemotes: + """Tests for the :meth:`IrisRelease.analyse_remotes` method.""" + @pytest.fixture(autouse=True) + def _setup(self) -> None: + self.instance = IrisRelease( + _dry_run=True, + ) + + def test_github_scitools(self, mocker): + # Developer is asked to select their Iris fork from a list. + # See mock_git_commands() + mock_inputs(mocker, "0") + self.instance.analyse_remotes() + assert self.instance.github_scitools == "upstream" + + def test_no_forks(self, mocker): + # The only remote is 'upstream', so error. + return_value = ( + "upstream git@github.com:SciTools/iris.git (fetch)\n" + "upstream no_push (push)\n" + ) + mocker.patch.object( + IrisRelease, + "_git_remote_v", + return_value=return_value, + ) + with pytest.raises(AssertionError, match="^$"): + self.instance.analyse_remotes() + + def test_choose_fork(self, mocker): + # Developer chooses a fork other than `myself`. + mock_inputs(mocker, "1") + self.instance.analyse_remotes() + assert self.instance.github_fork == "foo" + + def test_choose_fork_invalid(self, mocker, mock_report_problem): + # Mock an invalid input followed by a valid one. + mock_inputs(mocker, "99", "1") + self.instance.analyse_remotes() + mock_report_problem.assert_called_once_with( + "Invalid number. Please try again ..." + ) + + def test_derive_username(self, mocker): + mock_inputs(mocker, "0") + self.instance.analyse_remotes() + assert self.instance.github_user == "myself" + + def test_error_deriving_username(self, mocker): + mocker.patch.object( + IrisRelease, + "_git_remote_get_url", + return_value="bad_url", + ) + mock_inputs(mocker, "0") + with pytest.raises(RuntimeError, match="Error deriving GitHub username"): + self.instance.analyse_remotes() + + def test_default_fork_preserved(self, mocker): + self.instance.github_fork = "bar" + mock_inputs(mocker, "") + self.instance.analyse_remotes() + assert self.instance.github_fork == "bar" + + +class TestGetReleaseTag: + """Tests for the :meth:`IrisRelease.get_release_tag` method.""" + @pytest.fixture(autouse=True) + def _setup(self) -> None: + self.instance = IrisRelease( + _dry_run=True, + github_scitools="upstream", + github_fork="origin", + github_user="myself", + ) + + def test_valid_tag(self, mocker): + # User inputs a valid, non-existing tag + mock_inputs(mocker, "v9.2.0") + self.instance.get_release_tag() + assert self.instance.git_tag == "v9.2.0" + + def test_existing_tag(self, mocker, mock_report_problem): + # User tries an existing tag, then provides a valid one + mock_inputs(mocker, "v1.1.0", "v9.2.0") + self.instance.get_release_tag() + mock_report_problem.assert_called_once_with( + "Version v1.1.0 already exists as a git tag. Please try again ..." + ) + assert self.instance.git_tag == "v9.2.0" + + def test_invalid_version_format(self, mocker, mock_report_problem): + # User inputs invalid version format, then valid one + mock_inputs(mocker, "not-a-version", "v9.2.0") + self.instance.get_release_tag() + assert mock_report_problem.call_count == 1 + (call,) = mock_report_problem.call_args_list + (message,) = call.args + assert "Packaging error" in message + assert "Please try again" in message + assert self.instance.git_tag == "v9.2.0" + + def test_default_value_preserved(self, mocker): + # When loading from saved state, existing git_tag should be offered as default + self.instance.git_tag = "v9.2.0" + mock_inputs(mocker, "") # User accepts default + self.instance.get_release_tag() + assert self.instance.git_tag == "v9.2.0" + + +class TestGetAllPatches: + """Tests for the :meth:`IrisRelease.get_all_patches` method.""" + @pytest.fixture(autouse=True) + def _setup(self) -> None: + self.instance = IrisRelease( + _dry_run=True, + github_scitools="upstream", + github_fork="origin", + github_user="myself", + git_tag="v1.1.1", + ) + + def test_not_patch_release(self): + # Non-PATCH releases skip this step + self.instance.git_tag = "v1.3.0" + self.instance.get_all_patches() + assert self.instance.patch_min_max_tag is None + + def test_patch_single_series(self, mocker): + # PATCH release, user doesn't want to patch multiple series + mock_inputs(mocker, "1,1") + self.instance.get_all_patches() + assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.1.1") + + def test_patch_multiple_series(self, mocker): + # User selects a range of series to patch + mock_inputs(mocker, "1,2") + self.instance.get_all_patches() + assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.2.1") + assert self.instance.git_tag == "v1.1.1" + + def test_invalid_format(self, mocker, mock_report_problem): + # User inputs invalid format, then valid input + mock_inputs(mocker, "not-numbers", "1,2") + self.instance.get_all_patches() + mock_report_problem.assert_called_once_with( + "Invalid input, expected two integers comma-separated. " + "Please try again ..." + ) + assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.2.1") + assert self.instance.git_tag == "v1.1.1" + + def test_invalid_numbers(self, mocker, mock_report_problem): + # User inputs out-of-range numbers, then valid input + mock_inputs(mocker, "99,100", "1,2") + self.instance.get_all_patches() + mock_report_problem.assert_called_once_with( + "Invalid numbers. Please try again ..." + ) + assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.2.1") + assert self.instance.git_tag == "v1.1.1" + + def test_starts_with_earlier_patch(self, mocker): + # When patch_min is earlier than current git_tag, git_tag is updated + mock_inputs(mocker, "0,2") + self.instance.get_all_patches() + # TODO: assert for message. + assert self.instance.git_tag == "v1.0.3" + assert self.instance.patch_min_max_tag == ("v1.0.3", "v1.2.1") + + def test_default_value_preserved(self, mocker): + # When loading from saved state, existing patch_min_max_tag should work + self.instance.patch_min_max_tag = ("v1.0.3", "v1.2.1") + mock_inputs(mocker, "") + self.instance.get_all_patches() + assert self.instance.patch_min_max_tag == ("v1.0.3", "v1.2.1") + + +class TestApplyPatches: + """Tests for the :meth:`IrisRelease.apply_patches` method.""" + pass class TestValidate: - """Tests for the :func:`release_do_nothing.validate` function.""" + """Tests for the :meth:`IrisRelease.validate` method.""" @pytest.fixture(autouse=True) def _setup(self) -> None: self.instance = IrisRelease( _dry_run=True, - latest_complete_step=IrisRelease.get_steps().index(IrisRelease.validate) - 1, github_user="user", patch_min_max_tag=("8.0.0", "9.0.0") ) @@ -107,7 +413,7 @@ def first_in_series_not_rc(self) -> None: self.instance.git_tag = "9.1.0" def test_first_in_series_not_rc_message(self, first_in_series_not_rc, capfd, mocker): - mock_input(mocker, "y") + mock_inputs(mocker, "y", "y") self.instance.validate() out, err = capfd.readouterr() assert "No previous releases" in out @@ -115,12 +421,12 @@ def test_first_in_series_not_rc_message(self, first_in_series_not_rc, capfd, moc assert "sure you want to continue" in out def test_first_in_series_not_rc_exit(self, first_in_series_not_rc, mocker): - mock_input(mocker, "n") + mock_inputs(mocker, "n") with pytest.raises(SystemExit): self.instance.validate() def test_first_in_series_not_rc_continue(self, first_in_series_not_rc, mocker): - mock_input(mocker, "y") + mock_inputs(mocker, "y", "y") self.instance.validate() # Not an exhaustive list, just the inverse of the unhandled cases. @@ -134,10 +440,71 @@ def test_first_in_series_not_rc_continue(self, first_in_series_not_rc, mocker): def handled_cases(self, request) -> None: self.instance.git_tag = request.param - def test_handled_cases(self, handled_cases, mocker): - message = "Confirm that the details above are correct" - mock_input(mocker, "y") - mocked = mocker.patch.object(IrisRelease, "wait_for_done") + def test_handled_cases(self, handled_cases, mocker, mock_wait_for_done): + sub_message = "Confirm that the details above are correct" + mock_inputs(mocker, "y") self.instance.validate() - mocked.assert_called_once() - assert message in mocked.call_args[0][0] + mock_wait_for_done.assert_called_once() + (call,) = mock_wait_for_done.call_args_list + (message,) = call.args + assert sub_message in message + + +class TestUpdateStandardNames: + """Tests for the :meth:`IrisRelease.update_standard_names` method.""" + pass + + +class TestCheckDeprecations: + """Tests for the :meth:`IrisRelease.check_deprecations` method.""" + pass + + +class TestCreateReleaseBranch: + """Tests for the :meth:`IrisRelease.create_release_branch` method.""" + pass + + +class TestFinaliseWhatsNew: + """Tests for the :meth:`IrisRelease.finalise_whats_new` method.""" + pass + + +class TestCutRelease: + """Tests for the :meth:`IrisRelease.cut_release` method.""" + pass + + +class TestCheckRtd: + """Tests for the :meth:`IrisRelease.check_rtd` method.""" + pass + + +class TestCheckPyPI: + """Tests for the :meth:`IrisRelease.check_pypi` method.""" + pass + + +class TestUpdateCondaForge: + """Tests for the :meth:`IrisRelease.update_conda_forge` method.""" + pass + + +class TestUpdateLinks: + """Tests for the :meth:`IrisRelease.update_links` method.""" + pass + + +class TestBlueskyAnnounce: + """Tests for the :meth:`IrisRelease.bluesky_announce` method.""" + pass + + +class TestMergeBack: + """Tests for the :meth:`IrisRelease.merge_back` method.""" + pass + + +class TestNextRelease: + """Tests for the :meth:`IrisRelease.next_release` method.""" + pass From 35ce398f4cefad829fad8fdc850f03ef5b8d2ee1 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Sat, 28 Mar 2026 10:09:49 +0000 Subject: [PATCH 09/26] Finished most tests. --- tools/release_do_nothing.py | 2 +- tools/test_release_do_nothing.py | 750 ++++++++++++++++++++++++++----- 2 files changed, 647 insertions(+), 105 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 29dae2100e..899e3cb5ee 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -515,7 +515,7 @@ def create_release_branch(self): if self.first_in_series: message = ( - "Visit https://github.com/SciTools/iris and create the" + "Visit https://github.com/SciTools/iris and create the " f"``{self.version.branch}`` release branch from ``main``." ) self.wait_for_done(message) diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index c3d474852e..d46f306947 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -3,9 +3,10 @@ # 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. """Tests for the ``release_do_nothing.py`` file.""" - +import enum +from datetime import datetime from pathlib import Path -from typing import NamedTuple +from typing import Any, NamedTuple import pytest from pytest_mock import MockType @@ -21,41 +22,44 @@ def mock_fast_print(mocker) -> None: @pytest.fixture(autouse=True) -def mock_git_commands(mocker) -> None: - """Detach testing from reliance on .git directory.""" - return_value = ( - "origin git@github.com:myself/iris.git (fetch)\n" - "origin git@github.com:myself/iris.git (push)\n" - "upstream git@github.com:SciTools/iris.git (fetch)\n" - "upstream no_push (push)\n" - "foo git@github.com:foo/iris.git (fetch)\n" - "foo git@github.com:foo/iris.git (push)\n" - ) - mocker.patch.object( +def mock_git_remote_v(mocker) -> MockType: + """Mock :meth:`IrisRelease._git_remote_v`. + + Assumes return_value will be overridden by any calling test (the default + empty string will always error downstream). + """ + return mocker.patch.object( IrisRelease, "_git_remote_v", - return_value=return_value, + return_value="", ) - mocker.patch.object( + +@pytest.fixture(autouse=True) +def mock_git_remote_get_url(mocker) -> MockType: + """Mock :meth:`IrisRelease._git_remote_get_url`. + + Assumes return_value will be overridden by any calling test (the default + empty string will always error downstream). + """ + return mocker.patch.object( IrisRelease, "_git_remote_get_url", - return_value="git@github.com:myself/iris.git", + return_value="", ) - mocker.patch.object( + +@pytest.fixture(autouse=True) +def mock_git_ls_remote_tags(mocker) -> MockType: + """Mock :meth:`IrisRelease._git_ls_remote_tags`. + + Assumes return_value will be overridden by any calling test (the default + empty string will always error downstream). + """ + return mocker.patch.object( IrisRelease, "_git_ls_remote_tags", - # TODO: make this as minimal as possible while still enabling the tests. - return_value=( - "abcd1234 refs/tags/1.0.0\n" - "abcd1235 refs/tags/1.0.1\n" - "abcd1236 refs/tags/1.0.2\n" - "abcd1237 refs/tags/1.1.0rc1\n" - "abcd1238 refs/tags/1.1.0rc2\n" - "abcd1239 refs/tags/1.1.0\n" - "abcd1240 refs/tags/1.2.0rc0\n" - ), + return_value="", ) @@ -75,6 +79,13 @@ def mock_inputs(mocker, *inputs: str) -> None: mocker.patch("builtins.input", side_effect=inputs) +def assert_message_in_input(call: Any, expected: str) -> None: + assert hasattr(call, "args") and len(call.args) > 0 + message = call.args[0] + assert isinstance(message, str) + assert expected in message + + class TestIrisVersion: """Tests for the :class:`IrisVersion` class.""" @pytest.fixture(params=["9.0.0", "9.0.1", "9.1.0"], autouse=True) @@ -113,9 +124,12 @@ def _setup(self) -> None: def test_version(self): assert self.instance.version == IrisVersion("9.1.1") - @pytest.mark.parametrize("git_tag", ["0.0.1", "9.1.1"]) - def test_is_latest_tag(self, git_tag): - expecteds = {"0.0.1": False, "9.1.1": True} + @pytest.mark.parametrize("git_tag", ["1.1.0", "1.1.2"]) + def test_is_latest_tag(self, git_tag, mock_git_ls_remote_tags): + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/1.1.1\n" + ) + expecteds = {"1.1.0": False, "1.1.2": True} expected = expecteds[git_tag] self.instance.git_tag = git_tag assert self.instance.is_latest_tag is expected @@ -138,9 +152,13 @@ def test_is_release_candidate(self, git_tag): self.instance.git_tag = git_tag assert self.instance.is_release_candidate is expected - @pytest.mark.parametrize("git_tag", ["9.1.0", "1.1.1"]) - def test_first_in_series(self, git_tag): - expecteds = {"9.1.0": True, "1.1.1": False} + @pytest.mark.parametrize("git_tag", ["1.0.0", "1.0.1", "1.1.0"]) + def test_first_in_series(self, git_tag, mock_git_ls_remote_tags): + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/1.0.0\n" + "abcd1235 refs/tags/1.0.1\n" + ) + expecteds = {"1.0.0": False, "1.0.1": False, "1.1.0": True} expected = expecteds[git_tag] self.instance.git_tag = git_tag assert self.instance.first_in_series is expected @@ -181,29 +199,32 @@ def test_whats_news(self): class TestAnalyseRemotes: """Tests for the :meth:`IrisRelease.analyse_remotes` method.""" @pytest.fixture(autouse=True) - def _setup(self) -> None: - self.instance = IrisRelease( - _dry_run=True, + def _setup(self, mock_git_remote_get_url, mock_git_remote_v) -> None: + self.instance = IrisRelease(_dry_run=True) + mock_git_remote_get_url.return_value = "git@github.com:myself/iris.git" + mock_git_remote_v.return_value = ( + "origin git@github.com:myself/iris.git (fetch)\n" + "origin git@github.com:myself/iris.git (push)\n" + "upstream git@github.com:SciTools/iris.git (fetch)\n" + "upstream no_push (push)\n" + "foo git@github.com:foo/iris.git (fetch)\n" + "foo git@github.com:foo/iris.git (push)\n" ) def test_github_scitools(self, mocker): - # Developer is asked to select their Iris fork from a list. - # See mock_git_commands() + # The input is irrelevant to this test, we just need a valid input to + # get past that line so we can test the line that sets github_scitools. mock_inputs(mocker, "0") self.instance.analyse_remotes() assert self.instance.github_scitools == "upstream" - def test_no_forks(self, mocker): + def test_no_forks(self, mock_git_remote_v): # The only remote is 'upstream', so error. - return_value = ( + # (Also confirms that upstream has been successfully ignored). + mock_git_remote_v.return_value = ( "upstream git@github.com:SciTools/iris.git (fetch)\n" "upstream no_push (push)\n" ) - mocker.patch.object( - IrisRelease, - "_git_remote_v", - return_value=return_value, - ) with pytest.raises(AssertionError, match="^$"): self.instance.analyse_remotes() @@ -226,12 +247,8 @@ def test_derive_username(self, mocker): self.instance.analyse_remotes() assert self.instance.github_user == "myself" - def test_error_deriving_username(self, mocker): - mocker.patch.object( - IrisRelease, - "_git_remote_get_url", - return_value="bad_url", - ) + def test_error_deriving_username(self, mocker, mock_git_remote_get_url): + mock_git_remote_get_url.return_value = "bad_url" mock_inputs(mocker, "0") with pytest.raises(RuntimeError, match="Error deriving GitHub username"): self.instance.analyse_remotes() @@ -246,59 +263,59 @@ def test_default_fork_preserved(self, mocker): class TestGetReleaseTag: """Tests for the :meth:`IrisRelease.get_release_tag` method.""" @pytest.fixture(autouse=True) - def _setup(self) -> None: - self.instance = IrisRelease( - _dry_run=True, - github_scitools="upstream", - github_fork="origin", - github_user="myself", - ) + def _setup(self, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + mock_git_ls_remote_tags.return_value = "abcd1234 refs/tags/1.0.0" def test_valid_tag(self, mocker): # User inputs a valid, non-existing tag - mock_inputs(mocker, "v9.2.0") + mock_inputs(mocker, "v1.1.0") self.instance.get_release_tag() - assert self.instance.git_tag == "v9.2.0" + assert self.instance.git_tag == "v1.1.0" def test_existing_tag(self, mocker, mock_report_problem): # User tries an existing tag, then provides a valid one - mock_inputs(mocker, "v1.1.0", "v9.2.0") + mock_inputs(mocker, "v1.0.0", "v1.1.0") self.instance.get_release_tag() mock_report_problem.assert_called_once_with( - "Version v1.1.0 already exists as a git tag. Please try again ..." + "Version v1.0.0 already exists as a git tag. Please try again ..." ) - assert self.instance.git_tag == "v9.2.0" + assert self.instance.git_tag == "v1.1.0" def test_invalid_version_format(self, mocker, mock_report_problem): # User inputs invalid version format, then valid one - mock_inputs(mocker, "not-a-version", "v9.2.0") + mock_inputs(mocker, "not-a-version", "v1.1.0") self.instance.get_release_tag() assert mock_report_problem.call_count == 1 (call,) = mock_report_problem.call_args_list (message,) = call.args assert "Packaging error" in message assert "Please try again" in message - assert self.instance.git_tag == "v9.2.0" + assert self.instance.git_tag == "v1.1.0" def test_default_value_preserved(self, mocker): # When loading from saved state, existing git_tag should be offered as default - self.instance.git_tag = "v9.2.0" + self.instance.git_tag = "v1.1.0" mock_inputs(mocker, "") # User accepts default self.instance.get_release_tag() - assert self.instance.git_tag == "v9.2.0" + assert self.instance.git_tag == "v1.1.0" class TestGetAllPatches: """Tests for the :meth:`IrisRelease.get_all_patches` method.""" @pytest.fixture(autouse=True) - def _setup(self) -> None: + def _setup(self, mock_git_ls_remote_tags) -> None: self.instance = IrisRelease( _dry_run=True, - github_scitools="upstream", - github_fork="origin", - github_user="myself", git_tag="v1.1.1", ) + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1237 refs/tags/v1.1.0rc1\n" + "abcd1239 refs/tags/v1.1.0\n" + "abcd1240 refs/tags/v1.2.0\n" + ) def test_not_patch_release(self): # Non-PATCH releases skip this step @@ -340,36 +357,80 @@ def test_invalid_numbers(self, mocker, mock_report_problem): assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.2.1") assert self.instance.git_tag == "v1.1.1" - def test_starts_with_earlier_patch(self, mocker): + def test_starts_with_earlier_patch(self, mocker, capfd): # When patch_min is earlier than current git_tag, git_tag is updated mock_inputs(mocker, "0,2") self.instance.get_all_patches() - # TODO: assert for message. - assert self.instance.git_tag == "v1.0.3" - assert self.instance.patch_min_max_tag == ("v1.0.3", "v1.2.1") + out, err = capfd.readouterr() + assert "Starting with v1.0.2. (v1.1.1 will be covered in sequence)" in out + assert self.instance.git_tag == "v1.0.2" + assert self.instance.patch_min_max_tag == ("v1.0.2", "v1.2.1") def test_default_value_preserved(self, mocker): # When loading from saved state, existing patch_min_max_tag should work - self.instance.patch_min_max_tag = ("v1.0.3", "v1.2.1") + self.instance.patch_min_max_tag = ("v1.0.2", "v1.2.1") mock_inputs(mocker, "") self.instance.get_all_patches() - assert self.instance.patch_min_max_tag == ("v1.0.3", "v1.2.1") + assert self.instance.patch_min_max_tag == ("v1.0.2", "v1.2.1") class TestApplyPatches: """Tests for the :meth:`IrisRelease.apply_patches` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done) -> None: + self.instance = IrisRelease( + _dry_run=True, + git_tag="v1.1.1", + ) + self.mock_wait_for_done = mock_wait_for_done + + def get_wait_for_done_call(self) -> Any: + self.mock_wait_for_done.assert_called_once() + (call,) = self.mock_wait_for_done.call_args_list + return call + + def test_not_patch_release(self): + # Non-PATCH releases skip this step entirely. + self.instance.git_tag = "v1.2.0" + self.instance.apply_patches() + self.mock_wait_for_done.assert_not_called() + + def test_patch_branch_is_release_branch(self, mocker): + # User inputs the ideal branch - message confirms it is optimal. + mock_inputs(mocker, self.instance.version.branch) + self.instance.apply_patches() + call = self.get_wait_for_done_call() + assert_message_in_input(call, "patch change(s) are on the ideal branch") + assert_message_in_input(call, self.instance.version.branch) + + def test_patch_branch_empty(self, mocker): + # User inputs nothing - message instructs them to create a PR. + mock_inputs(mocker, "") + self.instance.apply_patches() + call = self.get_wait_for_done_call() + assert_message_in_input(call, "Propose the patch change(s)") + assert_message_in_input(call, self.instance.version.branch) + + def test_patch_branch_other(self, mocker): + # User inputs a different branch - message warns about cherry-pick conflicts. + mock_inputs(mocker, "some-other-branch") + self.instance.apply_patches() + call = self.get_wait_for_done_call() + assert_message_in_input(call, "cherry-picking the patch change(s)") + assert_message_in_input(call, "some-other-branch") + assert_message_in_input(call, self.instance.version.branch) class TestValidate: """Tests for the :meth:`IrisRelease.validate` method.""" @pytest.fixture(autouse=True) - def _setup(self) -> None: + def _setup(self, mock_git_ls_remote_tags) -> None: self.instance = IrisRelease( _dry_run=True, github_user="user", - patch_min_max_tag=("8.0.0", "9.0.0") + patch_min_max_tag=("1.0.0", "1.1.0") ) + mock_git_ls_remote_tags.return_value = "abcd1234 refs/tags/1.0.0" class Case(NamedTuple): git_tag: str @@ -377,23 +438,23 @@ class Case(NamedTuple): @pytest.fixture(params=[ pytest.param( - Case("9.1.dev0", "development release.*cannot handle"), + Case("1.1.dev0", "development release.*cannot handle"), id="dev release", ), pytest.param( - Case("9.1.post0", "post release.*cannot handle"), + Case("1.1.post0", "post release.*cannot handle"), id="post release", ), pytest.param( - Case("9.1.alpha0", "release candidate.*got 'a'"), + Case("1.1.alpha0", "release candidate.*got 'a'"), id="pre-release non-rc", ), pytest.param( - Case("9.1.1rc0", "PATCH release AND a release candidate.*cannot handle"), + Case("1.1.1rc0", "PATCH release AND a release candidate.*cannot handle"), id="patch release rc", ), pytest.param( - Case("9.1.1", "No previous releases.*cannot handle a PATCH"), + Case("1.1.1", "No previous releases.*cannot handle a PATCH"), id="first in series patch", ), ]) @@ -410,9 +471,10 @@ def test_unhandled_cases(self, unhandled_cases): @pytest.fixture def first_in_series_not_rc(self) -> None: - self.instance.git_tag = "9.1.0" + self.instance.git_tag = "1.1.0" def test_first_in_series_not_rc_message(self, first_in_series_not_rc, capfd, mocker): + # Two "yes" answers to arrive at the appropriate decision node. mock_inputs(mocker, "y", "y") self.instance.validate() out, err = capfd.readouterr() @@ -421,90 +483,570 @@ def test_first_in_series_not_rc_message(self, first_in_series_not_rc, capfd, moc assert "sure you want to continue" in out def test_first_in_series_not_rc_exit(self, first_in_series_not_rc, mocker): + # One "no" answer to arrive at the appropriate decision node. mock_inputs(mocker, "n") with pytest.raises(SystemExit): self.instance.validate() def test_first_in_series_not_rc_continue(self, first_in_series_not_rc, mocker): + # Two "yes" answers to arrive at the appropriate decision node. mock_inputs(mocker, "y", "y") self.instance.validate() # Not an exhaustive list, just the inverse of the unhandled cases. @pytest.fixture(params=[ - pytest.param("9.0.0rc0", id="major release RC"), - pytest.param("9.1.0rc0", id="minor release RC"), - pytest.param("1.2.0", id="minor release existing series"), - pytest.param("1.1.1", id="patch release existing series"), - pytest.param("9.1.0", id="first in series not RC"), + pytest.param("2.0.0rc0", id="major release RC"), + pytest.param("1.1.0rc0", id="minor release RC"), + pytest.param("1.1.0", id="minor release existing major"), + pytest.param("1.0.1", id="patch release existing minor"), + pytest.param("1.1.0", id="first in series not RC"), ]) def handled_cases(self, request) -> None: self.instance.git_tag = request.param def test_handled_cases(self, handled_cases, mocker, mock_wait_for_done): - sub_message = "Confirm that the details above are correct" + # One "yes" answer to arrive at the appropriate decision node. mock_inputs(mocker, "y") self.instance.validate() mock_wait_for_done.assert_called_once() (call,) = mock_wait_for_done.call_args_list - (message,) = call.args - assert sub_message in message + assert_message_in_input(call, "Confirm that the details above are correct") class TestUpdateStandardNames: """Tests for the :meth:`IrisRelease.update_standard_names` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + ) + + def test_not_first_in_series(self): + # Not first in series - method does nothing. + self.instance.git_tag = "v1.0.2" + self.instance.update_standard_names() + self.mock_wait_for_done.assert_not_called() + + def test_wait_messages(self): + # First in series. No other branching behaviour, so just a cursory check + # for the expected messages. + self.instance.git_tag = "v1.1.0" + self.instance.update_standard_names() + assert self.mock_wait_for_done.call_count == 5 + delete, checkout, update, pr, merge = self.mock_wait_for_done.call_args_list + message_fragments = [ + (delete, "avoid a name clash by deleting any existing local branch"), + (checkout, "Checkout a local branch from the official"), + (update, "Update the CF standard names table"), + (pr, "Create a Pull Request for your changes"), + (merge, "Work with the development team to get the PR merged"), + ] + for call, expected in message_fragments: + assert_message_in_input(call, expected) class TestCheckDeprecations: """Tests for the :meth:`IrisRelease.check_deprecations` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + + @pytest.mark.parametrize("git_tag", ["v1.1.0", "v1.1.1"]) + def test_not_major_release(self, git_tag): + # Not a MAJOR release - method does nothing. + self.instance.git_tag = git_tag + self.instance.check_deprecations() + self.mock_wait_for_done.assert_not_called() + + def test_major_release(self): + # MAJOR release - code block is active. + self.instance.git_tag = "v1.0.0" + self.instance.check_deprecations() + self.mock_wait_for_done.assert_called_once() + (call,) = self.mock_wait_for_done.call_args_list + assert_message_in_input(call, "be sure to finalise all deprecations") class TestCreateReleaseBranch: """Tests for the :meth:`IrisRelease.create_release_branch` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + ) + + def test_first_in_series(self): + self.instance.git_tag = "v1.1.0" + self.instance.create_release_branch() + self.mock_wait_for_done.assert_called_once() + (call,) = self.mock_wait_for_done.call_args_list + assert_message_in_input( + call, + f"create the ``{self.instance.version.branch}`` release branch" + ) + + def test_not_first_in_series(self): + self.instance.git_tag = "v1.0.2" + self.instance.create_release_branch() + self.mock_wait_for_done.assert_called_once() + (call,) = self.mock_wait_for_done.call_args_list + assert_message_in_input( + call, + "If necessary: cherry-pick any specific commits that are needed", + ) class TestFinaliseWhatsNew: """Tests for the :meth:`IrisRelease.finalise_whats_new` method.""" - pass + class WaitMessages(enum.StrEnum): + DELETE = "avoid a name clash by deleting any existing local branch" + CHECKOUT = "Checkout a local branch from the official" + CUT = "'Cut' the What's New for the release" + REFS = r"Replace references to" + TITLE = "set the page title to" + UNDERLINE = "ensure the page title underline is the exact same length" + DROPDOWN_HIGHLIGHT = "set the sphinx-design dropdown title" + REFLECTION = "ensure it is a good reflection of what is new" + HIGHLIGHTS = "populate the Release Highlights dropdown" + DROPDOWN_PATCH = "Create a patch dropdown section" + TEMPLATE = "Remove the What's New template file" + PUSH = "Commit and push all the What's New changes" + PR = "Create a Pull Request for your changes" + MERGE = "Work with the development team to get the PR merged" + + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1236 refs/tags/v1.1.0rc0\n" + ) + + def common_test(self, git_tag, expected_messages): + self.instance.git_tag = git_tag + self.instance.finalise_whats_new() + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip( + self.mock_wait_for_done.call_args_list, + expected_messages, + ): + assert_message_in_input(call, expected) + + def test_first_in_series(self): + expected_messages = [ + self.WaitMessages.DELETE, + self.WaitMessages.CHECKOUT, + self.WaitMessages.CUT, + self.WaitMessages.REFS, + self.WaitMessages.TITLE, + self.WaitMessages.UNDERLINE, + self.WaitMessages.DROPDOWN_HIGHLIGHT, + self.WaitMessages.REFLECTION, + self.WaitMessages.HIGHLIGHTS, + self.WaitMessages.TEMPLATE, + self.WaitMessages.PUSH, + self.WaitMessages.PR, + self.WaitMessages.MERGE, + ] + self.common_test("v1.2.0", expected_messages) + + def test_minor_not_first(self): + expected_messages = [ + self.WaitMessages.DELETE, + self.WaitMessages.CHECKOUT, + self.WaitMessages.TITLE, + self.WaitMessages.UNDERLINE, + self.WaitMessages.DROPDOWN_HIGHLIGHT, + self.WaitMessages.REFLECTION, + self.WaitMessages.HIGHLIGHTS, + self.WaitMessages.PUSH, + self.WaitMessages.PR, + self.WaitMessages.MERGE, + ] + self.common_test("v1.1.0", expected_messages) + + def test_patch(self): + expected_messages = [ + self.WaitMessages.DELETE, + self.WaitMessages.CHECKOUT, + self.WaitMessages.DROPDOWN_PATCH, + self.WaitMessages.PUSH, + self.WaitMessages.PR, + self.WaitMessages.MERGE, + ] + self.common_test("v1.0.2", expected_messages) class TestCutRelease: """Tests for the :meth:`IrisRelease.cut_release` method.""" - pass + class WaitMessages(enum.StrEnum): + WEBPAGE = "Visit https://github.com/SciTools/iris/releases/new" + TAG = "as the new tag to create, and also as the Release title" + TEXT = "Populate the main text box" + INSTALL_RC = "This is a release candidate - include the following instructions" + TICK_RC = "This is a release candidate - tick the box" + LATEST = "Tick the box to set this as the latest release" + NOT_LATEST = "Un-tick the latest release box." + PUBLISH = "Click: Publish release !" + URL = "Visit https://github.com/SciTools/iris/actions/workflows/ci-wheels.yml" + + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1236 refs/tags/v1.1.0\n" + ) + + def common_test(self, git_tag, expected_messages): + self.instance.git_tag = git_tag + self.instance.cut_release() + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip( + self.mock_wait_for_done.call_args_list, + expected_messages, + ): + assert_message_in_input(call, expected) + + def test_latest(self): + self.instance.git_tag = "v1.2.0" + expected_messages = [ + self.WaitMessages.WEBPAGE, + self.WaitMessages.TAG, + self.WaitMessages.TEXT, + self.WaitMessages.LATEST, + self.WaitMessages.PUBLISH, + self.WaitMessages.URL, + ] + self.common_test("v1.2.0", expected_messages) + + def test_not_latest(self): + expected_messages = [ + self.WaitMessages.WEBPAGE, + self.WaitMessages.TAG, + self.WaitMessages.TEXT, + self.WaitMessages.NOT_LATEST, + self.WaitMessages.PUBLISH, + self.WaitMessages.URL, + ] + self.common_test("v1.0.2", expected_messages) + + def test_release_candidate(self): + expected_messages = [ + self.WaitMessages.WEBPAGE, + self.WaitMessages.TAG, + self.WaitMessages.TEXT, + self.WaitMessages.INSTALL_RC, + self.WaitMessages.TICK_RC, + self.WaitMessages.PUBLISH, + self.WaitMessages.URL, + ] + self.common_test("v1.2.0rc0", expected_messages) class TestCheckRtd: """Tests for the :meth:`IrisRelease.check_rtd` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1236 refs/tags/v1.1.0\n" + ) + + @pytest.mark.parametrize("latest", [True, False], ids=["is_latest", "not_latest"]) + @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) + def test_default(self, latest: bool, rc: bool): + if latest: + git_tag = "v1.2.0" + else: + git_tag = "v1.0.2" + if rc: + git_tag += "rc0" + self.instance.git_tag = git_tag + self.instance.check_rtd() + expected_messages = [ + "Visit https://readthedocs.org/projects/scitools-iris/versions/", + "to Active, un-Hidden", + "to Active, Hidden", + "Keep only the latest 2 branch doc builds active", + "is available in RTD's version switcher", + "is NOT available in RTD's version switcher", + ] + call_args_list = self.mock_wait_for_done.call_args_list + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip(call_args_list, expected_messages): + assert_message_in_input(call, expected) + + (check_message,) = call_args_list[4][0] + check_expected = "Selecting 'stable' in the version switcher" + if latest and not rc: + assert check_expected in check_message + else: + assert check_expected not in check_message class TestCheckPyPI: """Tests for the :meth:`IrisRelease.check_pypi` method.""" - pass + class WaitMessages(enum.StrEnum): + URL = "Confirm that the following URL is correctly populated" + TOP = "is at the top of this page" + PRE_RELEASE = "is marked as a pre-release on this page" + TAG = "is the tag shown on the scitools-iris PyPI homepage" + INSTALL = "Confirm that pip install works as expected" + + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags, mocker) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + # For the PyPI SHA256 input. + mock_inputs( + mocker, + "ccc8025d24b74d86ab780266cb9f708c468ac53426a45fab20bfc315c68383f7", + ) + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1236 refs/tags/v1.2.0\n" + ) + + def common_test(self, git_tag, expected_messages): + self.instance.git_tag = git_tag + self.instance.check_pypi() + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip( + self.mock_wait_for_done.call_args_list, + expected_messages, + ): + assert_message_in_input(call, expected) + + def test_latest(self): + expected_messages = [ + self.WaitMessages.URL, + self.WaitMessages.TOP, + self.WaitMessages.TAG, + self.WaitMessages.INSTALL, + ] + self.common_test("v1.3.0", expected_messages) + + def test_not_latest(self): + expected_messages = [ + self.WaitMessages.URL, + self.WaitMessages.INSTALL, + ] + self.common_test("v1.0.2", expected_messages) + + def test_release_candidate(self): + expected_messages = [ + self.WaitMessages.URL, + self.WaitMessages.PRE_RELEASE, + self.WaitMessages.INSTALL, + ] + self.common_test("v1.1.0rc0", expected_messages) + + def test_latest_and_rc(self): + expected_messages = [ + self.WaitMessages.URL, + self.WaitMessages.TOP, + self.WaitMessages.PRE_RELEASE, + self.WaitMessages.INSTALL, + ] + self.common_test("v1.3.0rc0", expected_messages) + + def test_sha256_input(self, mocker, capfd): + self.instance.git_tag = "v1.3.0" + fake_sha = "3b2f4091883d1e401192b4f64aead9e4bbdb84854b74c984614d79742b2fab96" + mock_inputs(mocker, fake_sha) + self.instance.check_pypi() + out, err = capfd.readouterr() + assert "Visit the below and click `view details`" in out + assert self.instance.sha256 == fake_sha + + def test_invalid_sha(self, mocker, mock_report_problem): + self.instance.git_tag = "v1.3.0" + fake_sha = "3b2f4091883d1e401192b4f64aead9e4bbdb84854b74c984614d79742b2fab96" + mock_inputs(mocker, "not-a-sha", fake_sha) + self.instance.check_pypi() + mock_report_problem.assert_called_once_with( + "Invalid SHA256 hash. Please try again ..." + ) + assert self.instance.sha256 == fake_sha + + def test_sha_default_value_preserved(self, mocker): + self.instance.git_tag = "v1.3.0" + fake_sha = "3b2f4091883d1e401192b4f64aead9e4bbdb84854b74c984614d79742b2fab96" + self.instance.sha256 = fake_sha + mock_inputs(mocker, "") + self.instance.check_pypi() + assert self.instance.sha256 == fake_sha class TestUpdateCondaForge: """Tests for the :meth:`IrisRelease.update_conda_forge` method.""" + # TODO: Confirming this one behaves correctly is a nightmare. There is more + # conditional branching than elsewhere. Suggestions welcome. pass + # class WaitMessages(enum.StrEnum): + # FORK = "Make sure you have a GitHub fork of" + # RC_BRANCHES = "Visit the conda-forge feedstock branches page" + # RC_ARCHIVE = f"by appending _{datetime.today().strftime("%Y%m%d")} to its name" + # CHECKOUT = "Checkout a new branch for the conda-forge" + # UPDATE = "Update ./recipe/meta.yaml:" + # PUSH = "push up the changes to prepare for a Pull Request" + # PR = "Create a Pull Request for your changes" + # AUTO = "Follow the automatic conda-forge guidance" + # MAINTAINERS = "Work with your fellow feedstock maintainers" + # CI = "wait for the CI to complete" + # LIST = "appears in this list:" + # LATEST = "is displayed on this page as the latest available" + # TESTING = "The new release will now undergo testing and validation" + # INSTALL = "Confirm that conda (or mamba) install works as expected" + # PATCH = "is not the latest Iris release" + # + # @pytest.fixture(autouse=True) + # def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + # self.instance = IrisRelease(_dry_run=True) + # self.mock_wait_for_done = mock_wait_for_done + # mock_git_ls_remote_tags.return_value = ( + # "abcd1234 refs/tags/v1.0.0\n" + # "abcd1235 refs/tags/v1.0.1\n" + # "abcd1236 refs/tags/v1.1.0\n" + # "abcd1237 refs/tags/v2.0.0\n" + # ) + # + # @pytest.mark.parametrize("latest", [True, False], ids=["is_latest", "not_latest"]) + # @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) + # @pytest.mark.parametrize("more_patches", [True, False], ids=["more_patches", "no_more_patches"]) + # def test_waits(self, latest: bool, rc: bool, more_patches: bool): + # if latest: + # git_tag = "v2.1" + # else: + # git_tag = "v1.2" + # if more_patches: + # git_tag += ".1" + # else: + # git_tag += ".0" + # if rc: + # git_tag += "rc0" + # self.instance.git_tag = git_tag + # if more_patches: + # self.instance.patch_min_max_tag = (git_tag, "v2.2.1") + # + # expected_messages = list(self.WaitMessages) + # if not rc: + # expected_messages.remove(self.WaitMessages.RC_BRANCHES) + # expected_messages.remove(self.WaitMessages.RC_ARCHIVE) + # if rc or not latest: + # expected_messages.remove(self.WaitMessages.LATEST) + # if latest or more_patches: + # expected_messages.remove(self.WaitMessages.PATCH) + class TestUpdateLinks: """Tests for the :meth:`IrisRelease.update_links` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done) -> None: + self.instance = IrisRelease(_dry_run=True, git_tag="v1.2.0") + self.mock_wait_for_done = mock_wait_for_done + + def test_waits(self, mocker): + mock_inputs(mocker, "some-url") + self.instance.update_links() + assert self.mock_wait_for_done.call_count == 3 + revisit, update, comment = self.mock_wait_for_done.call_args_list + message_fragments = [ + (revisit, "Revisit the GitHub release:"), + (update, "the above links and anything else appropriate"), + (comment, "notify anyone watching"), + ] + for call, expected in message_fragments: + assert_message_in_input(call, expected) + + def test_url_input(self, mocker, capfd): + mock_inputs(mocker, "some-url") + self.instance.update_links() + out, err = capfd.readouterr() + assert "What is the URL for the GitHub discussions page" in out + revisit, update, comment = self.mock_wait_for_done.call_args_list + assert_message_in_input(update, "some-url") + assert_message_in_input(comment, "some-url") class TestBlueskyAnnounce: """Tests for the :meth:`IrisRelease.bluesky_announce` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + ) + + @pytest.mark.parametrize("first_in_series", [True, False], ids=["first_in_series", "not_first_in_series"]) + def test_wait(self, first_in_series: bool): + if first_in_series: + git_tag = "v1.1.0" + else: + git_tag = "v1.0.2" + self.instance.git_tag = git_tag + self.instance.bluesky_announce() + self.mock_wait_for_done.assert_called_once() + (call,) = self.mock_wait_for_done.call_args_list + assert_message_in_input(call, "Announce the release") + if not first_in_series: + assert_message_in_input(call, "Consider replying within an existing") class TestMergeBack: """Tests for the :meth:`IrisRelease.merge_back` method.""" + # TODO: figure out how to test this one - more complex than the rest. pass class TestNextRelease: """Tests for the :meth:`IrisRelease.next_release` method.""" - pass + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + + @pytest.mark.parametrize("patch", [True, False], ids=["patch", "not_patch"]) + @pytest.mark.parametrize("rc", [True, False], ids=["rc", "not_rc"]) + def test_waits(self, patch: bool, rc: bool): + if patch: + git_tag = "v1.1.1" + else: + git_tag = "v1.2.0" + if rc: + git_tag += "rc0" + self.instance.git_tag = git_tag + self.instance.next_release() + if not patch and not rc: + assert self.mock_wait_for_done.call_count == 5 + manager, milestone, discussion, sprints, champion = self.mock_wait_for_done.call_args_list + message_fragments = [ + (manager, "Confirm that there is a release manager"), + (milestone, "has set up a milestone for their release"), + (discussion, "has set up a discussion page for their release"), + (sprints, "has arranged some team development time"), + (champion, "importance of regularly championing their release"), + ] + for call, expected in message_fragments: + assert_message_in_input(call, expected) + else: + self.mock_wait_for_done.assert_not_called() \ No newline at end of file From a36ee154efae95c23c0c8f1424dc1d2e39d197ce Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 15 Apr 2026 16:02:38 +0100 Subject: [PATCH 10/26] Support regex in tests. --- tools/test_release_do_nothing.py | 84 ++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index d46f306947..833250f6d4 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -6,6 +6,7 @@ import enum from datetime import datetime from pathlib import Path +import re from typing import Any, NamedTuple import pytest @@ -79,11 +80,13 @@ def mock_inputs(mocker, *inputs: str) -> None: mocker.patch("builtins.input", side_effect=inputs) -def assert_message_in_input(call: Any, expected: str) -> None: +def assert_input_msg_regex(call: Any, expected: re.Pattern[str] | str) -> None: + if isinstance(expected, str): + expected = re.compile(expected) assert hasattr(call, "args") and len(call.args) > 0 message = call.args[0] assert isinstance(message, str) - assert expected in message + assert expected.search(message) is not None, f"Expected message matching {expected!r} in {message!r}" class TestIrisVersion: @@ -400,25 +403,31 @@ def test_patch_branch_is_release_branch(self, mocker): mock_inputs(mocker, self.instance.version.branch) self.instance.apply_patches() call = self.get_wait_for_done_call() - assert_message_in_input(call, "patch change(s) are on the ideal branch") - assert_message_in_input(call, self.instance.version.branch) + branch = re.escape(self.instance.version.branch) + assert_input_msg_regex( + call, rf"patch change\(s\) are on the ideal branch.*{branch}.*" + ) def test_patch_branch_empty(self, mocker): # User inputs nothing - message instructs them to create a PR. mock_inputs(mocker, "") self.instance.apply_patches() call = self.get_wait_for_done_call() - assert_message_in_input(call, "Propose the patch change(s)") - assert_message_in_input(call, self.instance.version.branch) + branch = re.escape(self.instance.version.branch) + assert_input_msg_regex( + call, rf"Propose the patch change\(s\).*{branch}.*" + ) def test_patch_branch_other(self, mocker): # User inputs a different branch - message warns about cherry-pick conflicts. mock_inputs(mocker, "some-other-branch") self.instance.apply_patches() call = self.get_wait_for_done_call() - assert_message_in_input(call, "cherry-picking the patch change(s)") - assert_message_in_input(call, "some-other-branch") - assert_message_in_input(call, self.instance.version.branch) + branch = re.escape(self.instance.version.branch) + assert_input_msg_regex( + call, + rf"cherry-picking the patch change\(s\).*some-other-branch.*{branch}.*" + ) class TestValidate: @@ -510,7 +519,7 @@ def test_handled_cases(self, handled_cases, mocker, mock_wait_for_done): self.instance.validate() mock_wait_for_done.assert_called_once() (call,) = mock_wait_for_done.call_args_list - assert_message_in_input(call, "Confirm that the details above are correct") + assert_input_msg_regex(call, "Confirm that the details above are correct") class TestUpdateStandardNames: @@ -545,7 +554,7 @@ def test_wait_messages(self): (merge, "Work with the development team to get the PR merged"), ] for call, expected in message_fragments: - assert_message_in_input(call, expected) + assert_input_msg_regex(call, expected) class TestCheckDeprecations: @@ -568,7 +577,7 @@ def test_major_release(self): self.instance.check_deprecations() self.mock_wait_for_done.assert_called_once() (call,) = self.mock_wait_for_done.call_args_list - assert_message_in_input(call, "be sure to finalise all deprecations") + assert_input_msg_regex(call, "be sure to finalise all deprecations") class TestCreateReleaseBranch: @@ -587,7 +596,7 @@ def test_first_in_series(self): self.instance.create_release_branch() self.mock_wait_for_done.assert_called_once() (call,) = self.mock_wait_for_done.call_args_list - assert_message_in_input( + assert_input_msg_regex( call, f"create the ``{self.instance.version.branch}`` release branch" ) @@ -597,7 +606,7 @@ def test_not_first_in_series(self): self.instance.create_release_branch() self.mock_wait_for_done.assert_called_once() (call,) = self.mock_wait_for_done.call_args_list - assert_message_in_input( + assert_input_msg_regex( call, "If necessary: cherry-pick any specific commits that are needed", ) @@ -609,10 +618,10 @@ class WaitMessages(enum.StrEnum): DELETE = "avoid a name clash by deleting any existing local branch" CHECKOUT = "Checkout a local branch from the official" CUT = "'Cut' the What's New for the release" - REFS = r"Replace references to" - TITLE = "set the page title to" + REFS = r"Replace references to.*latest\.rst with.*{series}" + TITLE = r"set the page title to.*\nv{series}" UNDERLINE = "ensure the page title underline is the exact same length" - DROPDOWN_HIGHLIGHT = "set the sphinx-design dropdown title" + DROPDOWN_HIGHLIGHT = r"set the sphinx-design dropdown title.*\nv{series}" REFLECTION = "ensure it is a good reflection of what is new" HIGHLIGHTS = "populate the Release Highlights dropdown" DROPDOWN_PATCH = "Create a patch dropdown section" @@ -639,7 +648,8 @@ def common_test(self, git_tag, expected_messages): self.mock_wait_for_done.call_args_list, expected_messages, ): - assert_message_in_input(call, expected) + expected = expected.format(series=re.escape(self.instance.version.series[1:])) + assert_input_msg_regex(call, expected) def test_first_in_series(self): expected_messages = [ @@ -717,7 +727,7 @@ def common_test(self, git_tag, expected_messages): self.mock_wait_for_done.call_args_list, expected_messages, ): - assert_message_in_input(call, expected) + assert_input_msg_regex(call, expected) def test_latest(self): self.instance.git_tag = "v1.2.0" @@ -778,18 +788,19 @@ def test_default(self, latest: bool, rc: bool): git_tag += "rc0" self.instance.git_tag = git_tag self.instance.check_rtd() + series = re.escape(self.instance.version.series) expected_messages = [ "Visit https://readthedocs.org/projects/scitools-iris/versions/", - "to Active, un-Hidden", - "to Active, Hidden", + rf"{series}.* to Active, un-Hidden", + rf"{series}.* to Active, Hidden", "Keep only the latest 2 branch doc builds active", - "is available in RTD's version switcher", - "is NOT available in RTD's version switcher", + rf"{series}.* is available in RTD's version switcher", + rf"{series}.* is NOT available in RTD's version switcher", ] call_args_list = self.mock_wait_for_done.call_args_list assert self.mock_wait_for_done.call_count == len(expected_messages) for call, expected in zip(call_args_list, expected_messages): - assert_message_in_input(call, expected) + assert_input_msg_regex(call, expected) (check_message,) = call_args_list[4][0] check_expected = "Selecting 'stable' in the version switcher" @@ -803,9 +814,9 @@ class TestCheckPyPI: """Tests for the :meth:`IrisRelease.check_pypi` method.""" class WaitMessages(enum.StrEnum): URL = "Confirm that the following URL is correctly populated" - TOP = "is at the top of this page" - PRE_RELEASE = "is marked as a pre-release on this page" - TAG = "is the tag shown on the scitools-iris PyPI homepage" + TOP = "{public} is at the top of this page" + PRE_RELEASE = "{public} is marked as a pre-release on this page" + TAG = "{public} is the tag shown on the scitools-iris PyPI homepage" INSTALL = "Confirm that pip install works as expected" @pytest.fixture(autouse=True) @@ -831,7 +842,8 @@ def common_test(self, git_tag, expected_messages): self.mock_wait_for_done.call_args_list, expected_messages, ): - assert_message_in_input(call, expected) + expected = expected.format(public=re.escape(self.instance.version.public)) + assert_input_msg_regex(call, expected) def test_latest(self): expected_messages = [ @@ -970,11 +982,11 @@ def test_waits(self, mocker): revisit, update, comment = self.mock_wait_for_done.call_args_list message_fragments = [ (revisit, "Revisit the GitHub release:"), - (update, "the above links and anything else appropriate"), + (update, "Update .* with the above links and anything else appropriate"), (comment, "notify anyone watching"), ] for call, expected in message_fragments: - assert_message_in_input(call, expected) + assert_input_msg_regex(call, expected) def test_url_input(self, mocker, capfd): mock_inputs(mocker, "some-url") @@ -982,8 +994,8 @@ def test_url_input(self, mocker, capfd): out, err = capfd.readouterr() assert "What is the URL for the GitHub discussions page" in out revisit, update, comment = self.mock_wait_for_done.call_args_list - assert_message_in_input(update, "some-url") - assert_message_in_input(comment, "some-url") + assert_input_msg_regex(update, "some-url") + assert_input_msg_regex(comment, "some-url") class TestBlueskyAnnounce: @@ -1007,9 +1019,9 @@ def test_wait(self, first_in_series: bool): self.instance.bluesky_announce() self.mock_wait_for_done.assert_called_once() (call,) = self.mock_wait_for_done.call_args_list - assert_message_in_input(call, "Announce the release") + assert_input_msg_regex(call, "Announce the release") if not first_in_series: - assert_message_in_input(call, "Consider replying within an existing") + assert_input_msg_regex(call, "Consider replying within an existing") class TestMergeBack: @@ -1047,6 +1059,6 @@ def test_waits(self, patch: bool, rc: bool): (champion, "importance of regularly championing their release"), ] for call, expected in message_fragments: - assert_message_in_input(call, expected) + assert_input_msg_regex(call, expected) else: - self.mock_wait_for_done.assert_not_called() \ No newline at end of file + self.mock_wait_for_done.assert_not_called() From 5b1b2c95b848a7731b8d06815cdc094eadc3127c Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 12:29:54 +0100 Subject: [PATCH 11/26] Finish do-nothing tests. --- tools/test_release_do_nothing.py | 311 +++++++++++++++++++++++++------ 1 file changed, 253 insertions(+), 58 deletions(-) diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 833250f6d4..1c297b4e07 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -81,8 +81,9 @@ def mock_inputs(mocker, *inputs: str) -> None: def assert_input_msg_regex(call: Any, expected: re.Pattern[str] | str) -> None: + # TODO: use this for testing ALL messages that include dynamic content? if isinstance(expected, str): - expected = re.compile(expected) + expected = re.compile(expected, re.DOTALL) assert hasattr(call, "args") and len(call.args) > 0 message = call.args[0] assert isinstance(message, str) @@ -910,62 +911,157 @@ class TestUpdateCondaForge: """Tests for the :meth:`IrisRelease.update_conda_forge` method.""" # TODO: Confirming this one behaves correctly is a nightmare. There is more # conditional branching than elsewhere. Suggestions welcome. - pass - - # class WaitMessages(enum.StrEnum): - # FORK = "Make sure you have a GitHub fork of" - # RC_BRANCHES = "Visit the conda-forge feedstock branches page" - # RC_ARCHIVE = f"by appending _{datetime.today().strftime("%Y%m%d")} to its name" - # CHECKOUT = "Checkout a new branch for the conda-forge" - # UPDATE = "Update ./recipe/meta.yaml:" - # PUSH = "push up the changes to prepare for a Pull Request" - # PR = "Create a Pull Request for your changes" - # AUTO = "Follow the automatic conda-forge guidance" - # MAINTAINERS = "Work with your fellow feedstock maintainers" - # CI = "wait for the CI to complete" - # LIST = "appears in this list:" - # LATEST = "is displayed on this page as the latest available" - # TESTING = "The new release will now undergo testing and validation" - # INSTALL = "Confirm that conda (or mamba) install works as expected" - # PATCH = "is not the latest Iris release" - # - # @pytest.fixture(autouse=True) - # def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: - # self.instance = IrisRelease(_dry_run=True) - # self.mock_wait_for_done = mock_wait_for_done - # mock_git_ls_remote_tags.return_value = ( - # "abcd1234 refs/tags/v1.0.0\n" - # "abcd1235 refs/tags/v1.0.1\n" - # "abcd1236 refs/tags/v1.1.0\n" - # "abcd1237 refs/tags/v2.0.0\n" - # ) - # - # @pytest.mark.parametrize("latest", [True, False], ids=["is_latest", "not_latest"]) - # @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) - # @pytest.mark.parametrize("more_patches", [True, False], ids=["more_patches", "no_more_patches"]) - # def test_waits(self, latest: bool, rc: bool, more_patches: bool): - # if latest: - # git_tag = "v2.1" - # else: - # git_tag = "v1.2" - # if more_patches: - # git_tag += ".1" - # else: - # git_tag += ".0" - # if rc: - # git_tag += "rc0" - # self.instance.git_tag = git_tag - # if more_patches: - # self.instance.patch_min_max_tag = (git_tag, "v2.2.1") - # - # expected_messages = list(self.WaitMessages) - # if not rc: - # expected_messages.remove(self.WaitMessages.RC_BRANCHES) - # expected_messages.remove(self.WaitMessages.RC_ARCHIVE) - # if rc or not latest: - # expected_messages.remove(self.WaitMessages.LATEST) - # if latest or more_patches: - # expected_messages.remove(self.WaitMessages.PATCH) + class WaitMessages(enum.StrEnum): + FORK = "Make sure you have a GitHub fork of" + RC_BRANCHES = "Visit the conda-forge feedstock branches page" + # `rc-original` = just the value used in these tests + RC_ARCHIVE = "Archive the rc-original branch" + CHECKOUT = "Checkout a new branch for the conda-forge" + + UPDATE = re.escape("Update ./recipe/meta.yaml:") + ".*unsure\.$" + UPDATE_NOT_LATEST = re.escape("Update ./recipe/meta.yaml:") + ".*unsure\..*{version} is not the latest Iris release" + + PUSH = "push up the changes to prepare for a Pull Request" + PR = "Create a Pull Request for your changes" + + AUTO = "Follow the automatic conda-forge guidance.*Pull Request\.$" + AUTO_RC = "Follow the automatic conda-forge guidance.*Pull Request\..*release candidate" + + MAINTAINERS = "Work with your fellow feedstock maintainers" + CI = "wait for the CI to complete" + LIST = r"Confirm that {public} appears in this list:" + LATEST = "is displayed on this page as the latest available" + TESTING = "The new release will now undergo testing and validation" + INSTALL = re.escape("Confirm that conda (or mamba) install works as expected") + PATCH = r"{version} is not the latest Iris release" + + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags) -> None: + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.0.1\n" + "abcd1236 refs/tags/v1.1.0\n" + "abcd1237 refs/tags/v2.0.0\n" + ) + + @pytest.mark.parametrize("latest", [True, False], ids=["is_latest", "not_latest"]) + @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) + @pytest.mark.parametrize("more_patches", [True, False], ids=["more_patches", "no_more_patches"]) + def test_waits(self, latest: bool, rc: bool, more_patches: bool, mocker): + if latest: + git_tag = "v2.1" + else: + git_tag = "v1.2" + if more_patches: + git_tag += ".1" + else: + git_tag += ".0" + if rc: + git_tag += "rc0" + self.instance.git_tag = git_tag + if more_patches: + self.instance.patch_min_max_tag = (git_tag, "v2.2.1") + + # All inputs relate to handling of the release candidate branch. We + # choose the inputs that allow exercising every wait message. + mock_inputs(mocker, "rc-original", "y", "rc-new") + + expected_messages = list(self.WaitMessages) + if not rc: + expected_messages.remove(self.WaitMessages.RC_BRANCHES) + expected_messages.remove(self.WaitMessages.RC_ARCHIVE) + expected_messages.remove(self.WaitMessages.AUTO_RC) + else: + expected_messages.remove(self.WaitMessages.AUTO) + + if latest: + expected_messages.remove(self.WaitMessages.UPDATE_NOT_LATEST) + else: + expected_messages.remove(self.WaitMessages.UPDATE) + + if rc or not latest: + expected_messages.remove(self.WaitMessages.LATEST) + + if latest or more_patches: + expected_messages.remove(self.WaitMessages.PATCH) + + self.instance.update_conda_forge() + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip( + self.mock_wait_for_done.call_args_list, + expected_messages, + ): + expected = expected.format( + public=re.escape(self.instance.version.public), + version=re.escape(str(self.instance.version)), + ) + assert_input_msg_regex(call, expected) + + def test_original_rc_branch_name(self, mocker): + self.instance.git_tag = "v2.1.0rc0" + mock_inputs(mocker, "my-special-rc-branch", "y", "rc-new") + self.instance.update_conda_forge() + wait_messages = [ + call.args[0] for call in self.mock_wait_for_done.call_args_list + ] + expected = self.WaitMessages.RC_ARCHIVE.replace("rc-original", "my-special-rc-branch") + not_expected = self.WaitMessages.RC_ARCHIVE + assert any(re.search(expected, m) for m in wait_messages) + assert not any(re.search(not_expected, m) for m in wait_messages) + + @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) + def test_new_rc_branch_name(self, rc, mocker): + git_tag = "v1.2.0" + if rc: + git_tag += "rc0" + self.instance.git_tag = git_tag + mock_inputs(mocker, "rc-original", "y", "rc-new") + self.instance.update_conda_forge() + all_calls = [call.args[0] for call in self.mock_wait_for_done.call_args_list] + calls = [ + call for call in all_calls + if any(phrase in call for phrase in [ + "Checkout a new branch", + "Create a Pull Request", + "branch needs to be restored", + ]) + ] + expected = "rc-new" if rc else "main" + assert all(expected in c for c in calls) + + def test_young_rc_branch(self, mocker): + self.instance.git_tag = "v2.1.0rc0" + mock_inputs(mocker, "rc-original", "n") + self.instance.update_conda_forge() + wait_messages = [ + call.args[0] for call in self.mock_wait_for_done.call_args_list + ] + regex = re.compile(self.WaitMessages.RC_ARCHIVE) + assert all(regex.search(m) is None for m in wait_messages) + + def test_invalid_rc_branch_age(self, mocker, mock_report_problem): + self.instance.git_tag = "v2.1.0rc0" + # Invalid entry, then valid "n". + mock_inputs(mocker, "rc-original", "maybe", "n") + self.instance.update_conda_forge() + mock_report_problem.assert_called_once_with( + "Invalid entry. Please try again ..." + ) + + @pytest.mark.parametrize("rc", [True, False], ids=["is_rc", "not_rc"]) + def test_channel_command(self, rc, mocker): + git_tag = "v1.2.0" + if rc: + git_tag += "rc0" + self.instance.git_tag = git_tag + mock_inputs(mocker, "rc-original", "n") + self.instance.update_conda_forge() + if rc: + assert any("label/rc_iris" in call.args[0] for call in self.mock_wait_for_done.call_args_list) + else: + assert not any("label/rc_iris" in call.args[0] for call in self.mock_wait_for_done.call_args_list) class TestUpdateLinks: @@ -1027,7 +1123,106 @@ def test_wait(self, first_in_series: bool): class TestMergeBack: """Tests for the :meth:`IrisRelease.merge_back` method.""" # TODO: figure out how to test this one - more complex than the rest. - pass + + class WaitMessages(enum.StrEnum): + DELETE = "avoid a name clash by deleting any existing local branch" + CHECKOUT = "Checkout a local branch from the official" + MERGE_IN = "Merge in the commits from {branch}" + TEMPLATE = "Recreate the What's New template" + LATEST = "Recreate the What's New latest" + GUIDANCE = "Follow any guidance in .*latest\.rst" + INDEX = "Add .*latest\.rst to the top of the list" + PUSH = "Commit and push all the What's New changes" + PR = "Create a Pull Request for your changes" + RISKY = "COMBINING BRANCHES CAN BE RISKY" + PR_MERGE = "Work with the development team to get the PR merged" + NEXT_PATCH = "Run the following command in a new terminal" + + @pytest.fixture(autouse=True) + def _setup(self, mock_wait_for_done, mock_git_ls_remote_tags): + self.instance = IrisRelease(_dry_run=True) + self.mock_wait_for_done = mock_wait_for_done + mock_git_ls_remote_tags.return_value = ( + "abcd1234 refs/tags/v1.0.0\n" + "abcd1235 refs/tags/v1.1.0\n" + "abcd1236 refs/tags/v1.2.0\n" + ) + + @pytest.mark.parametrize("first", [True, False], ids=["first_in_series", "not_first_in_series"]) + @pytest.mark.parametrize("more_patches", [True, False], ids=["more_patches", "no_more_patches"]) + def test_waits(self, first, more_patches): + if first and more_patches: + pytest.skip("first_in_series and more_patches are mutually exclusive in reality.") + if first: + git_tag = "v1.3.0" + else: + git_tag = "v1.0.1" + self.instance.git_tag = git_tag + if more_patches: + self.instance.patch_min_max_tag = (git_tag, "v1.2.1") + + expected_messages = list(self.WaitMessages) + if not first: + expected_messages.remove(self.WaitMessages.TEMPLATE) + expected_messages.remove(self.WaitMessages.LATEST) + expected_messages.remove(self.WaitMessages.GUIDANCE) + expected_messages.remove(self.WaitMessages.INDEX) + expected_messages.remove(self.WaitMessages.PUSH) + if not more_patches: + expected_messages.remove(self.WaitMessages.NEXT_PATCH) + + self.instance.merge_back() + assert self.mock_wait_for_done.call_count == len(expected_messages) + for call, expected in zip( + self.mock_wait_for_done.call_args_list, + expected_messages, + ): + expected = expected.format(branch=re.escape(self.instance.version.branch)) + assert_input_msg_regex(call, expected) + + @pytest.mark.parametrize("more_patches", [True, False], ids=["more_patches", "no_more_patches"]) + def test_branches(self, more_patches): + self.instance.git_tag = "v1.0.1" + if more_patches: + self.instance.patch_min_max_tag = ("v1.0.1", "v1.2.1") + target_branch = "v1.1.x" + working_branch = "v1.0.1-to-v1.1.x" + else: + target_branch = "main" + working_branch = "v1.0.x.mergeback" + + self.instance.merge_back() + wait_messages = [ + call.args[0] for call in self.mock_wait_for_done.call_args_list + ] + # Use CHECKOUT as the test since it contains target_ and working_branch. + (checkout_message,) = [ + m for m in wait_messages if re.search(self.WaitMessages.CHECKOUT, m) + ] + pattern = re.compile(rf"git checkout .*{target_branch} -b {working_branch}") + assert pattern.search(checkout_message) is not None + + def test_next_series_error(self, mocker): + self.instance.git_tag = "v1.0.1" + self.instance.patch_min_max_tag = ("v1.0.1", "v1.2.1") + _ = mocker.patch.object( + IrisRelease, + "_get_tagged_versions", + return_value=[IrisVersion("v1.0.0")], + ) + with pytest.raises(RuntimeError, match="Error finding next series"): + self.instance.merge_back() + + def test_next_patch_file(self): + self.instance.git_tag = "v1.0.1" + self.instance.patch_min_max_tag = ("v1.0.1", "v1.2.1") + expected_file = self.instance._get_file_stem().with_name("v1_1_1.json") + self.instance.merge_back() + assert expected_file.exists() + next_patch = IrisRelease.load(expected_file, dry_run=True) + assert next_patch.latest_complete_step == IrisRelease.get_steps().index(IrisRelease.validate) - 1 + assert next_patch.git_tag == "v1.1.1" + assert next_patch.patch_min_max == (IrisVersion("v1.0.1"), IrisVersion("v1.2.1")) class TestNextRelease: From 6df3f2a91d1109acb757310a5f96d3c69c3dac6c Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 12:32:27 +0100 Subject: [PATCH 12/26] Linting. --- tools/test_release_do_nothing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 1c297b4e07..065dcac7cf 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -993,11 +993,11 @@ def test_waits(self, latest: bool, rc: bool, more_patches: bool, mocker): self.mock_wait_for_done.call_args_list, expected_messages, ): - expected = expected.format( + expected_str = expected.format( public=re.escape(self.instance.version.public), version=re.escape(str(self.instance.version)), ) - assert_input_msg_regex(call, expected) + assert_input_msg_regex(call, expected_str) def test_original_rc_branch_name(self, mocker): self.instance.git_tag = "v2.1.0rc0" From 5ad71a21639abee6364c3aa44736f409a3be7ef6 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 12:43:16 +0100 Subject: [PATCH 13/26] Rename series to minor_series. --- tools/release_do_nothing.py | 52 +++++++++++++++----------------- tools/test_release_do_nothing.py | 18 +++++------ 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 899e3cb5ee..c5481389ec 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -34,14 +34,12 @@ def __str__(self): return f"v{super().__str__()}" @property - def series(self) -> str: - # TODO: find an alternative word which is meaningful to everyone - # while not being ambiguous. + def minor_series(self) -> str: return f"v{self.major}.{self.minor}" @property def branch(self) -> str: - return f"{self.series}.x" + return f"{self.minor_series}.x" class IrisRelease(Progress): @@ -246,18 +244,18 @@ def is_release_candidate(self) -> bool: @property def first_in_series(self) -> bool: - return self.version.series not in [v.series for v in self._get_tagged_versions()] + return self.version.minor_series not in [v.minor_series for v in self._get_tagged_versions()] 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 series." + "to multiple minor_series." ) self.print(message) tagged_versions = self._get_tagged_versions() - series_all = [v.series for v in sorted(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)) @@ -282,7 +280,7 @@ def numbers_to_new_patches( return None def series_new_patch(series: str) -> str: - latest = max(v for v in tagged_versions if v.series == series) + latest = max(v for v in tagged_versions if v.minor_series == series) iris_version = IrisVersion( f"{latest.major}.{latest.minor}.{latest.micro + 1}" ) @@ -294,7 +292,7 @@ def series_new_patch(series: str) -> str: key="patch_min_max_tag", message=( f"{series_numbered}\n\n" - "Input the earliest and latest series that need patching." + "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, @@ -326,7 +324,7 @@ 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.series < self.patch_min_max[1].series + self.version.minor_series < self.patch_min_max[1].minor_series ) def apply_patches(self): @@ -393,18 +391,18 @@ def validate(self) -> None: if self.first_in_series: message_pre = ( - f"No previous releases found in the {self.version.series} series." + 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 series." + 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 series is expected " + 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?" ) @@ -418,12 +416,12 @@ def validate(self) -> None: "Release tag": self.git_tag, "Release type": self.release_type.name, "Release candidate?": self.is_release_candidate, - f"First release in {self.version.series} series?": self.first_in_series, + 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["Series being patched"] = ( - f"{self.patch_min_max[0].series} to {self.patch_min_max[1].series}" + 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" @@ -556,7 +554,7 @@ def whats_news(self) -> WhatsNewRsts: return self.WhatsNewRsts( latest=latest, - release=whatsnew_dir / (self.version.series[1:] + ".rst"), + release=whatsnew_dir / (self.version.minor_series[1:] + ".rst"), index_=whatsnew_dir / "index.rst", template=latest.with_suffix(".rst.template"), ) @@ -595,7 +593,7 @@ def finalise_whats_new(self): if not self.release_type is self.ReleaseTypes.PATCH: whatsnew_title = ( - f"{self.version.series} ({datetime.today().strftime('%d %b %Y')}" + f"{self.version.minor_series} ({datetime.today().strftime('%d %b %Y')}" ) if self.is_release_candidate: whatsnew_title += " [release candidate]" @@ -618,7 +616,7 @@ def finalise_whats_new(self): ) self.wait_for_done(message) - dropdown_title = f"\n{self.version.series} Release Highlights\n" + 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}" @@ -627,7 +625,7 @@ def finalise_whats_new(self): message = ( f"Review {self.whats_news.release.name} to ensure it is a good " - f"reflection of what is new in {self.version.series}.\n" + 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 " @@ -979,7 +977,7 @@ def update_conda_forge(self): 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.series} releases)." + f"(check previous {self.version.minor_series} releases)." ) self.wait_for_done(message) @@ -1127,7 +1125,7 @@ def bluesky_announce(self): if not self.first_in_series: message += ( f"Consider replying within an existing " - f"{self.version.series} " + f"{self.version.minor_series} " "announcement thread, if appropriate." ) self.wait_for_done(message) @@ -1142,15 +1140,15 @@ def merge_back(self): def next_series_patch() -> IrisVersion: tagged_versions = self._get_tagged_versions() - series_all = sorted(set(v.series for v in tagged_versions)) + series_all = sorted(set(v.minor_series for v in tagged_versions)) try: - next_series = series_all[series_all.index(self.version.series) + 1] + next_series = series_all[series_all.index(self.version.minor_series) + 1] except (IndexError, ValueError): - message = f"Error finding next series after {self.version.series} ." + 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.series == next_series + 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}" @@ -1158,7 +1156,7 @@ def next_series_patch() -> IrisVersion: if self.more_patches_after_this_one: message = ( - "More series need patching. Merge into the next series' branch ..." + "More minor_series need patching. Merge into the next minor_series' branch ..." ) self.print(message) next_patch = next_series_patch() diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 065dcac7cf..3a9f2b8eca 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -101,9 +101,9 @@ def test_str(self): expecteds = {"9.0.0": "v9.0.0", "9.0.1": "v9.0.1", "9.1.0": "v9.1.0"} assert str(self.version) == expecteds[self.input_str] - def test_series(self): + def test_minor_series(self): expecteds = {"9.0.0": "v9.0", "9.0.1": "v9.0", "9.1.0": "v9.1"} - assert self.version.series == expecteds[self.input_str] + assert self.version.minor_series == expecteds[self.input_str] def test_branch(self): expecteds = {"9.0.0": "v9.0.x", "9.0.1": "v9.0.x", "9.1.0": "v9.1.x"} @@ -181,8 +181,8 @@ def test_more_patches_after_this_one(self, git_tag): expecteds = { "8.1.0": False, # Not a PATCH release. "8.1.1": True, # 9.0.0 still to patch. - "9.0.1": False, # Last PATCH in series. - "9.1.1": False, # Beyond max series. + "9.0.1": False, # Last PATCH in minor_series. + "9.1.1": False, # Beyond max minor_series. } expected = expecteds[git_tag] self.instance.git_tag = git_tag @@ -328,13 +328,13 @@ def test_not_patch_release(self): assert self.instance.patch_min_max_tag is None def test_patch_single_series(self, mocker): - # PATCH release, user doesn't want to patch multiple series + # PATCH release, user doesn't want to patch multiple minor_series mock_inputs(mocker, "1,1") self.instance.get_all_patches() assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.1.1") def test_patch_multiple_series(self, mocker): - # User selects a range of series to patch + # User selects a range of minor_series to patch mock_inputs(mocker, "1,2") self.instance.get_all_patches() assert self.instance.patch_min_max_tag == ("v1.1.1", "v1.2.1") @@ -649,7 +649,7 @@ def common_test(self, git_tag, expected_messages): self.mock_wait_for_done.call_args_list, expected_messages, ): - expected = expected.format(series=re.escape(self.instance.version.series[1:])) + expected = expected.format(series=re.escape(self.instance.version.minor_series[1:])) assert_input_msg_regex(call, expected) def test_first_in_series(self): @@ -789,7 +789,7 @@ def test_default(self, latest: bool, rc: bool): git_tag += "rc0" self.instance.git_tag = git_tag self.instance.check_rtd() - series = re.escape(self.instance.version.series) + series = re.escape(self.instance.version.minor_series) expected_messages = [ "Visit https://readthedocs.org/projects/scitools-iris/versions/", rf"{series}.* to Active, un-Hidden", @@ -1210,7 +1210,7 @@ def test_next_series_error(self, mocker): "_get_tagged_versions", return_value=[IrisVersion("v1.0.0")], ) - with pytest.raises(RuntimeError, match="Error finding next series"): + with pytest.raises(RuntimeError, match="Error finding next minor_series"): self.instance.merge_back() def test_next_patch_file(self): From 874c69eda3fcab6f20445f2296f26e2f352cecbc Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 12:47:06 +0100 Subject: [PATCH 14/26] Nox install nothing. --- noxfile.py | 1 + tools/test_release_do_nothing.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 46137f99b4..2d4f78b35c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -180,6 +180,7 @@ def tests(session: nox.sessions.Session): """ prepare_venv(session) session.install("--no-deps", "--editable", ".") + session.install("git+https://github.com/SciTools-incubator/nothing.git") session.env.update(ENV) run_args = [ "pytest", diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 3a9f2b8eca..ad80e1aa09 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -4,7 +4,6 @@ # See LICENSE in the root of the repository for full licensing details. """Tests for the ``release_do_nothing.py`` file.""" import enum -from datetime import datetime from pathlib import Path import re from typing import Any, NamedTuple From 03aa650c60c93a508288ba74dca2737061ac28c2 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 12:47:40 +0100 Subject: [PATCH 15/26] Commented code. --- tools/release_do_nothing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index c5481389ec..c28cd81c42 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -63,7 +63,6 @@ def get_cmd_description(cls) -> str: def get_steps(cls) -> list[typing.Callable[..., None]]: return [ cls.analyse_remotes, - # cls.parse_tags, cls.get_release_tag, cls.get_all_patches, cls.apply_patches, From b4083023c9d8a3e2d4ee3d6686189d1011165fc0 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 13:48:26 +0100 Subject: [PATCH 16/26] Update RTD instructions. --- tools/release_do_nothing.py | 10 +++++++--- tools/test_release_do_nothing.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index c28cd81c42..e6ed65c832 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -748,15 +748,19 @@ def check_rtd(self): self.print("Read the Docs checks ...") message = ( - "Visit https://readthedocs.org/projects/scitools-iris/versions/ " + "Visit https://app.readthedocs.org/projects/scitools-iris/ " "and make sure you are logged in." ) self.wait_for_done(message) - message = f"Set {self.version} to Active, un-Hidden." + 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." + message = f"Set {self.version.branch} to Active, Hidden.\n{add_version}" self.wait_for_done(message) message = ( diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index ad80e1aa09..03e7490692 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -790,7 +790,7 @@ def test_default(self, latest: bool, rc: bool): self.instance.check_rtd() series = re.escape(self.instance.version.minor_series) expected_messages = [ - "Visit https://readthedocs.org/projects/scitools-iris/versions/", + "Visit https://app.readthedocs.org/projects/scitools-iris/", rf"{series}.* to Active, un-Hidden", rf"{series}.* to Active, Hidden", "Keep only the latest 2 branch doc builds active", From 41449534375a73724148f7879d84a711ba9a8d0c Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 13:50:38 +0100 Subject: [PATCH 17/26] Python pin advice. --- tools/release_do_nothing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index e6ed65c832..a8f0c53227 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -859,6 +859,7 @@ def validate(sha256_string: str) -> str | None: 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.public};\n" From 688b0026fa53ac9883efce275c014027748c24b5 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 13:54:29 +0100 Subject: [PATCH 18/26] Change order of git add whats new. --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index a8f0c53227..0c0bb8d797 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -1199,7 +1199,6 @@ def next_series_patch() -> IrisVersion: "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) @@ -1220,6 +1219,7 @@ def next_series_patch() -> IrisVersion: 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};" From 6f2ad3399e6dfc4e62f64a33cf35cf90c836d345 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 13:58:00 +0100 Subject: [PATCH 19/26] conda-forge upstream warning. --- tools/release_do_nothing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 0c0bb8d797..b0a6ce9b8c 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -990,6 +990,8 @@ def update_conda_forge(self): "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};" From 687ed1c55f6da4a21eaea97c13bb85cf34412f67 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 13:58:52 +0100 Subject: [PATCH 20/26] Missing space. --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index b0a6ce9b8c..07932f9a00 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -340,7 +340,7 @@ def apply_patches(self): match patch_branch: case self.version.branch: message = ( - "The patch change(s) are on the ideal branch to avoid later" + "The patch change(s) are on the ideal branch to avoid later " f"Git conflicts: {self.version.branch} . Continue ..." ) case "": From ec886483af29d63f760d9fb6ea890e405ada3301 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:02:01 +0100 Subject: [PATCH 21/26] conda-forge build number handling. --- tools/release_do_nothing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 07932f9a00..3cc51170d5 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -970,6 +970,7 @@ def update_conda_forge(self): f"- The version at the very top of the file: " f"{self.version.public}\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" From 9ed0cef69a4e861b530f3794ec4b01d1ca2571b7 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:05:31 +0100 Subject: [PATCH 22/26] Better SHA256 url. --- tools/release_do_nothing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 3cc51170d5..60ee56dfa7 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -846,9 +846,9 @@ def validate(sha256_string: str) -> str | None: return result message = ( - f"Visit the below and click `view details` for the Source Distribution" + 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.public}#files\n" + f"https://pypi.org/project/scitools-iris/{self.version.public}##scitools_iris-{self.version.public}.tar.gz\n" ) self.set_value_from_input( key="sha256", From ce368048b5aaa3df7977171868d808e278a42ef8 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:07:19 +0100 Subject: [PATCH 23/26] Check README after re-render. --- tools/release_do_nothing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 60ee56dfa7..63689a8b73 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -1009,7 +1009,8 @@ def update_conda_forge(self): if self.is_release_candidate: readme_url = f"https://github.com/{self.github_user}/iris-feedstock/blob/{self.version}/README.md" rc_evidence = ( - "\n\nConfirm that conda-forge knows your changes are for the " + "\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}" From 0b03181d181576488c0eb9cf58a51fd817aa322b Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:09:45 +0100 Subject: [PATCH 24/26] New Conda URL. --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index 63689a8b73..ccaec318e7 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -1097,7 +1097,7 @@ def update_links(self): "notes:\n\n" f"https://scitools-iris.readthedocs.io/en/{self.version}/\n" f"https://pypi.org/project/scitools-iris/{self.version.public}/\n" - f"https://anaconda.org/conda-forge/iris?version={self.version.public}\n" + f"https://anaconda.org/channels/conda-forge/packages/iris/files?file_q={self.version.public}\n" ) self.wait_for_done(message) From bcc34850142b543052ff998db765c690364f7276 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:12:20 +0100 Subject: [PATCH 25/26] Don't require upstream URLs to end in .git. --- tools/release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release_do_nothing.py b/tools/release_do_nothing.py index ccaec318e7..0d432e599b 100755 --- a/tools/release_do_nothing.py +++ b/tools/release_do_nothing.py @@ -107,7 +107,7 @@ class Remote(typing.NamedTuple): for parts in remotes_split ] - scitools_regex = re.compile(r"github\.com[:/]SciTools/iris\.git") + 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) From 891eb6e182a5073745706c7fb38290c38a5d0bf0 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 23 Apr 2026 14:25:05 +0100 Subject: [PATCH 26/26] Fix test. --- tools/test_release_do_nothing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_release_do_nothing.py b/tools/test_release_do_nothing.py index 03e7490692..d5e0aed870 100644 --- a/tools/test_release_do_nothing.py +++ b/tools/test_release_do_nothing.py @@ -884,7 +884,7 @@ def test_sha256_input(self, mocker, capfd): mock_inputs(mocker, fake_sha) self.instance.check_pypi() out, err = capfd.readouterr() - assert "Visit the below and click `view details`" in out + assert "Visit the below to view the details" in out assert self.instance.sha256 == fake_sha def test_invalid_sha(self, mocker, mock_report_problem):