Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
431 changes: 279 additions & 152 deletions .github/CI.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

changelog:
exclude:
authors:
- dependabot[bot]
95 changes: 95 additions & 0 deletions .github/scripts/resolve_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Validate release workflow inputs and resolve an explicit package version."""

from __future__ import annotations

import argparse
import re

BASE_VERSION_PATTERN = re.compile(r"^v(?P<base>[0-9]+\.[0-9]+\.[0-9]+)$")
POSITIVE_INTEGER_PATTERN = re.compile(r"^[1-9][0-9]*$")


def _optional_positive_integer(value: str | None, name: str) -> int | None:
if value in (None, ""):
return None
if not POSITIVE_INTEGER_PATTERN.fullmatch(value):
raise ValueError(f"{name} must be a positive integer, got {value!r}")
return int(value)


def resolve_release_version(
*,
version: str,
alpha: str | None = None,
rc: str | None = None,
ga: bool = False,
ref_name: str,
) -> str | None:
"""Return the exact explicit release version, or ``None`` for a development build."""

version_match = BASE_VERSION_PATTERN.fullmatch(version)
if version_match is None:
raise ValueError(f"version must look like vX.Y.Z, got {version!r}")

alpha_number = _optional_positive_integer(alpha, "alpha")
rc_number = _optional_positive_integer(rc, "rc")

if alpha_number is not None and rc_number is not None:
raise ValueError("alpha and rc are mutually exclusive")
if ga and (alpha_number is not None or rc_number is not None):
raise ValueError("alpha and rc must be empty for GA releases")

is_explicit_release = ga or alpha_number is not None or rc_number is not None
if is_explicit_release and not ref_name.startswith("release/"):
raise ValueError("explicit alpha, RC, and GA releases must use a release/* branch")

base_version = version_match.group("base")
if alpha_number is not None:
return f"{base_version}a{alpha_number}"
if rc_number is not None:
return f"{base_version}rc{rc_number}"
if ga:
return base_version
return None


def _parse_bool(value: str) -> bool:
normalized = value.lower()
if normalized == "true":
return True
if normalized == "false":
return False
raise argparse.ArgumentTypeError("expected true or false")


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--alpha", default="")
parser.add_argument("--rc", default="")
parser.add_argument("--ga", default=False, type=_parse_bool)
parser.add_argument("--ref-name", required=True)
args = parser.parse_args()

try:
resolved_version = resolve_release_version(
version=args.version,
alpha=args.alpha,
rc=args.rc,
ga=args.ga,
ref_name=args.ref_name,
)
except ValueError as error:
parser.error(str(error))

if resolved_version is not None:
print(resolved_version)
return 0


if __name__ == "__main__":
raise SystemExit(main())
70 changes: 62 additions & 8 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@

# Manual release workflow: lint, unit tests, wheel + sdist build,
# package metadata + content validation, installed-wheel smoke tests,
# and TestPyPI publish (for both GA and non-GA dispatches). The K2
# Kitmaker wheel-release flow promotes the staged wheel from TestPyPI
# (preferred — via the new `release_kitmaker_wheel.py upload
# --wheel-url https://test.pypi.org/project/holoscan-cli/<v>/` shape)
# or, as a fallback, from the `wheel-artifact` uploaded here.
# and TestPyPI publish (for both GA and non-GA dispatches). The approved
# NVIDIA package-promotion process consumes the staged wheel from TestPyPI
# or, as a fallback, from the `wheel-artifact` uploaded here. This workflow
# has no public-PyPI deployment job.
#
# For more information see:
# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
Expand All @@ -33,6 +32,10 @@ on:
description: 'Version (e.g. v1.2.34)'
required: true
type: string
alpha:
description: 'Alpha build number (for example, 1 produces X.Y.Za1)'
required: false
type: number
rc:
description: 'RC Build Number'
required: false
Expand Down Expand Up @@ -152,15 +155,34 @@ jobs:
- name: Validate release inputs
env:
VERSION: ${{ github.event.inputs.version }}
ALPHA: ${{ github.event.inputs.alpha }}
RC: ${{ github.event.inputs.rc }}
GA: ${{ github.event.inputs.ga }}
REF_NAME: ${{ github.ref_name }}
run: |
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version must look like vX.Y.Z, got '$VERSION'" >&2
exit 1
fi
if [[ "$GA" == "true" && -n "$RC" ]]; then
echo "rc must be empty for GA releases" >&2
if [[ -n "$ALPHA" && ! "$ALPHA" =~ ^[1-9][0-9]*$ ]]; then
echo "alpha must be a positive integer, got '$ALPHA'" >&2
exit 1
fi
if [[ -n "$RC" && ! "$RC" =~ ^[1-9][0-9]*$ ]]; then
echo "rc must be a positive integer, got '$RC'" >&2
exit 1
fi
if [[ -n "$ALPHA" && -n "$RC" ]]; then
echo "alpha and rc are mutually exclusive" >&2
exit 1
fi
if [[ "$GA" == "true" && ( -n "$ALPHA" || -n "$RC" ) ]]; then
echo "alpha and rc must be empty for GA releases" >&2
exit 1
fi
if [[ ( -n "$ALPHA" || -n "$RC" || "$GA" == "true" ) \
&& "$REF_NAME" != release/* ]]; then
echo "explicit alpha, RC, and GA releases must use a release/* branch" >&2
exit 1
fi

Expand All @@ -187,6 +209,23 @@ jobs:
with:
python-version: "3.12"

- name: Resolve explicit release package version
id: release-version
env:
VERSION: ${{ github.event.inputs.version }}
ALPHA: ${{ github.event.inputs.alpha }}
RC: ${{ github.event.inputs.rc }}
GA: ${{ github.event.inputs.ga }}
REF_NAME: ${{ github.ref_name }}
run: |
package_version=$(python .github/scripts/resolve_release_version.py \
--version "$VERSION" \
--alpha "$ALPHA" \
--rc "$RC" \
--ga "$GA" \
--ref-name "$REF_NAME")
echo "package-version=${package_version}" >> "$GITHUB_OUTPUT"

- name: Install Poetry
# See note in the pre-commit job above.
run: |
Expand All @@ -195,15 +234,29 @@ jobs:

- name: Build wheel and sdist
env:
EXACT_VERSION: ${{ steps.release-version.outputs.package-version }}
rc: ${{ github.event.inputs.rc }}
ga: ${{ github.event.inputs.ga }}
run: |
if [[ -n "$EXACT_VERSION" ]]; then
export POETRY_DYNAMIC_VERSIONING_BYPASS="$EXACT_VERSION"
echo "Building exact release version $EXACT_VERSION"
fi
git tag -l
poetry run which python
source $(poetry env info --path)/bin/activate
poetry install
poetry dynamic-versioning -vvv
poetry build -vvv --clean
if [[ -n "$EXACT_VERSION" ]]; then
wheel=$(find dist -maxdepth 1 -type f \
-name "holoscan_cli-${EXACT_VERSION}-*.whl" -print -quit)
if [[ -z "$wheel" || ! -f "dist/holoscan_cli-${EXACT_VERSION}.tar.gz" ]]; then
echo "built artifacts do not match expected version $EXACT_VERSION" >&2
find dist -maxdepth 1 -type f -print >&2
exit 1
fi
fi

- name: Validate package metadata
run: |
Expand All @@ -221,7 +274,7 @@ jobs:
path: dist/*

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: Upload wheel for K2 Kitmaker
name: Upload wheel for NVIDIA promotion
with:
name: wheel-artifact
path: dist/holoscan_cli-*.whl
Expand Down Expand Up @@ -345,6 +398,7 @@ jobs:
python -m venv /tmp/holoscan-cli-testpypi
for i in 1 2 3 4 5 6 7 8 9 10; do
if /tmp/holoscan-cli-testpypi/bin/pip install \
--only-binary=holoscan-cli \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
"holoscan-cli==$version"; then
Expand Down
13 changes: 7 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,17 @@ without publishing a wheel first.
(`.github/workflows/release.yaml`). Dispatch it via the CLI:

```bash
gh workflow run release.yaml --ref <branch> \
-f version=vX.Y.Z \
-f rc=<optional-rc-number> \
-f ga=false # true only for an official GA
gh workflow run release.yaml --ref release/X.Y.Z \
-f version=vX.Y.Z -f alpha=1 -f ga=false # produces X.Y.Za1
```

The dispatch creates `refs/tags/vX.Y.Z` at the dispatch SHA, builds, smokes,
publishes to TestPyPI, re-installs from `test.pypi.org/simple/` and re-smokes,
and deletes the tag when `ga=false` (so RC dispatches leave no stray refs).
See [`.github/CI.md`](./.github/CI.md) for the full pipeline.
and deletes the temporary base tag when `ga=false`. Use `alpha=N` for
integration alphas, `rc=N` for release candidates, or `ga=true` for the final
version; those selectors are mutually exclusive. The workflow never publishes
to public PyPI. See [`.github/CI.md`](./.github/CI.md) for the full release
branch, promotion, and tagging runbook.

If you need to introduce or bump a third-party Action, see
[`.github/CI.md`](./.github/CI.md#github-actions-allowlist) — the repo's
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/test_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import subprocess
import sys
from pathlib import Path

import pytest

SCRIPT = Path(__file__).parents[2] / ".github" / "scripts" / "resolve_release_version.py"


def run_resolver(*arguments: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *arguments],
check=False,
capture_output=True,
text=True,
)


@pytest.mark.parametrize(
("maturity_arguments", "expected_version"),
[
(("--alpha", "1"), "5.0.0a1"),
(("--alpha", "12"), "5.0.0a12"),
(("--rc", "2"), "5.0.0rc2"),
(("--ga", "true"), "5.0.0"),
],
)
def test_resolves_explicit_release_version(maturity_arguments, expected_version):
result = run_resolver(
"--version",
"v5.0.0",
"--ref-name",
"release/5.0.0",
*maturity_arguments,
)

assert result.returncode == 0, result.stderr
assert result.stdout.strip() == expected_version


def test_leaves_implicit_development_version_to_dynamic_versioning():
result = run_resolver(
"--version",
"v5.0.0",
"--ref-name",
"feature/integration-test",
)

assert result.returncode == 0, result.stderr
assert result.stdout == ""


@pytest.mark.parametrize(
("arguments", "message"),
[
(("--version", "5.0.0"), "version must look like vX.Y.Z"),
(("--alpha", "0"), "alpha must be a positive integer"),
(("--rc", "-1"), "rc must be a positive integer"),
(("--alpha", "1", "--rc", "1"), "alpha and rc are mutually exclusive"),
(
("--alpha", "1", "--ga", "true"),
"alpha and rc must be empty for GA releases",
),
],
)
def test_rejects_invalid_release_inputs(arguments, message):
result = run_resolver(
"--version",
"v5.0.0",
"--ref-name",
"release/5.0.0",
*arguments,
)

assert result.returncode != 0
assert message in result.stderr


def test_rejects_explicit_release_from_non_release_branch():
result = run_resolver(
"--version",
"v5.0.0",
"--alpha",
"1",
"--ref-name",
"main",
)

assert result.returncode != 0
assert "must use a release/* branch" in result.stderr
Loading