#!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations import argparse import json import re import subprocess from dataclasses import asdict, dataclass from pathlib import Path SEMVER_TAG_GLOB = "v[0-9]*.[0-9]*.[0-9]*" SEMVER_TAG_RE = re.compile(r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)$") @dataclass(frozen=True) class Versions: python: str cargo: str docker: str deb: str rpm_version: str rpm_release: str git_tag: str git_sha: str git_distance: int def _repo_root() -> Path: return Path(__file__).resolve().parents[2] def _run(cmd: list[str], *, env: dict[str, str] | None = None) -> None: subprocess.run(cmd, check=True, env=env) def _git(cmd: list[str]) -> str: return ( subprocess.check_output(["git", *cmd], cwd=_repo_root()).decode("utf-8").strip() ) def _parse_semver_tag(tag: str) -> tuple[int, int, int] | None: match = SEMVER_TAG_RE.match(tag) if match is None: return None return ( int(match.group("major")), int(match.group("minor")), int(match.group("patch")), ) def _format_semver(version: tuple[int, int, int]) -> str: return f"{version[0]}.{version[1]}.{version[2]}" def _next_patch(version: tuple[int, int, int]) -> tuple[int, int, int]: return version[0], version[1], version[2] + 1 def _latest_semver_tag() -> str | None: try: tag = _git( ["describe", "--tags", "--match", SEMVER_TAG_GLOB, "--abbrev=0", "HEAD"] ) except subprocess.CalledProcessError: return None if _parse_semver_tag(tag) is None: raise RuntimeError(f"git describe returned non-semver release tag: {tag}") return tag def _versions_from_parts( base_version: tuple[int, int, int], git_distance: int, git_sha: str, git_tag: str, ) -> Versions: if git_distance == 0: python_version = _format_semver(base_version) rpm_version = python_version rpm_release = "1" else: next_version = _format_semver(_next_patch(base_version)) python_version = f"{next_version}.dev{git_distance}+g{git_sha}" rpm_version = next_version rpm_release = f"0.dev.{git_distance}.g{git_sha}" # Convert PEP 440 to a SemVer-ish string for Cargo: # 0.1.0.dev3+gabcdef -> 0.1.0-dev.3+gabcdef cargo_version = re.sub(r"\.dev(\d+)", r"-dev.\1", python_version) # Docker tags can't contain '+'. docker_version = cargo_version.replace("+", "-") # Debian versions use '~' so prereleases sort before the eventual release. deb_version = cargo_version deb_version = deb_version[1:] if deb_version.startswith("v") else deb_version deb_version = deb_version.replace("-dev.", "~dev.", 1) deb_version = f"{deb_version}-1" return Versions( python=python_version, cargo=cargo_version, docker=docker_version, deb=deb_version, rpm_version=rpm_version, rpm_release=rpm_release, git_tag=git_tag, git_sha=git_sha, git_distance=git_distance, ) def _compute_versions() -> Versions: git_tag = _latest_semver_tag() git_sha = _git(["rev-parse", "--short=9", "HEAD"]) if git_tag is None: base_version = (0, 0, 0) git_distance = int(_git(["rev-list", "--count", "HEAD"])) return _versions_from_parts(base_version, git_distance, git_sha, "") parsed_tag = _parse_semver_tag(git_tag) if parsed_tag is None: raise RuntimeError(f"invalid semantic release tag: {git_tag}") git_distance = int(_git(["rev-list", f"{git_tag}..HEAD", "--count"])) return _versions_from_parts(parsed_tag, git_distance, git_sha, git_tag) def _print_env(versions: Versions) -> None: print(f"VERSION_PY={versions.python}") print(f"VERSION_CARGO={versions.cargo}") print(f"VERSION_DOCKER={versions.docker}") print(f"VERSION_DEB={versions.deb}") print(f"VERSION_RPM={versions.rpm_version}") print(f"VERSION_RPM_RELEASE={versions.rpm_release}") print(f"GIT_TAG={versions.git_tag}") print(f"GIT_SHA={versions.git_sha}") print(f"GIT_DISTANCE={versions.git_distance}") def get_version(format: str) -> None: versions = _compute_versions() if format == "python": print(versions.python) elif format == "cargo": print(versions.cargo) elif format == "docker": print(versions.docker) elif format == "deb": print(versions.deb) elif format == "rpm-version": print(versions.rpm_version) elif format == "rpm-release": print(versions.rpm_release) elif format == "json": print(json.dumps(asdict(versions), sort_keys=True)) else: _print_env(versions) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="OpenClaw release tooling.") sub = parser.add_subparsers(dest="command", required=True) get_version_parser = sub.add_parser("get-version", help="Print computed version.") get_version_parser.add_argument( "--python", action="store_true", help="Print Python version only." ) get_version_parser.add_argument( "--cargo", action="store_true", help="Print Cargo version only." ) get_version_parser.add_argument( "--docker", action="store_true", help="Print Docker version only." ) get_version_parser.add_argument( "--deb", action="store_true", help="Print Debian package version only." ) get_version_parser.add_argument( "--rpm-version", action="store_true", help="Print RPM Version only." ) get_version_parser.add_argument( "--rpm-release", action="store_true", help="Print RPM Release only." ) get_version_parser.add_argument( "--json", action="store_true", help="Print all versions as JSON." ) return parser def main() -> None: parser = build_parser() args = parser.parse_args() if args.command == "get-version": if args.python: get_version("python") elif args.cargo: get_version("cargo") elif args.docker: get_version("docker") elif args.deb: get_version("deb") elif args.rpm_version: get_version("rpm-version") elif args.rpm_release: get_version("rpm-release") elif args.json: get_version("json") else: get_version("all") if __name__ == "__main__": main()