diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0e24b7c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +--- +version: 2 +updates: + # GitHub Actions in use by this repository + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + # Python packages in use by this repository + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + # The template workflows demonstrating use _of_ this repository + - package-ecosystem: "github-actions" + directory: "/templates" + schedule: + interval: "weekly" diff --git a/.github/workflows/internal-alias.yaml b/.github/workflows/internal-alias.yaml new file mode 100644 index 0000000..6f54a84 --- /dev/null +++ b/.github/workflows/internal-alias.yaml @@ -0,0 +1,20 @@ +--- +name: 🛠️ Update release alias tags + +run-name: Update alias for ${{ github.event.action }} ${{ github.event.release.name }} + +on: + release: + types: + - published + - deleted + +permissions: + actions: read + contents: write + +jobs: + update-alias: + uses: ./.github/workflows/wf-alias-release.yaml + # Secrets are only required until tool-create-release is made public + secrets: inherit diff --git a/.github/workflows/internal-finalize.yaml b/.github/workflows/internal-finalize.yaml index 43b90bd..026c065 100644 --- a/.github/workflows/internal-finalize.yaml +++ b/.github/workflows/internal-finalize.yaml @@ -1,5 +1,7 @@ --- -name: Finalize release +name: 🛠️ Finalize release + +run-name: Finalize release from branch `${{ github.event.pull_request.head.ref }}` on: pull_request: diff --git a/.github/workflows/internal-prepare.yaml b/.github/workflows/internal-prepare.yaml index b00d3cf..33b8e13 100644 --- a/.github/workflows/internal-prepare.yaml +++ b/.github/workflows/internal-prepare.yaml @@ -1,5 +1,5 @@ --- -name: Prepare new release +name: 📦 Prepare new release run-name: Open PR for new ${{ inputs.bump_type }} release @@ -8,14 +8,15 @@ on: inputs: bump_type: type: choice - description: >- - Semantic version bump type + description: Semantic version bump type required: true options: - major - minor - patch - - prerelease + prerelease: + type: boolean + description: Create a prerelease permissions: actions: read @@ -27,5 +28,6 @@ jobs: uses: ./.github/workflows/wf-prepare-release.yaml with: bump_type: ${{ inputs.bump_type }} - exact_version: '' + prerelease: ${{ inputs.prerelease }} + # Secrets are only required until tool-create-release is made public secrets: inherit diff --git a/.github/workflows/wf-alias-release.yaml b/.github/workflows/wf-alias-release.yaml new file mode 100644 index 0000000..ea9be2f --- /dev/null +++ b/.github/workflows/wf-alias-release.yaml @@ -0,0 +1,55 @@ +--- +on: + workflow_call: + +jobs: + alias-release: + runs-on: ubuntu-latest + + steps: + # Get the version of _this_ repository that is in use so that we can use + # sidecar scripts + - id: workflow-parsing + name: Get SHA of reusuable workflow + env: + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + GH_TOKEN: ${{ github.token }} + run: | + ACTION_DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") + echo "::debug::$ACTION_DATA" + SHA=$(echo "$ACTION_DATA" | jq -r '.referenced_workflows | .[] | select(.path | startswith("uclahs-cds/tool-create-release")).sha') + echo "SHA=$SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout reusable repository + uses: actions/checkout@v4 + with: + repository: uclahs-cds/tool-create-release + path: reusable + ref: ${{ steps.workflow-parsing.outputs.SHA }} + token: ${{ secrets.UCLAHS_CDS_REPO_READ_TOKEN }} + + - name: Checkout calling repository + uses: actions/checkout@v4 + with: + path: caller + fetch-depth: 0 + fetch-tags: true + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # Install the bundled package + - run: pip install ./reusable + + # Update the alias if necessary + - id: alias-release + run: | + git config --file "$REPO_DIR/.git/config" user.name "github-actions[bot]" + git config --file "$REPO_DIR/.git/config" user.email "41898282+github-actions[bot]@users.noreply.github.com" + alias-release "$REPO_DIR" "$GITHUB_REF" + env: + REPO_DIR: caller + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/wf-finalize-release.yaml b/.github/workflows/wf-finalize-release.yaml index 4fda56d..f61c7a1 100644 --- a/.github/workflows/wf-finalize-release.yaml +++ b/.github/workflows/wf-finalize-release.yaml @@ -38,6 +38,8 @@ jobs: env: INPUT_DRAFT: ${{ inputs.draft }} with: + # Use the separate token so that `published` events will be fired + github-token: ${{ secrets.UCLAHS_CDS_REPO_READ_TOKEN }} script: | const script = require('./scripts/finalize-release.js') await script({github, context, core}) diff --git a/.github/workflows/wf-prepare-release.yaml b/.github/workflows/wf-prepare-release.yaml index 5009472..8c6eb08 100644 --- a/.github/workflows/wf-prepare-release.yaml +++ b/.github/workflows/wf-prepare-release.yaml @@ -9,11 +9,16 @@ on: default: CHANGELOG.md bump_type: type: string - description: Semantic version bump type. Must be one of `major`, `minor`, `patch`, `prerelease`, or `exact`. Using the first four options will compute the next appropriate semantic version tag based on the most recent tag available from the main branch. Using `exact` is required for repositories without semantic version tags and allows specifying the exact next tag to use with the `exact_version` argument. + description: Semantic version bump type. Must be one of `major`, `minor`, `patch`, or `exact`. Using the first three options will compute the next appropriate semantic version tag based on the most recent tag available from the main branch. Using `exact` is required for repositories without semantic version tags and allows specifying the exact next tag to use with the `exact_version` argument. required: true + prerelease: + type: boolean + description: Mark this semantic version bump as a pre-release. Only used if bump_type is not set to `exact`. + required: false + default: false exact_version: type: string - description: Exact version number to target. Only used if bump_type is set to `exact`. + description: Exact non-semantic version number to target. Only used if bump_type is set to `exact`. required: false default: "" timezone: @@ -28,6 +33,7 @@ jobs: env: BUMP_TYPE: ${{ inputs.bump_type }} + PRERELEASE: ${{ inputs.prerelease }} EXACT_VERSION: ${{ inputs.exact_version }} CHANGELOG_TIMEZONE: ${{ inputs.timezone }} @@ -71,7 +77,7 @@ jobs: # Get the next version using the package's script - id: get-next-version - run: get-next-version "$REPO_DIR" "$BUMP_TYPE" "$EXACT_VERSION" + run: get-next-version "$REPO_DIR" "$BUMP_TYPE" "$PRERELEASE" "$EXACT_VERSION" env: REPO_DIR: caller diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a76d96..e5d2300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Documentation that versions must begin with a digit - Documentation that git tags must begin with a `v` +- Template workflows to copy +- Enable Dependabot for GitHub Actions, pip, and template workflows +- Unit tests for version updates +- Workflow to update major tag (e.g. `v2`) when new releases are published or deleted +- Unit tests for aliasing + +### Changed + +- Change `prerelease` from an input "bump type" to a separate boolean +- Create "prerelease" GitHub Releases from prerelease versions ### Fixed diff --git a/README.md b/README.md index 308d3cc..3ebb407 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Automations for GitHub Releases -This pair of reusable workflows manage the complexity of creating and tagging new software releases on GitHub. +This set of reusable workflows manage the complexity of creating and tagging new software releases on GitHub. ## Versioning Standards @@ -15,97 +15,64 @@ These workflows make the following assumptions: ## Usage +Usage of this tool requires adding three workflows to each calling repository (non-semantic repositories only need two). Complete versions of these workflows can be copied from the [templates/](templates/) directory. + `wf-prepare-release.yaml` is triggered manually (via a `workflow_dispatch`) and takes the following actions: -1. Compute the target version number based on existing tags and user input for `major`/`minor`/`patch`/`prerelease`. +1. Compute the target version number. + * Semantic repositories compute the version based on existing tags and user input for the bump type (`major`/`minor`/`patch`) and the prerelease flag. + * Non-semantic repositories accept the next version as an input. 1. Re-write the `CHANGELOG.md` file to move unreleased changes into a new dated release section. 1. Open a PR listing the target version number and release tag. +```mermaid + gitGraph + commit id: " " tag: "v1" tag: "v1.0.0" + commit id: " " + commit id: " " + + checkout main + branch prepare_patch + commit id: "target: v1.0.1" +``` + `wf-finalize-release.yaml`, triggered when a release PR is merged, takes the following actions: 1. Create a new release with auto-generated notes and the target tag. * By default the new release is a draft, so no public release or tag are created without user intervention. 1. Comment on the release PR with a link to the new release. -## Example Usage - -Usage of this tool requires adding two workflows to each calling repository: - -**`prepare-release.yaml`** - -```yaml ---- -name: Prepare new release - -run-name: Open PR for new ${{ inputs.bump_type }} release - -on: - workflow_dispatch: - inputs: - bump_type: - type: choice - description: >- - Semantic version bump type. Using `exact` is required for repositories - without semantic version tags and allows specifying the exact next tag - to use with the `exact_version` argument. - required: true - options: - - major - - minor - - patch - - prerelease - - exact - exact_version: - type: string - description: >- - Exact version number to target. Only used if bump_type is set to - `exact`. Do not include a leading `v`. - required: false - default: '' - -permissions: - actions: read - contents: write - pull-requests: write - -jobs: - prepare-release: - uses: uclahs-cds/tool-create-release/.github/workflows/wf-prepare-release.yaml@v1 - with: - bump_type: ${{ inputs.bump_type }} - exact_version: ${{ inputs.exact_version }} - # Secrets are only required until tool-create-release is made public - secrets: inherit +```mermaid + gitGraph + commit id: " " tag: "v1" tag: "v1.0.0" + commit id: " " + commit id: " " + + checkout main + branch prepare_patch + commit id: "target: v1.0.1" + + checkout main + merge prepare_patch tag: "v1.0.1" ``` -**`finalize-release.yaml`** - -```yaml ---- -name: Finalize release - -on: - pull_request: - branches: - - main - types: - - closed - -permissions: - actions: read - contents: write - pull-requests: write - -jobs: - finalize-release: - # This conditional ensures that the reusable workflow is only run for - # release pull requests. The called workflow repeats these checks. - if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'automation-create-release') }} - uses: uclahs-cds/tool-create-release/.github/workflows/wf-finalize-release.yaml@v1 - with: - draft: true - # Secrets are only required until tool-create-release is made public - secrets: inherit +> [!NOTE] +> `wf-alias-release.yaml` is only applicable for repositories using semantic versioning. + +`wf-alias-release.yaml`, triggered when a release is published or deleted, synchronizes the corresponding [major tag alias](https://docs.github.com/en/actions/sharing-automations/creating-actions/about-custom-actions#using-tags-for-release-management) to the highest non-prerelease release tag: + +```mermaid + gitGraph + commit id: " " tag: "v1.0.0" + commit id: " " + commit id: " " + + checkout main + branch prepare_patch + commit id: "target: v1.0.1" + + checkout main + merge prepare_patch tag: "v1" tag: "v1.0.1" ``` ## Parameters @@ -114,7 +81,8 @@ Parameters can be specified using the [`with`](https://docs.github.com/en/action | Workflow | Parameter | Type | Required | Description | | ---- | ---- | ---- | ---- | ---- | -| `wf-prepare-release.yaml` | `bump_type` | string | yes | Kind of semantic release version to target. Must be one of `major`, `minor`, `patch`, `prerelease`, or `exact`. Using `exact` requires `exact_version`. | +| `wf-prepare-release.yaml` | `bump_type` | string | yes | Kind of semantic release version to target. Must be one of `major`, `minor`, `patch`, or `exact`. Using `exact` requires `exact_version`. | +| `wf-prepare-release.yaml` | `prerelease` | boolean | no | If true, mark the bumped semantic release as a prerelease (only used if `bump_type` is not `exact`). | | `wf-prepare-release.yaml` | `exact_version` | string | no | The exact version to assign to the next release (only used if `bump_type` is `exact`). Must not include a leading `v` - use `1XXXX`, not `v1XXXX`. | | `wf-prepare-release.yaml` | `changelog` | string | no | Relative path to the CHANGELOG file. Defaults to `./CHANGELOG.md`. | | `wf-prepare-release.yaml` | `timezone` | string | no | IANA timezone to use when calculating the current date for the CHANGELOG. Defaults to `America/Los_Angeles`. | diff --git a/bumpchanges/alias.py b/bumpchanges/alias.py new file mode 100644 index 0000000..3272563 --- /dev/null +++ b/bumpchanges/alias.py @@ -0,0 +1,257 @@ +"""Create a major version alias for a semantic version release.""" + +import argparse +import logging +import re +import subprocess +import sys + +from pathlib import Path + +import semver + +from .logging import setup_logging, NOTICE, LoggingMixin +from .utils import dereference_tags, tag_to_semver, get_github_releases, Release + + +class IneligibleAliasError(Exception): + """ + Exception to major alias shouldn't be updated. + + These are expected and handle cases like prereleases, outdated tags, etc. + """ + + +class AliasError(Exception): + """ + Exception indicating that something failed while updating the alias. + + These are never expected. + """ + + +class ReleaseAliaser(LoggingMixin): + """A class to manage aliasing release tags.""" + + def __init__(self, repo_dir: Path): + super().__init__() + + self.logger.debug("Creating ReleaseAliaser") + + self.repo_dir = repo_dir + + # Map between existing tags and commit hashes, with annotated tags + # dereferenced + self.tag_to_commit_map: dict[str, str] = {} + + # Tags associated with a release on GitHub + self.tag_to_release_map: dict[str, Release] = {} + + # Map between existing tags and semantic versions + self.tag_to_version_map: dict[str, semver.version.Version] = {} + + # Fill in all data + for tag, commit in dereference_tags(self.repo_dir).items(): + self._add_git_tag(tag, commit) + + for release in get_github_releases(self.repo_dir): + self._add_github_release(release) + + def assert_invariants(self): + """Confirm that the collected data is in a reasonable state.""" + release_keys = self.tag_to_release_map.keys() + commit_keys = self.tag_to_commit_map.keys() + version_keys = self.tag_to_version_map.keys() + + # All releases must have corresponding git tags + if unknown_tags := release_keys - commit_keys: + raise AliasError( + f"GitHub reports tags that are not visible locally: {unknown_tags}" + ) + + # All semantic version tags must also be git tags + if unknown_tags := version_keys - commit_keys: + raise AliasError( + f"Invalid data state - non-git version tags exist: {unknown_tags}" + ) + + # Issue warnings about SemVer tags not associated with a release + for tag in sorted(version_keys - release_keys): + self.logger.warning( + "SemVer tag `%s` does not have a matching GitHub Release.", tag + ) + + # Issue warnings about releases not associated with SemVer tags + for tag in sorted(release_keys - version_keys): + release = self.tag_to_release_map[tag] + self.logger.warning( + "Github Release `%s` uses the non-SemVer tag `%s`. " + "All Releases should use SemVer tags.", + release.name, + tag, + ) + + def _add_git_tag(self, tag: str, commit: str): + """Shim method to make it easier to test.""" + self.logger.debug("Registering git tag `%s` at commit `%s`", tag, commit) + self.tag_to_commit_map[tag] = commit + + try: + self.tag_to_version_map[tag] = tag_to_semver(tag) + except ValueError as err: + self.logger.info(err) + + def _add_github_release(self, release: Release): + """Shim method to make it easier to test.""" + self.tag_to_release_map[release.tagName] = release + + def compute_alias_action(self, major_version: int) -> tuple[str, str]: + """ + Return a tuple of (alias, target) strings showing the necessary change. + + An example return value is ("v2", "v2.1.0"), meaning that the tag "v2" + should be updated to point to the existing tag "v2.1.0". + """ + self.assert_invariants() + + target_alias = f"v{major_version}" + + # Find all semantic version tags that are associated with GitHub releases + eligible_tags = [] + + for tag in self.tag_to_commit_map: + # Ignore non-semantic-version tags + if not (version := self.tag_to_version_map.get(tag)): + continue + + # Ignore non-GitHub-Release tags + if not (release := self.tag_to_release_map.get(tag)): + continue + + # Ignore prereleases (either SemVer or GitHub), drafts, and + # different major releases + if ( + release.isDraft + or release.isPrerelease + or version.prerelease + or version.major != major_version + ): + continue + + eligible_tags.append(tag) + + eligible_tags.sort(key=lambda x: self.tag_to_version_map[x]) + + if not eligible_tags: + raise IneligibleAliasError( + "No eligible release tags for alias `{target_alias}`" + ) + + target_tag = eligible_tags[-1] + self.logger.info("Alias `%s` should point to `%s`", target_alias, target_tag) + + return (target_alias, target_tag) + + def update_alias(self, target_alias: str, target_tag: str): + """Actually update the alias and push it to GitHub.""" + + if aliased_commit := self.tag_to_commit_map.get(target_alias): + other_tags = [ + tag + for tag, commit in self.tag_to_commit_map.items() + if commit == aliased_commit and tag != target_alias + ] + + if target_tag in other_tags: + self.logger.log( + NOTICE, "Alias `%s` is already up-to-date!", target_alias + ) + return + + if other_tags: + self.logger.info( + "Alias `%s` currently points to tag(s): %s (commit %s)", + target_alias, + other_tags, + aliased_commit, + ) + else: + self.logger.info( + NOTICE, + "Alias `%s` currently points to untagged commit: %s", + target_alias, + aliased_commit, + ) + + else: + self.logger.info("Alias `%s` does not exist", target_alias) + + # Create the tag locally (forcing if necessary) + subprocess.run( + [ + "git", + "tag", + "--force", + "--annotate", + "--message", + f"Update major tag {target_alias} to point to {target_tag}", + target_alias, + target_tag, + ], + cwd=self.repo_dir, + check=True, + ) + + # Push the tag to GitHub + subprocess.run( + ["git", "push", "--force", "origin", target_alias], + cwd=self.repo_dir, + check=True, + ) + + self.logger.log( + NOTICE, + "Alias `%s` updated to `%s` (commit %s)", + target_alias, + target_tag, + self.tag_to_commit_map[target_tag], + ) + + +def entrypoint(): + """Main entrypoint for this module.""" + setup_logging() + + parser = argparse.ArgumentParser() + parser.add_argument("repo_dir", type=Path) + parser.add_argument("changed_ref", type=str) + + args = parser.parse_args() + + if not (tag_re := re.match(r"^refs/tags/([^/]+)$", args.changed_ref)): + logging.getLogger(__name__).log( + NOTICE, + "Ref `%s` is not a tag - this workflow should not have been called", + args.changed_ref, + ) + sys.exit(1) + + try: + changed_version = tag_to_semver(tag_re.group(1)) + except ValueError: + logging.getLogger(__name__).log( + NOTICE, + "Tag `%s` is not a semantic version - not updating any aliases", + tag_re.group(1), + ) + sys.exit(0) + + if changed_version.major < 1: + logging.getLogger(__name__).log( + NOTICE, "This workflow only updates `v1` and above" + ) + sys.exit(0) + + aliaser = ReleaseAliaser(args.repo_dir) + alias, tag = aliaser.compute_alias_action(changed_version.major) + aliaser.update_alias(alias, tag) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 66adc13..ff1fd8a 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -14,6 +14,7 @@ from markdown_it.token import Token from .logging import NOTICE +from .utils import version_to_tag_str class ChangelogError(Exception): @@ -89,7 +90,7 @@ def heading(level: int, children: list): @dataclass -class Version: +class ChangelogVersion: """Class to help manage individual releases within CHANGELOG.md files.""" # Regex to match versions with embedded links, with or without dates @@ -99,7 +100,7 @@ class Version: # [1.2.3](https://foo.bar) # [badversion](https://foo.bar) link_heading_re: ClassVar = re.compile( - r"^\[(?P.+?)\]\((?:.+?)\)(?:\s+-\s+(?P.*))?$" + r"^\[(?P.+?)\]\((?:.+?)\)(?:\s+-\s+(?P.*))?$" ) # Regex to match versions, with or without dates @@ -110,7 +111,7 @@ class Version: # 1.2.3 # badversion heading_re: ClassVar = re.compile( - r"^\[?(?P.+?)\]?(?:\s+-\s+(?P.*))?$" + r"^\[?(?P.+?)\]?(?:\s+-\s+(?P.*))?$" ) # Regex to match versions with leading `v`s (for removal) @@ -129,7 +130,7 @@ class Version: UNRELEASED_VERSION: ClassVar = "Unreleased" - version: str + version_str: str date: Optional[str] = None link: Optional[str] = None @@ -146,12 +147,12 @@ class Version: @classmethod def blank_unreleased(cls): """Create a new empty Unreleased version.""" - return cls(version=cls.UNRELEASED_VERSION) + return cls(version_str=cls.UNRELEASED_VERSION) @classmethod def from_tokens(cls, tokens): """ - Parse a Version from a token stream. + Parse a ChangelogVersion from a token stream. Leading `v`s will be stripped from the version name. """ @@ -175,15 +176,18 @@ def from_tokens(cls, tokens): else: raise ChangelogError(f"Invalid section heading: {tokens[1].content}") - logging.getLogger(__name__).info("Parsed version: %s", kwargs.get("version")) + logging.getLogger(__name__).info( + "Parsed version: %s", kwargs.get("version_str") + ) # Strip any leading `v`s from versions, as long as they are followed by # a digit - if cls.leading_v_re.match(kwargs["version"]): + if cls.leading_v_re.match(kwargs["version_str"]): logging.getLogger(__name__).warning( - "Stripping leading `v` from Changelog version `%s`", kwargs["version"] + "Stripping leading `v` from Changelog version `%s`", + kwargs["version_str"], ) - kwargs["version"] = kwargs["version"][1:] + kwargs["version_str"] = kwargs["version_str"][1:] # The rest of the tokens should be the lists. Strip any rulers now. tokens = [token for token in tokens[3:] if token.type != "hr"] @@ -237,17 +241,17 @@ def from_tokens(cls, tokens): return cls(**kwargs) def serialize(self): - """Yield a stream of markdown tokens describing this Version.""" + """Yield a stream of markdown tokens describing this ChangelogVersion.""" link_kwargs = {} if self.link: link_kwargs["attrs"] = {"href": self.link} else: - link_kwargs["meta"] = {"label": self.version} + link_kwargs["meta"] = {"label": self.version_str} heading_children = [ Token("link_open", tag="a", nesting=1, **link_kwargs), - Token("text", tag="", nesting=0, level=1, content=self.version), + Token("text", tag="", nesting=0, level=1, content=self.version_str), Token("link_close", tag="a", nesting=-1), ] @@ -332,7 +336,7 @@ def __init__(self, changelog_file: Path, repo_url: str): if nexttoken is None: raise ChangelogError() - if Version.wrong_h1_re.match(nexttoken.content): + if ChangelogVersion.wrong_h1_re.match(nexttoken.content): token.tag = "h2" logger.log( NOTICE, "Changing `%s` from h1 to h2", nexttoken.content @@ -345,35 +349,39 @@ def __init__(self, changelog_file: Path, repo_url: str): if nexttoken is None: raise ChangelogError() - if Version.wrong_h2_re.match(nexttoken.content): + if ChangelogVersion.wrong_h2_re.match(nexttoken.content): token.tag = "h3" logger.log( NOTICE, "Changing `%s` from h2 to h3", nexttoken.content ) else: - # Split split these tokens off into a new Version + # Split split these tokens off into a new ChangelogVersion groups.append([]) groups[-1].append(token) self.header = [token for token in groups.pop(0) if token.tag != "hr"] - self.versions = [Version.from_tokens(group) for group in groups] + self.versions = [ChangelogVersion.from_tokens(group) for group in groups] if not self.versions: raise ChangelogError("No versions!") def update_version(self, next_version: str, date: datetime.date): """Move all unreleased changes under the new version.""" - if not self.versions or self.versions[0].version != Version.UNRELEASED_VERSION: + if ( + not self.versions + or self.versions[0].version_str != ChangelogVersion.UNRELEASED_VERSION + ): logging.getLogger(__name__).warning( - "No %s section - adding a new empty section", Version.UNRELEASED_VERSION + "No %s section - adding a new empty section", + ChangelogVersion.UNRELEASED_VERSION, ) - self.versions.insert(0, Version.blank_unreleased()) + self.versions.insert(0, ChangelogVersion.blank_unreleased()) # Change the version and date of the unreleased section. For now # explicitly assume UTC, but that should probably be an input. - self.versions[0].version = next_version + self.versions[0].version_str = next_version self.versions[0].date = date.isoformat() def render(self) -> str: @@ -397,12 +405,10 @@ def render(self) -> str: prior_tag = None for version in reversed(self.versions): - if version.version == Version.UNRELEASED_VERSION: + if version.version_str == ChangelogVersion.UNRELEASED_VERSION: this_tag = None else: - # _Do_ add leading `v`s. Versions numbers never have - # leading `v`s, tags always have leading `v`s. - this_tag = f"v{version.version.lstrip('v')}" + this_tag = version_to_tag_str(version.version_str) if prior_tag: href = f"{self.repo_url}/compare/{prior_tag}...{this_tag if this_tag else 'HEAD'}" @@ -411,7 +417,7 @@ def render(self) -> str: else: href = f"{self.repo_url}/commits/HEAD" - refs[version.version] = {"href": href, "title": ""} + refs[version.version_str] = {"href": href, "title": ""} prior_tag = this_tag diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 045122e..0a5ad2a 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -3,69 +3,82 @@ import argparse import re import os -import subprocess from logging import getLogger from pathlib import Path -import semver from .logging import setup_logging, NOTICE +from .utils import get_closest_semver_ancestor, version_to_tag_str, tag_exists -def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: - """Return the next tag after the appropriate bump type.""" +def get_next_semver(repo_dir: Path, bump_type: str, prerelease: bool) -> str: + """Validate and return the next semantic version.""" logger = getLogger(__name__) - if bump_type == "exact": - last_version = "" - if not exact_version: - logger.error("Exact version requested, but no version supplied!") - raise RuntimeError() + last_version = get_closest_semver_ancestor(repo_dir, allow_prerelease=False) + next_version = last_version.next_version(part=bump_type) - if re.match(r"^v\d", exact_version): - logger.error( - "Input version `{exact_version}` should not have a leading `v`" - ) - raise RuntimeError() + if prerelease: + next_version = next_version.bump_prerelease() + # Look for the next non-existing prerelease version + while tag_exists(repo_dir, version_to_tag_str(next_version)): + logger.debug("Prerelease %s already exists, bumping again...", next_version) + next_version = next_version.bump_prerelease() - next_version = exact_version + next_version_str = str(next_version) + validate_version_bump(repo_dir, str(last_version), next_version_str) + return next_version_str - else: - # Get the most recent ancestor tag that matches r"v\d.*" - try: - last_tag = subprocess.check_output( - ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], - cwd=repo_dir, - ).decode("utf-8") - except subprocess.CalledProcessError: - # It seems that this is the first release - last_tag = "v0.0.0" - logger.warning("No prior tag found! Defaulting to %s", last_tag) - - # Strip off the leading v when parsing the version - last_version = semver.Version.parse(last_tag[1:]) - next_version = str(last_version.next_version(part=bump_type)) - - logger.info("%s -> %s -> %s", last_version, bump_type, next_version) - next_tag = f"v{next_version}" - logger.log(NOTICE, "New version (tag): %s (%s)", next_version, next_tag) + +def get_exact_version(repo_dir: Path, exact_version) -> str: + """Validate the specified exact version.""" + logger = getLogger(__name__) + + if not exact_version: + logger.error("Exact version requested, but no version supplied!") + raise RuntimeError() + + if not re.match(r"^\d", exact_version): + logger.error("Input version `{exact_version}` does not start with a digit") + raise RuntimeError() + + validate_version_bump(repo_dir, "", exact_version) + return exact_version + + +def validate_version_bump( + repo_dir: Path, prior_version_str: str, next_version_str: str +): + """Validate that the proposed version is acceptable.""" + logger = getLogger(__name__) + + logger.info("%s -> %s", prior_version_str, next_version_str) + next_tag = version_to_tag_str(next_version_str) + logger.log(NOTICE, "New version (tag): %s (%s)", next_version_str, next_tag) # Confirm that the corresponding git tag does not exist - tag_ref_proc = subprocess.run( - ["git", "rev-parse", "--verify", f"refs/tags/{next_tag}"], - cwd=repo_dir, - capture_output=True, - check=False, - ) - if tag_ref_proc.returncode == 0: + if tag_exists(repo_dir, next_tag): # Oops, that tag does exist - logger.error( - "Tag %s already exists! %s", next_tag, tag_ref_proc.stdout.decode("utf-8") - ) + logger.error("Tag %s already exists!", next_tag) raise RuntimeError() - return next_version + +def str_to_bool(value: str) -> bool: + """Convert a string to a boolean (case-insensitive).""" + truthy_values = {"true", "t", "yes", "y", "1"} + falsey_values = {"false", "f", "no", "n", "0"} + + # Normalize input to lowercase + value = value.lower() + + if value in truthy_values: + return True + + if value in falsey_values: + return False + + raise argparse.ArgumentTypeError(f"Invalid boolean value: '{value}'") def entrypoint(): @@ -74,13 +87,19 @@ def entrypoint(): parser = argparse.ArgumentParser() parser.add_argument("repo_dir", type=Path) - parser.add_argument("bump_type", type=str) + parser.add_argument( + "bump_type", type=str, choices=("major", "minor", "patch", "exact") + ) + parser.add_argument("prerelease", type=str_to_bool) parser.add_argument("exact_version", type=str) args = parser.parse_args() setup_logging() - next_version = get_next_version(args.repo_dir, args.bump_type, args.exact_version) + if args.bump_type == "exact": + next_version = get_exact_version(args.repo_dir, args.exact_version) + else: + next_version = get_next_semver(args.repo_dir, args.bump_type, args.prerelease) Path(os.environ["GITHUB_OUTPUT"]).write_text( f"next_version={next_version}\n", encoding="utf-8" diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 2b9a5f0..b2f5889 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -42,3 +42,18 @@ def setup_logging(): root_logger = logging.getLogger(__name__.rpartition(".")[0]) root_logger.addHandler(handler) root_logger.setLevel(logging.DEBUG) + + +class LoggingMixin: + """A mixin class for logging.""" + + # pylint: disable=too-few-public-methods + + @property + def logger(self) -> logging.Logger: + """Create and return a logger for instance or class.""" + if not hasattr(self, "_logger") or not self._logger: + self._logger = logging.getLogger( + f"{self.__class__.__module__}.{self.__class__.__name__}" + ) + return self._logger diff --git a/bumpchanges/utils.py b/bumpchanges/utils.py new file mode 100644 index 0000000..cbc44c9 --- /dev/null +++ b/bumpchanges/utils.py @@ -0,0 +1,191 @@ +"""Utility functions.""" + +import logging +import json +import operator +import re +import subprocess +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Union + +import semver + +from .logging import NOTICE + + +@dataclass +class Release: + """A representation of a GitHub release.""" + + # These names match the attributes returned by the GitHub API + # pylint: disable=invalid-name + name: str + tagName: str + + isDraft: bool + isPrerelease: bool + + +def tag_to_semver(tag: str) -> semver.version.Version: + """ + Return the Version associated with this git tag. + + Raises ValueError for invalid tags. + """ + if not tag.startswith("v"): + raise ValueError(f"Tag `{tag}` doesn't start with a `v`") + + return semver.Version.parse(tag[1:]) + + +def version_to_tag_str(version: Union[str, semver.version.Version]) -> str: + """Return the git tag associated with this version.""" + # _Do_ add leading `v`s. Versions numbers never have leading `v`s, tags + # always have leading `v`s. + version = str(version) + return f"v{version.lstrip('v')}" + + +def tag_exists(repo_dir: Path, tag: str) -> bool: + """Return True if the tag exists, False otherwise.""" + tag_ref_proc = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + + return tag_ref_proc.returncode == 0 + + +def dereference_tags(repo_dir: Path) -> dict[str, str]: + """Return a dictionary mapping all tags to commit hashes.""" + show_ref_output = ( + subprocess.check_output(["git", "show-ref", "--dereference"], cwd=repo_dir) + .decode("utf-8") + .strip() + ) + + pattern = re.compile( + r"^(?P\w+)\s+refs/tags/(?P.*?)(?P\^\{\})?$", + flags=re.MULTILINE, + ) + + tag_to_commit_map: dict[str, str] = {} + dereferenced_tags: dict[str, str] = {} + + for match in pattern.finditer(show_ref_output): + logging.getLogger(__name__).debug(match.groups()) + operator.setitem( + dereferenced_tags if match["annotated"] else tag_to_commit_map, + match["tag"], + match["commit"], + ) + + # Update all of the annotated tags with the dereferenced commits + tag_to_commit_map.update(dereferenced_tags) + + return tag_to_commit_map + + +def get_github_releases(repo_dir: Path) -> list[Release]: + """Get all release data from GitHub.""" + return [ + Release(**item) + for item in json.loads( + subprocess.check_output( + [ + "gh", + "release", + "list", + "--json", + ",".join(( + "name", + "tagName", + "isDraft", + "isPrerelease", + )), + ], + cwd=repo_dir, + ) + ) + ] + + +def get_closest_semver_ancestor( + repo_dir: Path, allow_prerelease: bool = False +) -> semver.version.Version: + """ + Returns the most recent semantic version ancestor of HEAD. + + If `prerelease` is False, ignore prereleases. + """ + # Previously this was using `git describe --tags --abbrev=0 --match + # `, but the differences between the glob and the full regex were + # causing issues. Do an exhaustive search instead. + all_tags = ( + subprocess.check_output(["git", "tag"], cwd=repo_dir) + .decode("utf-8") + .strip() + .splitlines() + ) + + version_distances = defaultdict(list) + + for tag in all_tags: + # Ignore the tag if it's not an ancestor of HEAD or a semantic version + try: + subprocess.check_call( + ["git", "merge-base", "--is-ancestor", tag, "HEAD"], cwd=repo_dir + ) + version = tag_to_semver(tag) + + except subprocess.CalledProcessError: + logging.getLogger(__name__).debug( + "Tag `%s` is not an ancestor of HEAD", tag + ) + continue + except ValueError as err: + logging.getLogger(__name__).debug(err) + continue + + if version.prerelease and not allow_prerelease: + logging.getLogger(__name__).debug("Tag `%s` is a prerelease", tag) + continue + + # Compute the commit distance between the tag and HEAD + distance = int( + subprocess.check_output( + ["git", "rev-list", "--count", f"{tag}..HEAD"], + cwd=repo_dir, + ) + ) + version_distances[distance].append(version) + logging.getLogger(__name__).debug( + "Tag `%s` (version %s) is %d commits away from HEAD", tag, version, distance + ) + + if not version_distances: + fallback = semver.Version(0, 0, 0) + logging.getLogger(__name__).log( + NOTICE, + "No direct ancestors of HEAD are semantic versions - defaulting to %s", + fallback, + ) + return fallback + + min_distance = min(version_distances) + closest_versions = sorted(version_distances[min_distance]) + + if len(closest_versions) > 1: + logging.getLogger(__name__).warning( + "Multiple tags are equidistant from HEAD: %s", closest_versions + ) + + logging.getLogger(__name__).info( + "Closest ancestor %s is %d commits back", closest_versions[-1], min_distance + ) + + return closest_versions[-1] diff --git a/pyproject.toml b/pyproject.toml index 6e1d4e4..84d0355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ maintainers = [ [project.scripts] get-next-version = "bumpchanges:getversion.entrypoint" bump-changelog = "bumpchanges:bump.entrypoint" +alias-release = "bumpchanges:alias.entrypoint" [build-system] requires = ["hatchling", "hatch-vcs"] diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index b27b3ba..f86e719 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -24,6 +24,7 @@ module.exports = async ({ github, context, core }) => { } const newVersion = parsedVersion[1] + const isPrerelease = /-rc\.\d+$/.test(newVersion) const isDraft = core.getBooleanInput('draft', { required: false }) @@ -35,6 +36,7 @@ module.exports = async ({ github, context, core }) => { name: `Release ${newVersion}`, draft: isDraft, generate_release_notes: true, + prerelease: isPrerelease, body: `Automatically generated after merging #${context.payload.number}.` }) diff --git a/templates/alias-release.yaml b/templates/alias-release.yaml new file mode 100644 index 0000000..37dd835 --- /dev/null +++ b/templates/alias-release.yaml @@ -0,0 +1,20 @@ +--- +name: 🛠️ Update release alias tags + +run-name: Update alias for ${{ github.event.action }} ${{ github.event.release.name }} + +on: + release: + types: + - published + - deleted + +permissions: + actions: read + contents: write + +jobs: + update-alias: + uses: uclahs-cds/tool-create-release/.github/workflows/wf-alias-release.yaml@v0.0.2 + # Secrets are only required until tool-create-release is made public + secrets: inherit diff --git a/templates/finalize-release.yaml b/templates/finalize-release.yaml new file mode 100644 index 0000000..1deb6b2 --- /dev/null +++ b/templates/finalize-release.yaml @@ -0,0 +1,25 @@ +--- +name: 🛠️ Finalize release + +run-name: Finalize release from branch `${{ github.event.pull_request.head.ref }}` + +on: + pull_request: + branches: + - main + types: + - closed + +permissions: + actions: read + contents: write + pull-requests: write + +jobs: + finalize-release: + if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'automation-create-release') }} + uses: uclahs-cds/tool-create-release/.github/workflows/wf-finalize-release.yaml@v0.0.2 + with: + draft: true + # Secrets are only required until tool-create-release is made public + secrets: inherit diff --git a/templates/prepare-release-non-semantic-version.yaml b/templates/prepare-release-non-semantic-version.yaml new file mode 100644 index 0000000..1f9ab96 --- /dev/null +++ b/templates/prepare-release-non-semantic-version.yaml @@ -0,0 +1,26 @@ +--- +name: 📦 Prepare new release + +run-name: Open PR for new release + +on: + workflow_dispatch: + inputs: + version: + type: string + description: Version number to assign to next release. Must begin with a digit. + required: true + +permissions: + actions: read + contents: write + pull-requests: write + +jobs: + prepare-release: + uses: uclahs-cds/tool-create-release/.github/workflows/wf-prepare-release.yaml@v0.0.2 + with: + bump_type: "exact" + exact_version: ${{ inputs.version }} + # Secrets are only required until tool-create-release is made public + secrets: inherit diff --git a/templates/prepare-release.yaml b/templates/prepare-release.yaml new file mode 100644 index 0000000..383ad06 --- /dev/null +++ b/templates/prepare-release.yaml @@ -0,0 +1,33 @@ +--- +name: 📦 Prepare new release + +run-name: Open PR for new ${{ inputs.bump_type }} release + +on: + workflow_dispatch: + inputs: + bump_type: + type: choice + description: Semantic version bump type + required: true + options: + - major + - minor + - patch + prerelease: + type: boolean + description: Create a prerelease + +permissions: + actions: read + contents: write + pull-requests: write + +jobs: + prepare-release: + uses: uclahs-cds/tool-create-release/.github/workflows/wf-prepare-release.yaml@v0.0.2 + with: + bump_type: ${{ inputs.bump_type }} + prerelease: ${{ inputs.prerelease }} + # Secrets are only required until tool-create-release is made public + secrets: inherit diff --git a/tests/test_aliases.py b/tests/test_aliases.py new file mode 100644 index 0000000..790cc96 --- /dev/null +++ b/tests/test_aliases.py @@ -0,0 +1,129 @@ +"""Tests for CHANGELOG parsing and reformatting.""" + +import contextlib +from unittest.mock import patch + +import pytest +import semver + +from bumpchanges.alias import ReleaseAliaser, IneligibleAliasError, AliasError, Release + + +# Sample test case to mock _dereference_tags +@pytest.fixture(name="aliaser") +def mock_aliaser_internals(tmp_path): + """Fixture to mock out the git and GitHub internals of a ReleaseAliaser.""" + # The baseline case here is to have several non-semver-tags and then + # several semver tags across multiple major versions. + tag_to_commit_map = { + "nonsemver": "asdfasdf", + "2.0.0": "bar", + "v1.0.0": "tergasdfasdf", + "v1.0.1": "berqwref", + "v2.0.0-rc.1": "foo", + "v2.0.0": "bar", + "v2.1.0": "baz", + "v2.2.0-rc.1": "qux", + } + + releases = [ + Release("", "v1.0.0", False, False), + Release("", "v1.0.1", False, False), + Release("", "v2.0.0-rc.1", False, True), + Release("", "v2.0.0", False, False), + Release("", "v2.1.0", False, False), + ] + + with patch( + "bumpchanges.alias.dereference_tags", return_value=tag_to_commit_map + ), patch("bumpchanges.alias.get_github_releases", return_value=releases): + aliaser = ReleaseAliaser(tmp_path) + yield aliaser + + +def test_alias_workflow(aliaser): + """Test the basic steps of aliasing a new release.""" + assert aliaser.compute_alias_action(1) == ("v1", "v1.0.1") + assert aliaser.compute_alias_action(2) == ("v2", "v2.1.0") + + with pytest.raises(IneligibleAliasError): + aliaser.compute_alias_action(3) + + +def test_modified_releases(aliaser): + """Test how new releases interact with the results.""" + # pylint: disable=protected-access + + assert aliaser.compute_alias_action(2) == ("v2", "v2.1.0") + + # Act as if this GitHub release never existed + aliaser.tag_to_release_map.pop("v2.1.0") + assert aliaser.compute_alias_action(2) == ("v2", "v2.0.0") + + # Act as if this GitHub release never existed. There are no more valid releases. + aliaser.tag_to_release_map.pop("v2.0.0") + with pytest.raises(IneligibleAliasError): + aliaser.compute_alias_action(2) + + # Add in a new release + v220 = "v2.2.0" + aliaser._add_git_tag(v220, "asdfasdffs") + aliaser._add_github_release(Release("", v220, False, False)) + + assert aliaser.compute_alias_action(2) == ("v2", v220) + + # Add in a lower release, showing that it will be masked + v211 = "v2.1.1" + aliaser._add_git_tag(v211, "rtpeoisbf") + aliaser._add_github_release(Release("", v211, False, False)) + + assert aliaser.compute_alias_action(2) == ("v2", v220) + + # Add in a higher release, showing that it will take priority + v221 = "v2.2.1" + aliaser._add_git_tag(v221, "aqqqqdfasdffs") + aliaser._add_github_release(Release("", v221, False, False)) + + assert aliaser.compute_alias_action(2) == ("v2", v221) + + +@pytest.mark.parametrize("semver_pre", [True, False]) +@pytest.mark.parametrize("release_draft", [True, False]) +@pytest.mark.parametrize("release_pre", [True, False]) +def test_drafts_and_prereleases(aliaser, semver_pre, release_draft, release_pre): + """Test that only non-drafts and full releases are eligible.""" + # pylint: disable=protected-access + + base_version = semver.Version(3, 0, 0) + if semver_pre: + base_version = base_version.bump_prerelease() + + tag = f"v{base_version}" + + aliaser._add_git_tag(tag, "sdflaksfjkalsfj") + aliaser._add_github_release(Release("", tag, release_draft, release_pre)) + + if semver_pre or release_pre or release_draft: + expectation = pytest.raises(IneligibleAliasError) + else: + expectation = contextlib.nullcontext() + + with expectation: + assert aliaser.compute_alias_action(3) == ("v3", tag) + + +@pytest.mark.parametrize( + "missing_tag,expectation", + [ + ("v1.0.1", pytest.raises(AliasError)), + ("v2.0.0", pytest.raises(AliasError)), + ("nonsemver", contextlib.nullcontext()), + ], +) +def test_sanity_checks(aliaser, missing_tag, expectation): + """Test that invariants will be violated if the GitHub and git tags drift out of sync.""" + # Remove the tag + aliaser.tag_to_commit_map.pop(missing_tag) + + with expectation: + assert aliaser.compute_alias_action(2) == ("v2", "v2.1.0") diff --git a/tests/test_bumpchanges.py b/tests/test_bumpchanges.py index 358e20b..61fda4f 100644 --- a/tests/test_bumpchanges.py +++ b/tests/test_bumpchanges.py @@ -1,6 +1,135 @@ """Tests for the bumpchanges module.""" +import contextlib -def test_something(): - """An empty test. Should be filled with something.""" - assert True +from pathlib import Path +from unittest.mock import patch + +import pytest +from semver import Version + +from bumpchanges.getversion import get_next_semver, get_exact_version + + +@contextlib.contextmanager +def mock_tag_exists(tags): + """Context manager to mock `tag_exists`.""" + + def mock_function(_, tag): + """Local mock function.""" + return tag in tags + + with patch("bumpchanges.getversion.tag_exists", side_effect=mock_function): + yield + + +@pytest.mark.parametrize( + "last,bump_type,prerelease,expected_str", + [ + (Version(1, 1, 1), "patch", True, "1.1.2-rc.1"), + (Version(1, 1, 1), "patch", False, "1.1.2"), + (Version(1, 1, 1), "minor", True, "1.2.0-rc.1"), + (Version(1, 1, 1), "minor", False, "1.2.0"), + (Version(1, 1, 1), "major", True, "2.0.0-rc.1"), + (Version(1, 1, 1), "major", False, "2.0.0"), + ], +) +def test_next_version(last, bump_type, prerelease, expected_str): + """Get that the next versions match what is expected.""" + with patch("bumpchanges.getversion.get_closest_semver_ancestor", return_value=last): + # Ignore any tags in _this_ repository while testing + with mock_tag_exists([]): + assert get_next_semver(Path(), bump_type, prerelease) == expected_str + + +@pytest.mark.parametrize( + "last,bad_type,existing_tags", + [ + (Version(1, 1, 58), "patch", ["v1.1.59"]), + (Version(2, 44, 58), "minor", ["v2.45.0"]), + (Version(1, 1, 1), "major", ["v2.0.0"]), + (Version(87600, 1, 1), "major", ["v87601.0.0"]), + ], +) +def test_fail_on_existing_tags(last, bad_type, existing_tags): + """Test that bumping to an existing version will raise an error.""" + bump_types = ["major", "minor", "patch"] + + contexts = { + bump_type: pytest.raises(RuntimeError) + if bump_type == bad_type + else contextlib.nullcontext() + for bump_type in bump_types + } + + with patch("bumpchanges.getversion.get_closest_semver_ancestor", return_value=last): + for bump_type in bump_types: + with mock_tag_exists([]): + # With no tags, everything is valid + get_next_semver(Path(), bump_type, False) + + with mock_tag_exists(existing_tags): + with contexts[bump_type]: + get_next_semver(Path(), bump_type, False) + + +@pytest.mark.parametrize( + "last,bump_type,existing_tags,expected", + [ + ( + Version(23, 85, 43), + "patch", + ["v23.85.44-rc.1", "v23.85.44-rc.2"], + "23.85.44-rc.3", + ), + (Version(1, 2, 3), "minor", ["v1.3.0-rc.1"], "1.3.0-rc.2"), + (Version(1, 2, 3), "major", ["v2.0.0-rc.1"], "2.0.0-rc.2"), + (Version(1, 2, 3), "major", ["v2.0.0-rc.2"], "2.0.0-rc.1"), + ], +) +def test_bumping_prerelease(last, bump_type, existing_tags, expected): + """Test that multiple generations of prerelease can work.""" + with patch("bumpchanges.getversion.get_closest_semver_ancestor", return_value=last): + with mock_tag_exists(existing_tags): + assert get_next_semver(Path(), bump_type, True) == expected + + +@pytest.mark.parametrize( + "exact_version_str,expectation", + [ + ("1-alpha", contextlib.nullcontext()), + ("5.4.3", contextlib.nullcontext()), + ("2-alpha", contextlib.nullcontext()), + ("233.33153", contextlib.nullcontext()), + ("v2.1.1", pytest.raises(RuntimeError)), + ("two", pytest.raises(RuntimeError)), + ], +) +def test_get_exact(exact_version_str, expectation): + """Test protections when giving an exact version string.""" + with mock_tag_exists([]): + with expectation: + assert get_exact_version(Path(), exact_version_str) == exact_version_str + + +@pytest.mark.parametrize( + "exact_version_str,tag", + [ + ("1-alpha", "v1-alpha"), + ("5.4.3", "v5.4.3"), + ("2-alpha", "v2-alpha"), + ("233.33153", "v233.33153"), + ], +) +def test_exact_tag_protection(exact_version_str, tag): + """Test that get_exact_version will fail if the version tag already exists.""" + other_tags = ["v1.0.0", "1.0.0", "random"] + + with mock_tag_exists(other_tags): + assert get_exact_version(Path(), exact_version_str) == exact_version_str + + other_tags.append(tag) + + with mock_tag_exists(other_tags): + with pytest.raises(RuntimeError): + get_exact_version(Path(), exact_version_str)