diff --git a/.github/workflows/bump.yaml b/.github/workflows/bump.yaml index 558b012..164a29c 100644 --- a/.github/workflows/bump.yaml +++ b/.github/workflows/bump.yaml @@ -1,7 +1,7 @@ # Reusable workflow: bump version (optionally with changelog) and optionally # draft a GitHub release in the same dispatch. # -# An optional follow-up commit can be made to bump the version onto a pre-release (e.g. 1.2.3 -> 1.3.0a1) after tagging the stable release (1.2.3). +# An optional follow-up commit can be made to bump the version onto a pre-release (e.g. 1.2.3 -> 1.3.0a1) after tagging the stable release (1.2.3). # This is useful to differentiate between the tagged release commit and the post-release commit on main. # The default is to make a "patch dev" post-release commit (e.g. 1.2.3 -> 1.2.4.dev0). # @@ -15,9 +15,12 @@ # with: # bump-rule: ${{ inputs.bump_rule }} # +# Projects whose version comes from git tags (e.g. hatch-vcs) pass `dynamic-versioning: true`, +# which tags without editing any manifest and lands no post-tag commit. +# # A PAT is only needed when branch protection blocks the github-actions bot or -# when the release is auto-published and downstream workflows must fire. In -# that case pass it as `secrets: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}`. +# when the release is auto-published and downstream workflows must fire. +# In that case pass it as `secrets: token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}`. name: Bump on: @@ -25,14 +28,28 @@ on: inputs: project-type: description: > - Kind of project to bump, named after the package manager that owns the - lockfile: 'uv' (Python), or 'yarn' / 'npm' / 'pnpm' (Node). The Node types - behave identically apart from which lockfile is refreshed. See the - bump-version action for how bump-rule/pre-release-base/pre-release-bump - differ per type. + Kind of project to bump, named after the package manager that owns the lockfile: + 'uv' (Python), or 'yarn' / 'npm' / 'pnpm' (Node). + + The Node types behave identically apart from which lockfile is refreshed. + See the bump-version action for how bump-rule/pre-release-base/pre-release-bump differ per type. required: false type: string default: "uv" + dynamic-versioning: + description: | + Take the base version from the latest reachable v* tag rather than from a manifest, + for projects whose version is derived from git tags (e.g. hatch-vcs). + With no such tag the base is 0.0.0. + + No manifest is rewritten and no lockfile is refreshed, + so workspace-packages does not apply and neither does the post-tag commit, + which makes pre-release-bump and pre-release-base inert. + + Only project-type 'uv' is supported. + required: false + type: boolean + default: false bump-rule: description: | For project-type 'uv': whitespace-separated list of segments passed to @@ -51,7 +68,7 @@ on: Base bump applied before the pre-release segment (patch|minor|major|none). This is useful to land the post-tag commit on a more appropriate version. - i.e. from 1.2.3, a pre-release-base of minor and pre-release-bump of alpha would land on 1.3.0a0, + i.e. from 1.2.3, a pre-release-base of minor and pre-release-bump of alpha would land on 1.3.0a0, while a pre-release-base of none would land on 1.2.4a1. Use 'none' to skip the base bump and only add the pre-release segment. @@ -144,8 +161,8 @@ on: token: description: > Optional PAT used to push commits/tags and create the release. - Defaults to the workflow's GITHUB_TOKEN. - Provide a PAT when the repo has branch protection that blocks the github-actions bot, + Defaults to the workflow's GITHUB_TOKEN. + Provide a PAT when the repo has branch protection that blocks the github-actions bot, or when the release is auto-published and downstream `release: published` workflows must fire. required: false outputs: @@ -206,6 +223,7 @@ jobs: uses: climate-resource/github-actions/bump-version@v1.5.1 with: project-type: ${{ inputs.project-type }} + dynamic-versioning: ${{ inputs.dynamic-versioning }} bump-rule: ${{ inputs.bump-rule }} pre-release-bump: ${{ inputs.pre-release-bump }} pre-release-base: ${{ inputs.pre-release-base }} diff --git a/README.md b/README.md index 73c31b0..776033a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,31 @@ jobs: dist/* ``` +### Dynamically versioned projects (hatch-vcs) + +Projects whose version is derived from git tags hold no version to edit, so the tag is the release: + +```yaml +jobs: + bump: + uses: climate-resource/github-actions/.github/workflows/bump.yaml@v1 + with: + dynamic-versioning: true + bump-rule: ${{ inputs.bump_rule }} + create-release: true + build-command: "uv build" + release-files: | + dist/* +``` + +The base version is the latest reachable `v*` tag, +and `bump-rule` is applied to it exactly as it would be to a version read from `pyproject.toml`. +Nothing is written to `pyproject.toml`, no lockfile is refreshed, and no post-tag commit is landed, +so `pre-release-bump`, `pre-release-base` and `workspace-packages` are ignored. +The changelog build and `pre-commit-command` still run, and their changes land in the tagged commit. + + + ### Skipping the changelog or the pre-release dev commit ```yaml diff --git a/bump-version/README.md b/bump-version/README.md index ee4178c..c68e262 100644 --- a/bump-version/README.md +++ b/bump-version/README.md @@ -8,17 +8,21 @@ tagged version. `project-type` selects the backend, and is named after the package manager that owns the lockfile: -| `project-type` | Version lives in | Bumped with | `lock: true` runs | -| --- | --- | --- | --- | -| `uv` (default) | `pyproject.toml` | `uv version --bump` | `uv lock` | -| `yarn` | `package.json` | `npm version` | `yarn install --mode=update-lockfile` | -| `npm` | `package.json` | `npm version` | `npm install --package-lock-only` | -| `pnpm` | `package.json` | `npm version` | `pnpm install --lockfile-only` | +| `project-type` | Version lives in | Bumped with | `lock: true` runs | +| -------------- | ---------------- | ------------------- | ------------------------------------- | +| `uv` (default) | `pyproject.toml` | `uv version --bump` | `uv lock` | +| `yarn` | `package.json` | `npm version` | `yarn install --mode=update-lockfile` | +| `npm` | `package.json` | `npm version` | `npm install --package-lock-only` | +| `pnpm` | `package.json` | `npm version` | `pnpm install --lockfile-only` | The three Node types behave identically apart from that last column. `npm version` is used purely as a version-bumping CLI — it ships with Node, and the package manager you named stays in charge of the lockfile. +Projects that derive their version from git tags (e.g. hatch-vcs) set `dynamic-versioning: true` instead. +The tag is then the only thing that carries the version, so no manifest is touched. +See [Dynamic versioning](#dynamic-versioning) below. + The logic lives in [`bump.py`](bump.py), which `action.yml` invokes with `uv run --script`. Its unit tests are in [`../tests/test_bump.py`](../tests/test_bump.py). @@ -40,6 +44,7 @@ The logic lives in [`bump.py`](bump.py), which `action.yml` invokes with | Input | Default | Description | | --- | --- | --- | | `project-type` | `uv` | `uv` for a Python project; `yarn`, `npm` or `pnpm` for a Node project. See the table above. | +| `dynamic-versioning` | `false` | Take the base version from the latest reachable `v*` tag rather than from a manifest. `uv` projects only. See [Dynamic versioning](#dynamic-versioning). | | `bump-rule` | _required_ | Whitespace-separated arguments describing the bump. For `uv`, each segment becomes a separate `--bump`: `patch`, `minor`, `major`, `stable`, `minor alpha`, `patch rc`. From a stable version, prerelease segments (`alpha`, `beta`, `rc`, `dev`) must be combined with a release segment. For the Node types, passed verbatim to `npm version`: `patch`, `preminor --preid alpha`, `prerelease --preid rc`. | | `pre-release-bump` | `dev` | Pre-release segment for the second commit. For `uv`: `dev`, `alpha`, `beta`, `rc`. For the Node types: verbatim `npm version` arguments, e.g. `--preid dev`. Use `none` to skip the second commit. | | `pre-release-base` | `patch` | Base bump applied before the pre-release segment in the second commit. For `uv`: a bump rule (`patch`, `minor`, `major`, …). For the Node types: an `npm version` strategy word, e.g. `prepatch`. Use `none` to add the pre-release marker without bumping the base. | @@ -93,6 +98,51 @@ back off the version `npm version` produced, so `--preid dev` landing on Pre-release detection is version-scheme aware: PEP 440 for `uv` (via `packaging`), semver for the Node types. +## Dynamic versioning + +With `dynamic-versioning: true` the version is not stored anywhere in the repo, +so the base is the highest `v*` tag reachable from `HEAD`, with the leading `v` stripped. +A repo with no such tag starts from `0.0.0`. + +Tags are ranked by PEP 440. +A tag that is not a valid version, such as `vendor-3`, is ignored rather than treated as one. + +`bump-rule` means exactly what it means in static mode (`uv version --bump` is still used to calculate the tag). +So `patch`, `minor`, `major`, `stable` and the pre-release segments (`alpha`, `beta`, `rc`, `dev`) +all behave as they usually do, and an unsupported combination fails with uv's own message. + +What changes: + +- `pyproject.toml` or the lockfile is not edited as it isn't needed, + so `workspace-packages` and `lock` do not apply. +- There is no post-tag commit, `pre-release-bump` and `pre-release-base` are ignored, + and `dev-version` is empty. +- The commit is made only if something is left to commit, + which in practice means the changelog build or `pre-commit-command` changed a tracked file. + With nothing to commit the tag lands on `HEAD` rather than on an empty commit. + +`update-changelog` and `pre-commit-command` work as they do in static mode. + +```yaml +- uses: climate-resource/github-actions/setup-uv@v1 +- uses: climate-resource/github-actions/bump-version@v1 + with: + dynamic-versioning: true + bump-rule: ${{ inputs.bump_rule }} +``` + +### Gotchas + +The tag is the only record of the version, so anything that muddies the tags muddies the release. + +- Releasing twice from one commit is refused, because hatch-vcs would read the lower tag. + Moving aliases such as `v1` or `v1.3` trip the same guard, so a repo that publishes them cannot use `dynamic-versioning`. +- Every package in the repo shares one version, because they all read the same tag. + A release moves them together and no single member can be released on its own. +- A member in a subdirectory needs `raw-options = { search_parent_directories = true }` + under `[tool.hatch.version]`, or hatch-vcs fails to find the repository at all. + This configuration is needed for monorepos. + ## Example ```yaml diff --git a/bump-version/action.yml b/bump-version/action.yml index 5dcf76f..1e11f0c 100644 --- a/bump-version/action.yml +++ b/bump-version/action.yml @@ -4,6 +4,9 @@ description: > update the CHANGELOG via towncrier, commit, tag, push, and optionally land a second commit bumping main onto a pre-release version. + Projects that derive their version from git tags (e.g. hatch-vcs) set + `dynamic-versioning` and are released by the tag alone. + inputs: project-type: description: > @@ -16,6 +19,19 @@ inputs: differ only in which lockfile the `lock` input refreshes. required: false default: "uv" + dynamic-versioning: + description: > + If 'true', take the base version from the latest reachable `v*` tag rather than from a manifest, + for projects whose version is derived from git tags (e.g. hatch-vcs). + With no such tag the base is 0.0.0. + + No manifest is rewritten and no lockfile is refreshed, so `workspace-packages` and `lock` do not apply. + The `pre-release-bump` and `pre-release-base` flags are ignored as they do not apply. + Only project-type 'uv' is supported. + + The checkout needs the tags, so use `fetch-depth: 0` when calling this action directly. + required: false + default: "false" bump-rule: description: > Whitespace-separated arguments describing the bump. @@ -132,6 +148,7 @@ runs: shell: bash env: PROJECT_TYPE: ${{ inputs.project-type }} + DYNAMIC_VERSIONING: ${{ inputs.dynamic-versioning }} BUMP_RULE: ${{ inputs.bump-rule }} PRE_RELEASE_BUMP: ${{ inputs.pre-release-bump }} PRE_RELEASE_BASE: ${{ inputs.pre-release-base }} diff --git a/bump-version/bump.py b/bump-version/bump.py index c401944..a8b11ef 100755 --- a/bump-version/bump.py +++ b/bump-version/bump.py @@ -16,17 +16,21 @@ 2. `land_prerelease` optionally lands a second commit moving the branch onto a pre-release version, so later commits do not share the tagged version. -Everything that differs between project types lives behind `Backend`; the two -phases themselves never branch on the project type. +Everything that differs between project types lives behind `Backend`, +so the two phases never branch on the project type. + +Projects whose version is derived from git tags (e.g. hatch-vcs) skip phase 2. """ import os import shlex import subprocess import sys +import tempfile from collections.abc import Generator, Sequence from contextlib import contextmanager from dataclasses import dataclass +from pathlib import Path from typing import Protocol, Self from packaging.version import InvalidVersion, Version @@ -35,6 +39,17 @@ # of the second commit. NONE = "none" +# Base version for a dynamically versioned project with no release tag yet. +FIRST_VERSION = "0.0.0" + +# Scratch project that uv can bump when using dynamic versioning +SCRATCH_PROJECT = """\ +[project] +name = "bump-version-scratch" +version = "{version}" +requires-python = ">=3.9" +""" + class BumpError(Exception): """A failure that should end the action with a readable message.""" @@ -116,6 +131,9 @@ def read_version(self) -> str: def bump_command(self, segments: Sequence[str]) -> list[str]: """Build the command applying `bump-rule` segments to the root project.""" + def apply_bump(self, segments: Sequence[str], base: str) -> str: + """Apply `bump-rule` segments to `base` and return the new version.""" + def prerelease_command(self, base: str, bump: str) -> list[str]: """Build the command applying the post-tag pre-release bump.""" @@ -139,7 +157,15 @@ def prerelease_label(self, requested: str, version: str) -> str: """ -class UvBackend: +class ManifestBackend: + """Backends whose version lives in a file the bump command rewrites.""" + + def apply_bump(self, segments: Sequence[str], base: str) -> str: + run(self.bump_command(segments)) + return self.read_version() + + +class UvBackend(ManifestBackend): """Python projects whose version lives in `pyproject.toml`, managed by uv.""" name = "uv" @@ -180,7 +206,7 @@ def prerelease_label(self, requested: str, version: str) -> str: return requested -class NodeBackend: +class NodeBackend(ManifestBackend): """Node projects whose version lives in `package.json`. Every Node project type reads and bumps the same way: `npm version` ships @@ -249,6 +275,58 @@ class PnpmBackend(NodeBackend): lock = ("pnpm", "install", "--lockfile-only") +def latest_tagged_version(tags: Sequence[str]) -> str | None: + """Return the highest release among `v`-prefixed `tags`, or None if there is none. + + Ordering is PEP 440 rather than git's `v:refname`, + which ranks a pre-release above the release it precedes and does not skip tags that are not versions. + """ + versions = [] + for tag in tags: + try: + versions.append(Version(tag.removeprefix("v"))) + except InvalidVersion: + log(f"Ignoring tag {tag}, which is not a version") + if not versions: + return None + return str(max(versions)) + + +class DynamicBackend(UvBackend): + """Python projects whose version is derived from git tags (e.g. hatch-vcs). + + No file in the repo carries the version, + so the base comes from the latest reachable tag and the bump is applied to a scratch project. + Borrowing uv rather than reimplementing PEP 440 keeps the bump rules identical to the static mode's. + """ + + def read_version(self) -> str: + tags = run( + ["git", "tag", "--list", "v*", "--merged", "HEAD"], capture=True + ).split() + version = latest_tagged_version(tags) + if version is None: + log(f"No v* release tag is reachable, so the base version is {FIRST_VERSION}") + return FIRST_VERSION + return version + + def scratch_bump_command(self, segments: Sequence[str], directory: str) -> list[str]: + """Build the bump command for the scratch project in `directory`. + + Deliberately not `bump_command`, which rewrites the repo's own manifest. + """ + command = ["uv", "version", "--frozen", "--short", "--directory", directory] + for segment in segments: + command += ["--bump", segment] + return command + + def apply_bump(self, segments: Sequence[str], base: str) -> str: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "pyproject.toml" + manifest.write_text(SCRATCH_PROJECT.format(version=base), encoding="utf-8") + return run(self.scratch_bump_command(segments, directory), capture=True) + + BACKENDS: dict[str, type[Backend]] = { "uv": UvBackend, "yarn": YarnBackend, @@ -273,6 +351,7 @@ def as_lines(value: str) -> tuple[str, ...]: @dataclass(frozen=True) class Config: project_type: str + dynamic_versioning: bool bump_rule: str pre_release_bump: str pre_release_base: str @@ -290,14 +369,19 @@ def from_env(cls, env: dict[str, str] | None = None) -> Self: def get(name: str, default: str = "") -> str: return source.get(name, default) + dynamic = as_bool(get("DYNAMIC_VERSIONING", "false")) + return cls( project_type=get("PROJECT_TYPE", "uv").strip(), + dynamic_versioning=dynamic, bump_rule=get("BUMP_RULE"), pre_release_bump=get("PRE_RELEASE_BUMP", "dev").strip(), pre_release_base=get("PRE_RELEASE_BASE", "patch").strip(), update_changelog=as_bool(get("UPDATE_CHANGELOG", "true")), - workspace_packages=as_lines(get("WORKSPACE_PACKAGES")), - run_lock=as_bool(get("RUN_LOCK", "true")), + # Dynamic versioning rewrites no manifest, so there is nothing to + # mirror onto workspace packages and nothing to relock. + workspace_packages=() if dynamic else as_lines(get("WORKSPACE_PACKAGES")), + run_lock=as_bool(get("RUN_LOCK", "true")) and not dynamic, pre_commit_command=get("PRE_COMMIT_COMMAND").strip(), commit_skip_hooks=as_bool(get("COMMIT_SKIP_HOOKS", "false")), do_push=as_bool(get("DO_PUSH", "true")), @@ -315,6 +399,13 @@ def backend(self) -> Backend: ) if not self.bump_segments: raise BumpError("bump-rule must not be empty") + if self.dynamic_versioning: + if self.project_type != "uv": + raise BumpError( + "dynamic-versioning is only supported for project-type 'uv', " + f"got '{self.project_type}'" + ) + return DynamicBackend() return BACKENDS[self.project_type]() @@ -342,6 +433,27 @@ def run_pre_commit_command(config: Config) -> None: run(["bash", "-c", config.pre_commit_command]) +def has_tracked_changes() -> bool: + """Return whether `git commit -a` would have anything to commit.""" + status = run(["git", "status", "--porcelain", "--untracked-files=no"], capture=True) + return bool(status) + + +def guard_untagged_head(new_version: str) -> None: + """Refuse to put a second release tag on a commit that already carries one. + + hatch-vcs reads the lower tag, so the artefacts would not match `new_version`. + """ + existing = latest_tagged_version( + run(["git", "tag", "--points-at", "HEAD"], capture=True).split() + ) + if existing is not None: + raise BumpError( + f"HEAD is already tagged v{existing}, so v{new_version} would build " + f"{existing}. Release from a later commit." + ) + + def commit(config: Config, message: str) -> None: command = ["git", "commit"] if config.commit_skip_hooks: @@ -368,8 +480,7 @@ def tag_release(config: Config, backend: Backend) -> Release: log(f"Bumping from version {base_version}") with group("Bump version"): - run(backend.bump_command(config.bump_segments)) - new_version = backend.read_version() + new_version = backend.apply_bump(config.bump_segments, base_version) log(f"Bumped to version {new_version}") mirror_workspace(config, backend, new_version) refresh_lock(config, backend) @@ -382,7 +493,14 @@ def tag_release(config: Config, backend: Backend) -> Release: with group("Commit and tag"): run_pre_commit_command(config) - commit(config, f"bump: version {base_version} -> {new_version}") + # A dynamic bump changes nothing on its own, so the tag can land on + # HEAD rather than on an empty commit. + if config.dynamic_versioning and not has_tracked_changes(): + log("Nothing to commit, so tagging HEAD as it stands") + else: + commit(config, f"bump: version {base_version} -> {new_version}") + if config.dynamic_versioning: + guard_untagged_head(new_version) run(["git", "tag", f"v{new_version}"]) if config.do_push: run(["git", "push"]) @@ -400,6 +518,9 @@ def land_prerelease(config: Config, backend: Backend, release: Release) -> str | Returns the new pre-release version, or None when the bump was skipped. """ + if config.dynamic_versioning: + log("Skipping pre-release bump because the version comes from the tag") + return None if config.pre_release_bump == NONE: log("Skipping pre-release bump (pre-release-bump=none)") return None diff --git a/changelog/30.feature.md b/changelog/30.feature.md new file mode 100644 index 0000000..0615cf0 --- /dev/null +++ b/changelog/30.feature.md @@ -0,0 +1,3 @@ +Added a `dynamic-versioning` input to the `bump-version` action and the `bump.yaml` reusable workflow. +Projects whose version is derived from git tags (e.g. hatch-vcs) can now be released by the tag alone, +with no version edit in `pyproject.toml` and no post-tag pre-release commit. diff --git a/tests/test_bump.py b/tests/test_bump.py index ee8f181..86b17f0 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -13,6 +13,11 @@ def uv() -> bump.UvBackend: return bump.UvBackend() +@pytest.fixture +def dynamic() -> bump.DynamicBackend: + return bump.DynamicBackend() + + @pytest.fixture def yarn() -> bump.YarnBackend: return bump.YarnBackend() @@ -57,6 +62,169 @@ def test_changelog_uses_project_environment(self, uv): ] +class TestLatestTaggedVersion: + """Picking the base version out of whatever tags a repo happens to carry.""" + + def test_no_tags_at_all(self): + assert bump.latest_tagged_version([]) is None + + def test_no_tag_is_a_version(self): + assert bump.latest_tagged_version(["vendor-3", "v-junk", "vlatest"]) is None + + def test_highest_wins_not_the_closest(self): + # git describe --abbrev=0 returns v1.5 here, which would re-tag v1.5.1. + assert bump.latest_tagged_version(["v1.5", "v1.5.0", "v1.5.1"]) == "1.5.1" + + def test_ordering_is_numeric_not_lexical(self): + assert bump.latest_tagged_version(["v1.9.0", "v1.10.0"]) == "1.10.0" + + def test_a_prerelease_ranks_below_its_release(self): + # git's v:refname sort ranks these the other way up. + tags = ["v1.6.0a1", "v1.6.0rc2", "v1.6.0"] + assert bump.latest_tagged_version(tags) == "1.6.0" + + def test_a_prerelease_wins_when_it_is_the_highest(self): + assert bump.latest_tagged_version(["v1.5.0", "v1.6.0a1"]) == "1.6.0a1" + + def test_prerelease_segments_rank_against_each_other(self): + tags = ["v1.6.0.dev1", "v1.6.0a1", "v1.6.0b1", "v1.6.0rc1"] + assert bump.latest_tagged_version(tags) == "1.6.0rc1" + + def test_a_post_release_outranks_its_release(self): + assert bump.latest_tagged_version(["v1.5.0", "v1.5.0.post1"]) == "1.5.0.post1" + + def test_an_epoch_outranks_a_higher_looking_number(self): + assert bump.latest_tagged_version(["v9.0.0", "v1!0.1.0"]) == "1!0.1.0" + + def test_unparseable_tags_are_skipped_not_fatal(self): + tags = ["vendor-3", "v1.5.0", "v-junk", "v1.6.0"] + assert bump.latest_tagged_version(tags) == "1.6.0" + + def test_the_version_is_normalised_for_uv(self): + assert bump.latest_tagged_version(["v1.5.0.RC1"]) == "1.5.0rc1" + + def test_a_bare_version_tag_still_parses(self): + assert bump.latest_tagged_version(["1.2.3"]) == "1.2.3" + + +class TestDynamicReadVersion: + """`read_version` against a real repo, since the tag listing is the risky part.""" + + def _repo(self, tmp_path, tags): + import subprocess + + run = lambda *a: subprocess.run(a, cwd=tmp_path, check=True, capture_output=True) + run("git", "init", "-q", ".") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "t") + run("git", "commit", "-q", "--allow-empty", "-m", "base") + for tag in tags: + run("git", "tag", tag) + return tmp_path + + def test_the_duplicate_tag_bug_is_fixed(self, dynamic, tmp_path, monkeypatch): + # v1.5 and v1.5.1 on one commit: the old code picked v1.5 and re-tagged v1.5.1. + monkeypatch.chdir(self._repo(tmp_path, ["v1.5", "v1.5.0", "v1.5.1"])) + assert dynamic.read_version() == "1.5.1" + + def test_an_untagged_repo_starts_from_zero(self, dynamic, tmp_path, monkeypatch): + monkeypatch.chdir(self._repo(tmp_path, [])) + assert dynamic.read_version() == bump.FIRST_VERSION + + def test_non_version_tags_do_not_become_the_base(self, dynamic, tmp_path, monkeypatch): + monkeypatch.chdir(self._repo(tmp_path, ["vendor-3", "v1.2.0"])) + assert dynamic.read_version() == "1.2.0" + + def test_a_tag_not_reachable_from_head_is_ignored(self, dynamic, tmp_path, monkeypatch): + import subprocess + + repo = self._repo(tmp_path, ["v1.0.0"]) + run = lambda *a: subprocess.run(a, cwd=repo, check=True, capture_output=True) + run("git", "checkout", "-q", "-b", "side") + run("git", "commit", "-q", "--allow-empty", "-m", "side") + run("git", "tag", "v9.9.9") + run("git", "checkout", "-q", "-") + monkeypatch.chdir(repo) + assert dynamic.read_version() == "1.0.0" + + +class TestGuardUntaggedHead: + """Dynamic mode must not stack a second release tag on one commit.""" + + def _repo(self, tmp_path, tags): + import subprocess + + run = lambda *a: subprocess.run(a, cwd=tmp_path, check=True, capture_output=True) + run("git", "init", "-q", ".") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "t") + run("git", "commit", "-q", "--allow-empty", "-m", "base") + for tag in tags: + run("git", "tag", tag) + return tmp_path + + def test_an_untagged_head_is_allowed(self, tmp_path, monkeypatch): + monkeypatch.chdir(self._repo(tmp_path, [])) + bump.guard_untagged_head("1.4.0") + + def test_a_release_tag_on_head_is_refused(self, tmp_path, monkeypatch): + monkeypatch.chdir(self._repo(tmp_path, ["v1.3.0"])) + with pytest.raises(bump.BumpError, match="already tagged v1.3.0"): + bump.guard_untagged_head("1.4.0") + + def test_a_moving_alias_on_head_is_refused(self, tmp_path, monkeypatch): + # An alias such as v1 is what hatch-vcs would build, not the new tag. + monkeypatch.chdir(self._repo(tmp_path, ["v1", "v1.3.0"])) + with pytest.raises(bump.BumpError, match="already tagged"): + bump.guard_untagged_head("1.4.0") + + def test_a_non_version_tag_on_head_is_allowed(self, tmp_path, monkeypatch): + monkeypatch.chdir(self._repo(tmp_path, ["nightly"])) + bump.guard_untagged_head("1.4.0") + + def test_a_tag_on_an_earlier_commit_is_allowed(self, tmp_path, monkeypatch): + import subprocess + + repo = self._repo(tmp_path, ["v1.3.0"]) + subprocess.run( + ("git", "commit", "-q", "--allow-empty", "-m", "changelog"), + cwd=repo, check=True, capture_output=True, + ) + monkeypatch.chdir(repo) + bump.guard_untagged_head("1.4.0") + + +class TestDynamicBumpCommands: + """The bump runs against a scratch project, never the repo's own manifest.""" + + def test_bump_targets_the_scratch_directory(self, dynamic): + assert dynamic.scratch_bump_command(["minor", "alpha"], "/tmp/scratch") == [ + "uv", "version", "--frozen", "--short", "--directory", "/tmp/scratch", + "--bump", "minor", "--bump", "alpha", + ] + + @pytest.mark.parametrize( + ("base", "segments", "expected"), + [ + ("1.2.3", ["patch"], "1.2.4"), + ("1.2.3", ["minor"], "1.3.0"), + ("1.2.3", ["major"], "2.0.0"), + ("1.2.3", ["minor", "alpha"], "1.3.0a1"), + ("1.2.3", ["patch", "rc"], "1.2.4rc1"), + ("1.2.3", ["patch", "dev"], "1.2.4.dev1"), + ("1.2.3b2", ["stable"], "1.2.3"), + ("1.2.3b2", ["beta"], "1.2.3b3"), + ], + ) + def test_bump_rules_match_the_static_mode(self, dynamic, base, segments, expected): + # uv does the arithmetic in both modes, so the rules cannot drift apart. + assert dynamic.apply_bump(segments, base) == expected + + def test_unsupported_segment_is_reported(self, dynamic): + with pytest.raises(bump.BumpError, match="command failed"): + dynamic.apply_bump(["epoch"], "1.2.3") + + class TestNodeBumpCommands: """Shared across yarn, npm and pnpm — all bump via the npm CLI.""" @@ -180,6 +348,7 @@ class TestConfig: def test_defaults_match_the_action_inputs(self): config = bump.Config.from_env({"BUMP_RULE": "patch"}) assert config.project_type == "uv" + assert config.dynamic_versioning is False assert config.pre_release_base == "patch" assert config.pre_release_bump == "dev" assert config.update_changelog is True @@ -224,6 +393,35 @@ def test_backend_name_matches_its_project_type(self): for project_type, backend in bump.BACKENDS.items(): assert backend.name == project_type + def test_dynamic_versioning_selects_the_dynamic_backend(self): + config = bump.Config.from_env( + {"BUMP_RULE": "patch", "DYNAMIC_VERSIONING": "true"} + ) + assert isinstance(config.backend(), bump.DynamicBackend) + + def test_dynamic_versioning_drops_manifest_only_inputs(self): + config = bump.Config.from_env( + { + "BUMP_RULE": "patch", + "DYNAMIC_VERSIONING": "true", + "WORKSPACE_PACKAGES": "one\ntwo", + "RUN_LOCK": "true", + } + ) + assert config.workspace_packages == () + assert config.run_lock is False + + def test_dynamic_versioning_rejects_node_projects(self): + config = bump.Config.from_env( + { + "BUMP_RULE": "patch", + "DYNAMIC_VERSIONING": "true", + "PROJECT_TYPE": "yarn", + } + ) + with pytest.raises(bump.BumpError, match="only supported for project-type"): + config.backend() + def test_unknown_project_type_is_rejected(self): config = bump.Config.from_env({"BUMP_RULE": "patch", "PROJECT_TYPE": "poetry"}) with pytest.raises(