From 133614e253ebc2b31d8457335f32b5cd7ba6ec8a Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Fri, 22 Aug 2025 13:44:50 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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