diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 068fa5c85..258ec9bc6 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -30,6 +30,8 @@ jobs: python_version: ${{ steps.v.outputs.python }} cargo_version: ${{ steps.v.outputs.cargo }} deb_version: ${{ steps.v.outputs.deb }} + rpm_version: ${{ steps.v.outputs.rpm_version }} + rpm_release: ${{ steps.v.outputs.rpm_release }} steps: - uses: actions/checkout@v6 with: @@ -48,6 +50,8 @@ jobs: echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" build-gateway: needs: [compute-versions] @@ -651,6 +655,9 @@ jobs: uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ github.sha }} + rpm-version: ${{ needs.compute-versions.outputs.rpm_version }} + rpm-release: ${{ needs.compute-versions.outputs.rpm_release }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} secrets: inherit # --------------------------------------------------------------------------- @@ -720,6 +727,31 @@ jobs: path: release/ merge-multiple: true + - name: Normalize dev package filenames + run: | + set -euo pipefail + shopt -s nullglob + + move_one() { + local dest="$1" + shift + local matches=("$@") + if [ "${#matches[@]}" -ne 1 ]; then + echo "expected exactly one source for ${dest}, found ${#matches[@]}: ${matches[*]-}" >&2 + exit 1 + fi + mv "${matches[0]}" "release/${dest}" + } + + move_one openshell-dev-amd64.deb release/openshell_*_amd64.deb + move_one openshell-dev-arm64.deb release/openshell_*_arm64.deb + move_one openshell-dev-x86_64.rpm release/openshell-[0-9]*.x86_64.rpm + move_one openshell-dev-aarch64.rpm release/openshell-[0-9]*.aarch64.rpm + move_one openshell-gateway-dev-x86_64.rpm release/openshell-gateway-[0-9]*.x86_64.rpm + move_one openshell-gateway-dev-aarch64.rpm release/openshell-gateway-[0-9]*.aarch64.rpm + + ls -la release/ + - name: Capture wheel filenames id: wheel_filenames run: | @@ -736,7 +768,7 @@ jobs: openshell-x86_64-unknown-linux-musl.tar.gz \ openshell-aarch64-unknown-linux-musl.tar.gz \ openshell-aarch64-apple-darwin.tar.gz \ - openshell_*.deb \ + *.deb \ openshell-*.rpm \ *.whl > openshell-checksums-sha256.txt cat openshell-checksums-sha256.txt @@ -758,20 +790,12 @@ jobs: release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz - - name: Prune stale wheel, deb, and rpm assets from dev release + - name: Prune managed assets from dev release uses: actions/github-script@v7 - env: - WHEEL_VERSION: ${{ needs.compute-versions.outputs.python_version }} with: script: | - const wheelVersion = process.env.WHEEL_VERSION; - const currentPrefix = `openshell-${wheelVersion}-`; const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - core.info(`=== Wheel pruning diagnostics ===`); - core.info(`WHEEL_VERSION: ${wheelVersion}`); - core.info(`CURRENT_PREFIX: ${currentPrefix}`); - // Fetch the dev release let release; try { @@ -790,31 +814,29 @@ jobs: core.info(` ${String(a.id).padStart(12)} ${a.name}`); } - // Delete stale wheels, debs, rpms, and removed VM checksum assets. - let kept = 0, deleted = 0, debDeleted = 0, rpmDeleted = 0, removedVmChecksums = 0; + const managed = (name) => ( + name.startsWith('openshell') && + ( + name.endsWith('.tar.gz') || + name.endsWith('.txt') || + name.endsWith('.whl') || + name.endsWith('.deb') || + name.endsWith('.rpm') + ) + ); + + let deleted = 0, skipped = 0; for (const asset of assets) { - if (asset.name.endsWith('.deb')) { - core.info(`Deleting stale deb package: ${asset.name} (id=${asset.id})`); - await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); - debDeleted++; - } else if (asset.name.endsWith('.rpm')) { - core.info(`Deleting stale rpm package: ${asset.name} (id=${asset.id})`); - await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); - rpmDeleted++; - } else if (asset.name === 'openshell-driver-vm-checksums-sha256.txt') { - core.info(`Deleting removed VM checksum asset: ${asset.name} (id=${asset.id})`); - await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); - removedVmChecksums++; - } else if (asset.name.endsWith('.whl') && asset.name.startsWith(currentPrefix)) { - core.info(`Keeping current wheel: ${asset.name}`); - kept++; - } else if (asset.name.endsWith('.whl')) { - core.info(`Deleting stale wheel: ${asset.name} (id=${asset.id})`); + if (managed(asset.name)) { + core.info(`Deleting managed dev asset: ${asset.name} (id=${asset.id})`); await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); deleted++; + } else { + core.info(`Skipping unmanaged asset: ${asset.name}`); + skipped++; } } - core.info(`Summary: kept_wheels=${kept}, deleted_wheels=${deleted}, deleted_debs=${debDeleted}, deleted_rpms=${rpmDeleted}, deleted_removed_vm_checksums=${removedVmChecksums}`); + core.info(`Summary: deleted=${deleted}, skipped=${skipped}`); - name: Move dev tag run: | @@ -845,7 +867,8 @@ jobs: release/openshell-x86_64-unknown-linux-musl.tar.gz release/openshell-aarch64-unknown-linux-musl.tar.gz release/openshell-aarch64-apple-darwin.tar.gz - release/openshell_*.deb + release/openshell-dev-amd64.deb + release/openshell-dev-arm64.deb release/openshell-*.rpm release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 34d82a879..0e9fab9bc 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -41,6 +41,8 @@ jobs: python_version: ${{ steps.v.outputs.python }} cargo_version: ${{ steps.v.outputs.cargo }} deb_version: ${{ steps.v.outputs.deb }} + rpm_version: ${{ steps.v.outputs.rpm_version }} + rpm_release: ${{ steps.v.outputs.rpm_release }} # Semver without 'v' prefix (e.g. 0.6.0), used for image tags and release body semver: ${{ steps.v.outputs.semver }} steps: @@ -62,6 +64,8 @@ jobs: echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" build-gateway: @@ -678,6 +682,9 @@ jobs: uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} + rpm-version: ${{ needs.compute-versions.outputs.rpm_version }} + rpm-release: ${{ needs.compute-versions.outputs.rpm_release }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} secrets: inherit # --------------------------------------------------------------------------- diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml index f6003d666..6c040fa5f 100644 --- a/.github/workflows/rpm-package.yml +++ b/.github/workflows/rpm-package.yml @@ -9,6 +9,18 @@ on: checkout-ref: required: true type: string + rpm-version: + required: false + type: string + default: "" + rpm-release: + required: false + type: string + default: "" + cargo-version: + required: false + type: string + default: "" permissions: contents: read @@ -21,6 +33,7 @@ jobs: build-rpm-linux: name: Build RPM Package (Linux ${{ matrix.arch }}) strategy: + fail-fast: false matrix: include: - arch: x86_64 @@ -53,22 +66,23 @@ jobs: run: git fetch --tags --force - name: Build RPMs via Packit + env: + OPENSHELL_RPM_VERSION: ${{ inputs['rpm-version'] }} + OPENSHELL_RPM_RELEASE: ${{ inputs['rpm-release'] }} + OPENSHELL_CARGO_VERSION: ${{ inputs['cargo-version'] }} run: packit build locally - name: Collect RPM artifacts run: | set -euo pipefail mkdir -p artifacts - for rpm_dir in "$HOME/rpmbuild/RPMS" "$PWD/${{ matrix.arch }}"; do - if [ -d "$rpm_dir" ]; then - find "$rpm_dir" -type f -name '*.rpm' -exec cp {} artifacts/ \; - fi - done - if ! compgen -G 'artifacts/*.rpm' > /dev/null; then - echo "::error::No RPM artifacts found" - find "$PWD" -maxdepth 3 -type f -name '*.rpm' -print + mapfile -t rpms < <(find "$GITHUB_WORKSPACE" -maxdepth 3 -type f -name '*.rpm' ! -name '*.src.rpm' | sort) + if [ "${#rpms[@]}" -eq 0 ]; then + echo "::error::No RPM artifacts found under $GITHUB_WORKSPACE" + find "$GITHUB_WORKSPACE" -maxdepth 3 -type f | sort exit 1 fi + cp "${rpms[@]}" artifacts/ echo "=== Built RPMs ===" ls -lah artifacts/ diff --git a/.packit.yaml b/.packit.yaml index 2598ae638..5070feeb8 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -17,10 +17,11 @@ srpm_build_deps: actions: get-current-version: - # Derive version from the latest SemVer upstream tag on the current branch. - # Avoid operational tags such as vm-dev; Packit normalizes that to m.dev, - # which is not a valid Cargo package version. - - 'bash -c "git describe --tags --match ''v[0-9]*.[0-9]*.[0-9]*'' --abbrev=0 HEAD | sed ''s/^v//''"' + # Release workflows pass OPENSHELL_RPM_VERSION so every artifact shares one + # precomputed identity. Local Packit runs fall back to the latest SemVer + # upstream tag, avoiding operational tags such as vm-dev; Packit normalizes + # those to invalid Cargo package versions such as m.dev. + - 'bash -c "if [ -n \"${OPENSHELL_RPM_VERSION:-}\" ]; then echo \"${OPENSHELL_RPM_VERSION}\"; else git describe --tags --match ''v[0-9]*.[0-9]*.[0-9]*'' --abbrev=0 HEAD | sed ''s/^v//''; fi"' create-archive: # Step 1: Create source tarball from git working tree. @@ -41,7 +42,11 @@ actions: # Update Version - 'bash -c "sed -i -r \"s/^Version:(\\s*)\\S+/Version:\\1${PACKIT_RPMSPEC_VERSION}/\" openshell.spec"' # Update Release - - 'bash -c "sed -i -r \"s/^Release:(\\s*)\\S+/Release:\\1${PACKIT_RPMSPEC_RELEASE}%{?dist}/\" openshell.spec"' + - 'bash -c "RELEASE=${OPENSHELL_RPM_RELEASE:-${PACKIT_RPMSPEC_RELEASE}} && sed -i -r \"s/^Release:(\\s*)\\S+/Release:\\1${RELEASE}%{?dist}/\" openshell.spec"' + # Keep embedded binary metadata aligned with the release workflow. Python + # dist-info stays at the RPM Version; dev build identity is carried by + # Release so Fedora's Python RPM post-processing can normalize metadata. + - 'bash -c "if [ -n \"${OPENSHELL_CARGO_VERSION:-}\" ]; then sed -i -r \"s/^%global openshell_cargo_version .*/%global openshell_cargo_version ${OPENSHELL_CARGO_VERSION}/\" openshell.spec; fi"' jobs: # Build on every pull request targeting main for CI validation diff --git a/architecture/build-containers.md b/architecture/build-containers.md index 61cfe9d89..c59ec9a5a 100644 --- a/architecture/build-containers.md +++ b/architecture/build-containers.md @@ -66,6 +66,22 @@ OpenShell also publishes Python wheels for `linux/amd64`, `linux/arm64`, and mac - Release workflows mirror the CLI layout: a Linux matrix job for amd64/arm64, a separate macOS job, and release jobs that download the per-platform wheel artifacts directly before publishing. - Release CPU jobs run on `linux-amd64-cpu8` and `linux-arm64-cpu8`; the macOS wheel is still cross-compiled in Docker from the amd64 Linux runner. +## Development Release Assets + +The rolling `dev` release is installer-facing but still publishes the full +artifact set: CLI tarballs, standalone gateway and sandbox tarballs, Python +wheels, Debian packages, RPM packages, and checksums. Every artifact is built +from the version computed once in `release-dev.yml`. + +Package-manager artifacts use stable dev aliases on the GitHub release +(`openshell-dev-*.deb`, `openshell-dev-*.rpm`, and +`openshell-gateway-dev-*.rpm`) so the rolling release stays readable. Python +wheels keep their versioned filenames because wheel metadata requires it. + +The dev release workflow prunes workflow-owned `openshell*` assets before +uploading the fresh set. `openshell-driver-vm` artifacts are intentionally not +published on the main `dev` release; VM driver binaries live on `vm-dev`. + ## Sandbox Images Sandbox images are not built in this repository. They are maintained in the [openshell-community](https://github.com/nvidia/openshell-community) repository and pulled from `ghcr.io/nvidia/openshell-community/sandboxes/` at runtime. diff --git a/deploy/docker/Dockerfile.cli-macos b/deploy/docker/Dockerfile.cli-macos index d07d5a9ee..7dce3a63d 100644 --- a/deploy/docker/Dockerfile.cli-macos +++ b/deploy/docker/Dockerfile.cli-macos @@ -12,11 +12,12 @@ # --build-arg OPENSHELL_CARGO_VERSION=0.6.0 \ # --output type=local,dest=out/ . -ARG OSXCROSS_IMAGE=crazymax/osxcross:latest +ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest +ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python:3.12-slim FROM ${OSXCROSS_IMAGE} AS osxcross -FROM python:3.12-slim AS builder +FROM ${PYTHON_IMAGE} AS builder ARG CARGO_TARGET_CACHE_SCOPE=default diff --git a/deploy/docker/Dockerfile.driver-vm-macos b/deploy/docker/Dockerfile.driver-vm-macos index f667653d0..58317a52d 100644 --- a/deploy/docker/Dockerfile.driver-vm-macos +++ b/deploy/docker/Dockerfile.driver-vm-macos @@ -18,11 +18,12 @@ # --build-context vm-runtime-compressed=/path/to/compressed-dir \ # --output type=local,dest=out/ . -ARG OSXCROSS_IMAGE=crazymax/osxcross:latest +ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest +ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python:3.12-slim FROM ${OSXCROSS_IMAGE} AS osxcross -FROM python:3.12-slim AS builder +FROM ${PYTHON_IMAGE} AS builder ARG CARGO_TARGET_CACHE_SCOPE=default diff --git a/deploy/docker/Dockerfile.gateway-macos b/deploy/docker/Dockerfile.gateway-macos index 9d83c7990..4cae2f0e7 100644 --- a/deploy/docker/Dockerfile.gateway-macos +++ b/deploy/docker/Dockerfile.gateway-macos @@ -6,11 +6,12 @@ # Cross-compile the standalone openshell-gateway binary for macOS aarch64 # (Apple Silicon) using the osxcross toolchain. -ARG OSXCROSS_IMAGE=crazymax/osxcross:latest +ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest +ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python:3.12-slim FROM ${OSXCROSS_IMAGE} AS osxcross -FROM python:3.12-slim AS builder +FROM ${PYTHON_IMAGE} AS builder ARG CARGO_TARGET_CACHE_SCOPE=default diff --git a/deploy/docker/Dockerfile.python-wheels-macos b/deploy/docker/Dockerfile.python-wheels-macos index 8c22537e9..45194b069 100644 --- a/deploy/docker/Dockerfile.python-wheels-macos +++ b/deploy/docker/Dockerfile.python-wheels-macos @@ -4,12 +4,13 @@ # SPDX-License-Identifier: Apache-2.0 -ARG OSXCROSS_IMAGE=crazymax/osxcross:latest +ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest +ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python ARG PYTHON_VERSION=3.12 FROM ${OSXCROSS_IMAGE} AS osxcross -FROM python:${PYTHON_VERSION}-slim AS builder +FROM ${PYTHON_IMAGE}:${PYTHON_VERSION}-slim AS builder ARG TARGETARCH ARG CARGO_TARGET_CACHE_SCOPE=default diff --git a/openshell.spec b/openshell.spec index 36aee556e..82bf6459d 100644 --- a/openshell.spec +++ b/openshell.spec @@ -2,6 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 %global crate openshell +%global openshell_cargo_version %{version} +# Python dist-info metadata intentionally follows the RPM Version. Dev build +# identity is represented by Release for RPM packages. +%global openshell_python_version %{version} # Cargo/Rust builds with vendored deps do not produce debugsource listings # in the format redhat-rpm-config expects (especially on EPEL). @@ -87,9 +91,9 @@ management, agent execution, and inference routing via gRPC. tar xf %{SOURCE1} %cargo_prep -v vendor -# Patch workspace version from placeholder to actual version -sed -i 's/^version = "0.0.0"/version = "%{version}"/' Cargo.toml -grep -q 'version = "%{version}"' Cargo.toml || (echo "ERROR: Cargo.toml version patch failed" && exit 1) +# Patch workspace version from placeholder to actual build identity. +sed -i 's/^version = "0.0.0"/version = "%{openshell_cargo_version}"/' Cargo.toml +grep -q 'version = "%{openshell_cargo_version}"' Cargo.toml || (echo "ERROR: Cargo.toml version patch failed" && exit 1) %build # Build the CLI and gateway binaries @@ -203,11 +207,11 @@ install -pm 0644 python/%{name}/_proto/__init__.py %{buildroot}%{python3_sitelib install -pm 0644 python/%{name}/_proto/*.py %{buildroot}%{python3_sitelib}/%{name}/_proto/ # Create dist-info so importlib.metadata can resolve the package version -install -d %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info -cat > %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/METADATA << EOF +install -d %{buildroot}%{python3_sitelib}/%{name}-%{openshell_python_version}.dist-info +cat > %{buildroot}%{python3_sitelib}/%{name}-%{openshell_python_version}.dist-info/METADATA << EOF Metadata-Version: 2.1 Name: %{name} -Version: 0.0.37 +Version: %{openshell_python_version} Summary: OpenShell Python SDK for agent execution and management License: Apache-2.0 Requires-Python: >=3.12 @@ -217,10 +221,10 @@ Requires-Dist: protobuf>=4.25 EOF # INSTALLER marker per PEP 376 -echo "rpm" > %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/INSTALLER +echo "rpm" > %{buildroot}%{python3_sitelib}/%{name}-%{openshell_python_version}.dist-info/INSTALLER # RECORD can be empty for RPM-managed installs -touch %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/RECORD +touch %{buildroot}%{python3_sitelib}/%{name}-%{openshell_python_version}.dist-info/RECORD %check # Smoke-test the CLI binary @@ -233,7 +237,7 @@ touch %{buildroot}%{python3_sitelib}/%{name}-%{version}.dist-info/RECORD # We query the dist-info directly rather than importing the package because # the full import pulls in grpcio and other runtime deps not present in the # build environment. -PYTHONPATH=%{buildroot}%{python3_sitelib} %{python3} -c "from importlib.metadata import version; v = version('openshell'); print(v); assert v == '%{version}', f'expected %{version}, got {v}'" +PYTHONPATH=%{buildroot}%{python3_sitelib} %{python3} -c "from importlib.metadata import version; v = version('openshell'); print(v); assert v == '%{openshell_python_version}', f'expected %{openshell_python_version}, got {v}'" %post gateway %systemd_user_post %{name}-gateway.service @@ -269,7 +273,7 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} %{python3} -c "from importlib.metadata %files -n python3-%{name} %license LICENSE %{python3_sitelib}/%{name}/ -%{python3_sitelib}/%{name}-%{version}.dist-info/ +%{python3_sitelib}/%{name}-%{openshell_python_version}.dist-info/ %changelog %autochangelog diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py new file mode 100644 index 000000000..ccc97453f --- /dev/null +++ b/python/release_tooling_test.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def _load_release_module(): + path = Path(__file__).resolve().parents[1] / "tasks/scripts/release.py" + spec = importlib.util.spec_from_file_location("openshell_release_tooling", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +release = _load_release_module() + + +def test_exact_tag_versions_are_stable_release_versions() -> None: + versions = release._versions_from_parts((0, 0, 37), 0, "152d05940", "v0.0.37") + + assert versions.python == "0.0.37" + assert versions.cargo == "0.0.37" + assert versions.docker == "0.0.37" + assert versions.deb == "0.0.37-1" + assert versions.rpm_version == "0.0.37" + assert versions.rpm_release == "1" + + +def test_dev_versions_share_one_build_identity() -> None: + versions = release._versions_from_parts((0, 0, 37), 108, "152d05940", "v0.0.37") + + assert versions.python == "0.0.38.dev108+g152d05940" + assert versions.cargo == "0.0.38-dev.108+g152d05940" + assert versions.docker == "0.0.38-dev.108-g152d05940" + assert versions.deb == "0.0.38~dev.108+g152d05940-1" + assert versions.rpm_version == "0.0.38" + assert versions.rpm_release == "0.dev.108.g152d05940" + + +def test_semver_tag_parser_excludes_vm_tags() -> None: + assert release._parse_semver_tag("v0.0.37") == (0, 0, 37) + assert release._parse_semver_tag("0.0.37") == (0, 0, 37) + assert release._parse_semver_tag("vm-runtime") is None + assert release._parse_semver_tag("vm-dev") is None diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index 0e1772b8a..0fb229022 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -6,12 +6,14 @@ from __future__ import annotations import argparse +import json import re import subprocess -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path -from setuptools_scm import get_version as scm_get_version +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) @@ -20,8 +22,11 @@ class Versions: 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: @@ -38,17 +43,54 @@ def _git(cmd: list[str]) -> str: ) -def _compute_versions() -> Versions: - root = _repo_root() - python_version = scm_get_version( - # NOTE: Cargo doesn't support .post versions, so when we are releasing, - # but not on tag, we use a next version (bumps the patch). - # EXAMPLE: if the last tag was 0.1.0, then the next version will be 0.1.1-dev.0 - version_scheme="guess-next-dev", - root=str(root), - fallback_version="0.0.0", +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) @@ -62,19 +104,48 @@ def _compute_versions() -> Versions: deb_version = deb_version.replace("-dev.", "~dev.", 1) deb_version = f"{deb_version}-1" - git_tag = _git(["describe", "--tags", "--abbrev=0"]) - git_sha = _git(["rev-parse", "--short", "HEAD"]) - 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": @@ -85,13 +156,14 @@ def get_version(format: str) -> None: 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(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"GIT_TAG={versions.git_tag}") - print(f"GIT_SHA={versions.git_sha}") + _print_env(versions) def build_parser() -> argparse.ArgumentParser: @@ -111,6 +183,15 @@ def build_parser() -> argparse.ArgumentParser: 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 @@ -128,6 +209,12 @@ def main() -> None: 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")