From f77024659632709288b180e5f746d96e35525c33 Mon Sep 17 00:00:00 2001 From: Leandro Lerena Date: Tue, 18 Aug 2026 16:18:10 +0200 Subject: [PATCH] chore(release-management): sync vendored assets to v4.2.0 Replaces the tag_*.sh scripts with the Python release.py shipped in release-management v4.2.0, and points the tag-* make targets at it. Generated by make sync-release-assets; no hand edits. Stable tags are now guarded against SemVer graph-ordering violations. make tag-* requires uv from here on. --- .../release-management-makefile | 16 +- .make_scripts/release-management/release.py | 199 ++++++++++++++++++ .../release-management/tag_latest_beta.sh | 17 -- .make_scripts/release-management/tag_major.sh | 8 - .../release-management/tag_major_beta.sh | 13 -- .make_scripts/release-management/tag_minor.sh | 7 - .../release-management/tag_minor_beta.sh | 13 -- .make_scripts/release-management/tag_patch.sh | 13 -- .../release-management/tag_patch_beta.sh | 13 -- .../release-management/test_release.py | 172 +++++++++++++++ 10 files changed, 379 insertions(+), 92 deletions(-) create mode 100644 .make_scripts/release-management/release.py delete mode 100755 .make_scripts/release-management/tag_latest_beta.sh delete mode 100755 .make_scripts/release-management/tag_major.sh delete mode 100755 .make_scripts/release-management/tag_major_beta.sh delete mode 100755 .make_scripts/release-management/tag_minor.sh delete mode 100755 .make_scripts/release-management/tag_minor_beta.sh delete mode 100755 .make_scripts/release-management/tag_patch.sh delete mode 100755 .make_scripts/release-management/tag_patch_beta.sh create mode 100644 .make_scripts/release-management/test_release.py diff --git a/.make_scripts/release-management/release-management-makefile b/.make_scripts/release-management/release-management-makefile index f982523..d412a40 100644 --- a/.make_scripts/release-management/release-management-makefile +++ b/.make_scripts/release-management/release-management-makefile @@ -1,19 +1,19 @@ tag-major: - .make_scripts/release-management/tag_major.sh + uv run .make_scripts/release-management/release.py major tag-major-beta: - .make_scripts/release-management/tag_major_beta.sh + uv run .make_scripts/release-management/release.py major-beta tag-minor: - .make_scripts/release-management/tag_minor.sh + uv run .make_scripts/release-management/release.py minor tag-minor-beta: - .make_scripts/release-management/tag_minor_beta.sh + uv run .make_scripts/release-management/release.py minor-beta tag-patch: - .make_scripts/release-management/tag_patch.sh + uv run .make_scripts/release-management/release.py patch tag-patch-beta: - .make_scripts/release-management/tag_patch_beta.sh + uv run .make_scripts/release-management/release.py patch-beta tag-latest-beta: - .make_scripts/release-management/tag_latest_beta.sh + uv run .make_scripts/release-management/release.py latest-beta sync-release-assets: - .make_scripts/release-management/sync_release_assets.sh + gh release download --repo zebbra/release-management --pattern sync_release_assets.sh --output - | /bin/sh check-for-releases: .make_scripts/release-management/check_for_releases.sh tag-major-minor-ruleset: diff --git a/.make_scripts/release-management/release.py b/.make_scripts/release-management/release.py new file mode 100644 index 0000000..d131bc4 --- /dev/null +++ b/.make_scripts/release-management/release.py @@ -0,0 +1,199 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["semver>=3"] +# /// +""" +release.py -- SemVer git tag generation with a graph-integrity guard. + +Replaces the tag_*.sh scripts; invoked by the `make tag-*` targets via `uv run`. + +Subcommands: + patch | minor | major -> stable release, guarded + patch-beta | minor-beta | major-beta | latest-beta -> prerelease, not guarded + +Version numbering is unchanged from the shell scripts: the next stable version is +the repo-wide highest stable tag bumped at the requested level. The new behaviour +is the guard: before a STABLE tag is created it is checked against HEAD's commit +graph and REFUSED if it would violate SemVer ordering (an ancestor with a +greater-or-equal version, or a descendant with a lower-or-equal version). Beta tags +are not graph-ordered and are never guarded. + +The guarantee holds relative to local tag state. The guard does a best-effort +`git fetch --tags` first (skip with RELEASE_MGMT_NO_FETCH=1); run `git fetch --all` +beforehand for a fully up-to-date graph. +""" +import argparse +import os +import subprocess +import sys + +import semver + +SEEDS = {"patch": "0.0.1", "minor": "0.1.0", "major": "1.0.0"} + + +def git(*args: str, check: bool = True) -> str: + """Run a git command and return stripped stdout.""" + result = subprocess.run( + ["git", *args], text=True, capture_output=True, check=check + ) + return result.stdout.strip() + + +def all_tags() -> list[str]: + return [t.strip() for t in git("tag").splitlines() if t.strip()] + + +def parse_tag(tag: str) -> semver.Version | None: + """Parse a git tag (optional v/V prefix) as SemVer, or None if invalid.""" + name = tag[1:] if tag[:1] in ("v", "V") and tag[1:2].isdigit() else tag + try: + return semver.Version.parse(name) + except ValueError: + return None + + +def stable_versions(tags: list[str]) -> list[semver.Version]: + """Valid stable versions (no prerelease, no build metadata) among `tags`.""" + out = [] + for t in tags: + v = parse_tag(t) + if v is not None and not v.prerelease and not v.build: + out.append(v) + return out + + +def highest_stable() -> semver.Version | None: + versions = stable_versions(all_tags()) + return max(versions) if versions else None + + +def next_stable(level: str) -> semver.Version: + base = highest_stable() + if base is None: + return semver.Version.parse(SEEDS[level]) + return { + "patch": base.bump_patch, + "minor": base.bump_minor, + "major": base.bump_major, + }[level]() + + +# --------------------------------------------------------------------------- +# Integrity guard (stable tags only) +# --------------------------------------------------------------------------- + +def _refuse(new: semver.Version, reason: str, max_anc, min_desc) -> None: + lo = f"v{max_anc}" if max_anc is not None else "(none)" + hi = f"v{min_desc}" if min_desc is not None else "(none)" + print(f"🚫 Refusing to create v{new}: it would violate SemVer graph integrity.") + print(f" Reason: {reason}.") + print(f" HEAD's highest stable ancestor: {lo}; lowest stable descendant: {hi}.") + print(f" Allowed: strictly greater than {lo} and strictly less than {hi}.") + print(" Check out the intended commit/branch, run `git fetch --all`, and retry.") + sys.exit(1) + + +def assert_integrity(new: semver.Version) -> None: + """Refuse `new` if it would break ancestor= new] + if too_high: + hi = max(too_high) + _refuse(new, f"ancestor v{hi} has a version >= v{new}", hi, min(descendants, default=None)) + + too_low = [v for v in descendants if v <= new] + if too_low: + lo = min(too_low) + _refuse(new, f"descendant v{lo} has a version <= v{new}", max(ancestors, default=None), lo) + + +# --------------------------------------------------------------------------- +# Tag creation +# --------------------------------------------------------------------------- + +def _create(tag: str, message: str, label: str) -> None: + git("tag", tag, "-m", message) + print(f"✅ Created new tag({label}): {tag}") + + +def cmd_stable(level: str) -> None: + if level == "patch": + branch = git("branch", "--show-current") + allowed = branch == "main" or branch.startswith(("hotfix/", "patch/")) + if not allowed: + print( + "🚫 A patch may only be created on hotfix/*, patch/* or main to " + f"prevent accidental breaking changes. Current branch: {branch}" + ) + sys.exit(1) + new = next_stable(level) + assert_integrity(new) + _create(f"v{new}", f"{level} release", level) + + +def _existing_betas_for(base: semver.Version) -> list[semver.Version]: + result = [] + for t in all_tags(): + v = parse_tag(t) + if ( + v is not None + and (v.major, v.minor, v.patch) == (base.major, base.minor, base.patch) + and v.prerelease + and v.prerelease.startswith("beta.") + ): + result.append(v) + return sorted(result) + + +def _bump_beta(v: semver.Version) -> semver.Version: + n = int(v.prerelease.split(".")[-1]) + return v.replace(prerelease=f"beta.{n + 1}") + + +def cmd_beta(level: str) -> None: + base = next_stable(level) + betas = _existing_betas_for(base) + new = _bump_beta(betas[-1]) if betas else base.replace(prerelease="beta.1") + _create(f"v{new}", f"{level}-beta release", f"{level}-beta") + + +def cmd_latest_beta() -> None: + betas = sorted( + v for t in all_tags() + if (v := parse_tag(t)) is not None and v.prerelease and "beta" in v.prerelease + ) + if betas: + new = _bump_beta(betas[-1]) + else: + parsed = sorted(v for t in all_tags() if (v := parse_tag(t)) is not None) + top = parsed[-1] if parsed else semver.Version.parse("0.1.0") + new = semver.Version(top.major, top.minor, top.patch, prerelease="beta.1") + _create(f"v{new}", "beta release", "beta") + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="release.py") + parser.add_argument( + "command", + choices=[ + "patch", "minor", "major", + "patch-beta", "minor-beta", "major-beta", "latest-beta", + ], + ) + cmd = parser.parse_args(argv).command + if cmd in ("patch", "minor", "major"): + cmd_stable(cmd) + elif cmd == "latest-beta": + cmd_latest_beta() + else: + cmd_beta(cmd.split("-")[0]) + + +if __name__ == "__main__": + main() diff --git a/.make_scripts/release-management/tag_latest_beta.sh b/.make_scripts/release-management/tag_latest_beta.sh deleted file mode 100755 index 25b5271..0000000 --- a/.make_scripts/release-management/tag_latest_beta.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -CURRENT_BETA_TAG=$(git tag | grep b | grep v | sed 's/v//' | sort --version-sort --reverse | head -n 1) -if [ -z "$CURRENT_BETA_TAG" ]; then - NEW_TAG=$(git tag | grep v | sed 's/v//' | sort --version-sort --reverse | head -n 1) - if [ -z "$NEW_TAG" ]; then - NEW_TAG="0.1.0" - git tag v$NEW_TAG-beta.1 -m "beta release" && echo "✅ Created new tag(beta): v$NEW_TAG-beta.1" - else - while read NEW_TAG; do git tag v$NEW_TAG-beta.1 -m "beta release" && echo "✅ Created new tag(beta): - v$NEW_TAG-beta.1"; done <<< "$NEW_TAG" - fi -else - echo "Current beta tag: v$CURRENT_BETA_TAG" - NEW_BETA_TAG=$(echo $CURRENT_BETA_TAG | awk -F. -v OFS=. '{ $4 += 1; print }') - git tag v$NEW_BETA_TAG -m "beta release" && echo "✅ Created new tag(beta): v$NEW_BETA_TAG" -fi diff --git a/.make_scripts/release-management/tag_major.sh b/.make_scripts/release-management/tag_major.sh deleted file mode 100755 index 0c83bf9..0000000 --- a/.make_scripts/release-management/tag_major.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -NEW_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1 | awk -F. -v OFS=. '{ $1 += 1; $2 = 0; $3 = 0; print }') -if [ -z "$NEW_TAG" ]; then - NEW_TAG="1.0.0" -fi -git tag v$NEW_TAG -m "major release" && echo "✅ Created new tag(major): v$NEW_TAG" - diff --git a/.make_scripts/release-management/tag_major_beta.sh b/.make_scripts/release-management/tag_major_beta.sh deleted file mode 100755 index 7af9045..0000000 --- a/.make_scripts/release-management/tag_major_beta.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -CURRENT_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1) -CURRENT_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $1 += 1; $2 = 0; $3 = 0; print }') -CURRENT_BETA_TAG=$(echo $(git tag | grep $CURRENT_BETA_TAG | sed 's/v//' | sort --version-sort --reverse | head -n 1)) -if [ -z "$CURRENT_BETA_TAG" ]; then - NEW_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $1 += 1; $2 = 0; $3 = 0; print }') - git tag v$NEW_BETA_TAG-beta.1 -m "major-beta release" && echo "✅ Created new tag(major-beta): v$NEW_BETA_TAG-beta.1" -else - echo "Current beta tag: $CURRENT_BETA_TAG" - NEW_BETA_TAG=$(echo $CURRENT_BETA_TAG | awk -F. -v OFS=. '{ $4 += 1; print }') - git tag v$NEW_BETA_TAG -m "major-beta release" && echo "✅ Created new tag(major-beta): v$NEW_BETA_TAG" -fi diff --git a/.make_scripts/release-management/tag_minor.sh b/.make_scripts/release-management/tag_minor.sh deleted file mode 100755 index d3bae6d..0000000 --- a/.make_scripts/release-management/tag_minor.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -NEW_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1 | awk -F. -v OFS=. '{ $2 += 1; $3 = 0; print }') -if [ -z "$NEW_TAG" ]; then - NEW_TAG="0.1.0" -fi -git tag v$NEW_TAG -m "minor release" && echo "✅ Created new tag(minor): v$NEW_TAG" diff --git a/.make_scripts/release-management/tag_minor_beta.sh b/.make_scripts/release-management/tag_minor_beta.sh deleted file mode 100755 index f6f9c7f..0000000 --- a/.make_scripts/release-management/tag_minor_beta.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -CURRENT_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1) -CURRENT_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $2 += 1; $3 = 0; print }') -CURRENT_BETA_TAG=$(echo $(git tag | grep $CURRENT_BETA_TAG | sed 's/v//' | sort --version-sort --reverse | head -n 1)) -if [ -z "$CURRENT_BETA_TAG" ]; then - NEW_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $1 += 0; $2 += 1; $3 = 0; print }') - git tag v$NEW_BETA_TAG-beta.1 -m "minor-beta release" && echo "✅ Created new tag(minor-beta): v$NEW_BETA_TAG-beta.1" -else - echo "Current beta tag: $CURRENT_BETA_TAG" - NEW_BETA_TAG=$(echo $CURRENT_BETA_TAG | awk -F. -v OFS=. '{ $4 += 1; print }') - git tag v$NEW_BETA_TAG -m "minor-beta release" && echo "✅ Created new tag(minor-beta): v$NEW_BETA_TAG" -fi diff --git a/.make_scripts/release-management/tag_patch.sh b/.make_scripts/release-management/tag_patch.sh deleted file mode 100755 index 29d01b0..0000000 --- a/.make_scripts/release-management/tag_patch.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -NEW_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1 | awk -F. -v OFS=. '{ - $3 += 1; print }') - if [ -z "$NEW_TAG" ]; then - NEW_TAG="0.0.1" - fi -BRANCH=$(git branch --show-current) -if [[ $BRANCH == hotfix/* ]] || [[ $BRANCH == patch/* ]] || [[ $BRANCH == main ]]; then - git tag v$NEW_TAG -m "patch release" && echo "✅ Created new tag(patch): v$NEW_TAG" -else - echo "🚫 A patch should only be created on hotfix/*, patch/* or the main branch to prevent accidental breaking changes. Current branch: $BRANCH" -fi diff --git a/.make_scripts/release-management/tag_patch_beta.sh b/.make_scripts/release-management/tag_patch_beta.sh deleted file mode 100755 index bcc0a70..0000000 --- a/.make_scripts/release-management/tag_patch_beta.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -CURRENT_TAG=$(git tag | grep v | grep -v - | sed 's/v//' | sort --version-sort --reverse | head -n 1) -CURRENT_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $3 += 1; print }') -CURRENT_BETA_TAG=$(echo $(git tag | grep $CURRENT_BETA_TAG | sed 's/v//' | sort --version-sort --reverse | head -n 1)) -if [ -z "$CURRENT_BETA_TAG" ]; then - NEW_BETA_TAG=$(echo $CURRENT_TAG | awk -F. -v OFS=. '{ $1 += 0; $2 += 0; $3 += 1; print }') - git tag v$NEW_BETA_TAG-beta.1 -m "patch-beta release" && echo "✅ Created new tag(patch-beta): v$NEW_BETA_TAG-beta.1" -else - echo "Current beta tag: $CURRENT_BETA_TAG" - NEW_BETA_TAG=$(echo $CURRENT_BETA_TAG | awk -F. -v OFS=. '{ $4 += 1; print }') - git tag v$NEW_BETA_TAG -m "patch-beta release" && echo "✅ Created new tag(patch-beta): v$NEW_BETA_TAG" -fi diff --git a/.make_scripts/release-management/test_release.py b/.make_scripts/release-management/test_release.py new file mode 100644 index 0000000..88deda9 --- /dev/null +++ b/.make_scripts/release-management/test_release.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Tests for release.py -- SemVer tag generation with graph-integrity guard. + +Uses real temporary git repositories. The guard's remote fetch is disabled via +RELEASE_MGMT_NO_FETCH so tests run offline and deterministically. +""" +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) +import release # noqa: E402 + +os.environ["RELEASE_MGMT_NO_FETCH"] = "1" + + +@dataclass +class GitRepo: + path: Path + + def run(self, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=self.path, text=True, capture_output=True, check=True + ).stdout.strip() + + def commit(self, message: str = "c") -> str: + self.run("commit", "--allow-empty", "-m", message) + return self.run("rev-parse", "HEAD") + + def tag(self, name: str, ref: str | None = None) -> None: + self.run("tag", name, *( [ref] if ref else [] )) + + def tags(self) -> set[str]: + out = self.run("tag") + return {t for t in out.splitlines() if t} + + def branch(self, name: str) -> None: + self.run("checkout", "-b", name) + + def checkout(self, ref: str) -> None: + self.run("checkout", ref) + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch) -> GitRepo: + r = GitRepo(tmp_path) + r.run("init", "-b", "main") + r.run("config", "user.email", "t@t.com") + r.run("config", "user.name", "T") + r.commit("initial") + monkeypatch.chdir(tmp_path) # release.py operates on the current working dir + return r + + +# --- version computation ----------------------------------------------------- + +class TestNextStable: + def test_bumps(self, repo: GitRepo): + repo.tag("v1.2.3") + assert str(release.next_stable("patch")) == "1.2.4" + assert str(release.next_stable("minor")) == "1.3.0" + assert str(release.next_stable("major")) == "2.0.0" + + def test_seeds_on_empty(self, repo: GitRepo): + assert str(release.next_stable("patch")) == "0.0.1" + assert str(release.next_stable("minor")) == "0.1.0" + assert str(release.next_stable("major")) == "1.0.0" + + def test_ignores_betas_and_build(self, repo: GitRepo): + repo.tag("v1.2.3") + repo.tag("v1.3.0-beta.1") + repo.tag("v9.9.9+build.1") + assert str(release.next_stable("minor")) == "1.3.0" + + +# --- stable happy path ------------------------------------------------------- + +class TestStableHappyPath: + def test_patch_minor_major(self, repo: GitRepo): + repo.tag("v1.2.3") + repo.commit("work") + release.main(["minor"]) + assert "v1.3.0" in repo.tags() + + def test_first_ever_tag(self, repo: GitRepo): + release.main(["major"]) + assert "v1.0.0" in repo.tags() + + +# --- integrity guard --------------------------------------------------------- + +class TestGuard: + def test_refuses_on_old_commit(self, repo: GitRepo): + """HEAD is an ancestor of a higher stable tag -> refuse.""" + first = repo.run("rev-parse", "HEAD") + repo.tag("v1.0.0", first) + repo.commit("second") + repo.tag("v1.1.0") + repo.checkout(first) # detach onto the old commit + with pytest.raises(SystemExit) as exc: + release.main(["minor"]) # would be v1.2.0 on an ancestor of v1.1.0 + assert exc.value.code == 1 + assert "v1.2.0" not in repo.tags() # nothing created + + def test_allows_divergent_branch(self, repo: GitRepo): + """A side branch whose tag is neither ancestor nor descendant is fine.""" + repo.tag("v1.0.0") + repo.branch("feature") + repo.commit("feature work") + # highest stable repo-wide is still 1.0.0 -> minor => 1.1.0 on feature tip, + # which is not related to any higher tag -> allowed. + release.main(["minor"]) + assert "v1.1.0" in repo.tags() + + def test_refuses_equal_to_descendant(self, repo: GitRepo): + first = repo.run("rev-parse", "HEAD") + repo.tag("v1.0.0", first) + repo.commit("second") + repo.tag("v1.1.0") + repo.checkout(first) + with pytest.raises(SystemExit): + release.main(["patch"]) # v1.1.1 vs descendant v1.1.0 <= it + + +# --- branch restriction ------------------------------------------------------ + +class TestPatchBranchRestriction: + def test_patch_refused_off_allowed_branches(self, repo: GitRepo): + repo.tag("v1.0.0") + repo.branch("feature") + repo.commit("work") + with pytest.raises(SystemExit) as exc: + release.main(["patch"]) + assert exc.value.code == 1 + assert "v1.0.1" not in repo.tags() + + def test_patch_allowed_on_hotfix(self, repo: GitRepo): + repo.tag("v1.0.0") + repo.branch("hotfix/x") + repo.commit("fix") + release.main(["patch"]) + assert "v1.0.1" in repo.tags() + + +# --- beta (unguarded) -------------------------------------------------------- + +class TestBeta: + def test_seed_then_increment(self, repo: GitRepo): + repo.tag("v1.2.3") + repo.commit("work") + release.main(["patch-beta"]) + assert "v1.2.4-beta.1" in repo.tags() + repo.commit("more") + release.main(["patch-beta"]) + assert "v1.2.4-beta.2" in repo.tags() + + def test_beta_not_guarded_on_old_commit(self, repo: GitRepo): + """Beta tags are exempt from the integrity guard.""" + first = repo.run("rev-parse", "HEAD") + repo.tag("v1.0.0", first) + repo.commit("second") + repo.tag("v1.1.0") + repo.checkout(first) + release.main(["minor-beta"]) # must NOT raise (betas are unguarded) + # base is repo-wide (1.1.0 -> 1.2.0), unaffected by HEAD position + assert "v1.2.0-beta.1" in repo.tags()