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
53 changes: 45 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ name: Publish to PyPI

on:
push:
branches:
- 'v[0-9]+.[0-9]+'
tags:
- "v*.*.*"
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -22,28 +26,61 @@ jobs:
- name: Get version from pyproject.toml
id: version
run: |
set -euo pipefail

VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Validate release tag
run: |
set -euo pipefail

if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "Publishing must run from a release tag, not ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}."
exit 1
fi

NORMALIZED_VERSION="$(python scripts/release_version.py verify-tag \
"${GITHUB_REF_NAME}" \
"${{ steps.version.outputs.version }}")"
RELEASE_BRANCH="$(python scripts/release_version.py branch "${GITHUB_REF_NAME}")"
git fetch origin \
"refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}"
TAG_COMMIT="$(git rev-list -n 1 "${GITHUB_REF_NAME}")"
if ! git merge-base --is-ancestor "${TAG_COMMIT}" "origin/${RELEASE_BRANCH}"; then
echo "Release tag ${GITHUB_REF_NAME} must point to a commit contained in ${RELEASE_BRANCH}."
exit 1
fi
echo "Release tag ${GITHUB_REF_NAME} matches package ${NORMALIZED_VERSION} and ${RELEASE_BRANCH}."

- name: Check if version exists on PyPI
id: check
run: |
set -euo pipefail

STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/genvm-linter/${{ steps.version.outputs.version }}/json")
if [ "$STATUS" = "200" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Skip if version exists
- name: Fail if version exists
if: steps.check.outputs.exists == 'true'
run: echo "Version ${{ steps.version.outputs.version }} already published, skipping"
run: |
echo "Version ${{ steps.version.outputs.version }} is already published on PyPI."
echo "PyPI releases are immutable; bump pyproject.toml and create a new matching tag."
exit 1

- name: Install dependencies
if: steps.check.outputs.exists == 'false'
run: |
python -m pip install --upgrade pip
pip install build twine
pip install -e ".[dev]" build twine

- name: Run tests
if: steps.check.outputs.exists == 'false'
run: pytest tests/ -q

- name: Build package
if: steps.check.outputs.exists == 'false'
Expand Down
11 changes: 8 additions & 3 deletions docs/BRANCHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ not the release gate.

## Promotion and Release

When an integration train is ready, open a promotion PR from the integration
branch to the matching stable branch, for example `v0.11-dev` to `v0.11`.
Release candidates are cut directly from the active integration branch with a
version such as `0.11.1-rc.1`. The matching tag (`v0.11.1-rc.1`) publishes the
candidate without promoting unfinished train work to the stable branch.

When an integration train is ready for a final release, open a promotion PR
from the integration branch to the matching stable branch, for example
`v0.11-dev` to `v0.11`.

That promotion PR is the release-readiness gate and must pass required
cross-repo `E2E Tests`. The actual package release is cut from the stable branch
cross-repo `E2E Tests`. The final package release is cut from the stable branch
using a version tag after the stable branch is ready.

## `main`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "genvm-linter"
version = "0.10.0"
version = "0.11.1-rc.1"
description = "Fast validation and schema extraction for GenLayer intelligent contracts"
readme = "README.md"
license = "MIT"
Expand Down
122 changes: 122 additions & 0 deletions scripts/release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Validate linter release versions and select their owning branches."""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass

_VERSION_PATTERN = re.compile(
r"^v?(?P<major>0|[1-9][0-9]*)\."
r"(?P<minor>0|[1-9][0-9]*)\."
r"(?P<patch>0|[1-9][0-9]*)"
r"(?:(?:-?rc\.?(?P<rc>[1-9][0-9]*)))?$",
re.IGNORECASE,
)
_BRANCH_PATTERN = re.compile(
r"^v(?P<major>0|[1-9][0-9]*)\."
r"(?P<minor>0|[1-9][0-9]*)(?P<dev>-dev)?$"
)


@dataclass(frozen=True)
class ReleaseVersion:
major: int
minor: int
patch: int
rc: int | None = None

@property
def normalized(self) -> str:
suffix = "" if self.rc is None else f"-rc.{self.rc}"
return f"{self.major}.{self.minor}.{self.patch}{suffix}"

@property
def is_prerelease(self) -> bool:
return self.rc is not None

@property
def release_branch(self) -> str:
suffix = "-dev" if self.is_prerelease else ""
return f"v{self.major}.{self.minor}{suffix}"


def parse_release_version(value: str) -> ReleaseVersion:
match = _VERSION_PATTERN.fullmatch(value.strip())
if match is None:
raise ValueError(
f"{value!r} is not a supported release version; use X.Y.Z for a "
"final release or X.Y.Z-rc.N for a release candidate"
)
return ReleaseVersion(
major=int(match.group("major")),
minor=int(match.group("minor")),
patch=int(match.group("patch")),
rc=int(match.group("rc")) if match.group("rc") is not None else None,
)


def validate_branch_version(branch: str, value: str) -> ReleaseVersion:
branch_match = _BRANCH_PATTERN.fullmatch(branch)
if branch_match is None:
raise ValueError(f"{branch!r} is not a release branch; use vX.Y or vX.Y-dev")

version = parse_release_version(value)
branch_line = (int(branch_match.group("major")), int(branch_match.group("minor")))
if branch_line != (version.major, version.minor):
raise ValueError(
f"{version.normalized} belongs to v{version.major}.{version.minor}, not {branch}"
)

branch_is_dev = branch_match.group("dev") is not None
if branch_is_dev and not version.is_prerelease:
raise ValueError(
f"final release {version.normalized} must be cut from v{version.major}.{version.minor}"
)
if not branch_is_dev and version.is_prerelease:
raise ValueError(
f"release candidate {version.normalized} must be cut from "
f"v{version.major}.{version.minor}-dev"
)
return version


def _usage() -> str:
return (
"usage: release_version.py normalize <version> | branch <version> | "
"is-prerelease <version> | validate <branch> <version> | "
"verify-tag <tag> <package-version>"
)


def main(argv: list[str]) -> int:
try:
command = argv[1]
if command == "normalize" and len(argv) == 3:
print(parse_release_version(argv[2]).normalized)
elif command == "branch" and len(argv) == 3:
print(parse_release_version(argv[2]).release_branch)
elif command == "is-prerelease" and len(argv) == 3:
print("true" if parse_release_version(argv[2]).is_prerelease else "false")
elif command == "validate" and len(argv) == 4:
print(validate_branch_version(argv[2], argv[3]).normalized)
elif command == "verify-tag" and len(argv) == 4:
tag_version = parse_release_version(argv[2])
package_version = parse_release_version(argv[3])
if tag_version != package_version:
raise ValueError(
f"tag {tag_version.normalized} does not match package "
f"version {package_version.normalized}"
)
print(tag_version.normalized)
else:
raise ValueError(_usage())
except (IndexError, ValueError) as exc:
print(exc, file=sys.stderr)
return 2
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
62 changes: 62 additions & 0 deletions tests/test_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import importlib.util
import sys
from pathlib import Path

import pytest

MODULE_PATH = Path(__file__).parents[1] / "scripts" / "release_version.py"
SPEC = importlib.util.spec_from_file_location("release_version", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
release_version = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = release_version
SPEC.loader.exec_module(release_version)


@pytest.mark.parametrize(
("raw", "normalized", "branch", "is_prerelease"),
[
("0.11.1", "0.11.1", "v0.11", False),
("v0.11.1-rc.1", "0.11.1-rc.1", "v0.11-dev", True),
("0.11.1rc2", "0.11.1-rc.2", "v0.11-dev", True),
],
)
def test_release_version_normalizes_pep440_rc_spellings(
raw, normalized, branch, is_prerelease
):
version = release_version.parse_release_version(raw)

assert version.normalized == normalized
assert version.release_branch == branch
assert version.is_prerelease is is_prerelease


@pytest.mark.parametrize(
("branch", "version", "message"),
[
("main", "0.11.1", "not a release branch"),
("v0.10-dev", "0.11.1-rc.1", "belongs to v0.11"),
("v0.11", "0.11.1-rc.1", "must be cut from v0.11-dev"),
("v0.11-dev", "0.11.1", "must be cut from v0.11"),
],
)
def test_release_version_rejects_wrong_release_route(branch, version, message):
with pytest.raises(ValueError, match=message):
release_version.validate_branch_version(branch, version)


def test_release_version_accepts_rc_only_on_owning_dev_line():
version = release_version.validate_branch_version("v0.11-dev", "0.11.1rc1")

assert version.normalized == "0.11.1-rc.1"


@pytest.mark.parametrize("version", ["0.11.1-alpha.1", "0.11.1-rc.0", "00.11.1"])
def test_release_version_rejects_non_rc_or_noncanonical_versions(version):
with pytest.raises(ValueError, match="not a supported release version"):
release_version.parse_release_version(version)


def test_release_tag_and_package_version_compare_after_normalization():
assert release_version.main(
["release_version.py", "verify-tag", "v0.11.1-rc.1", "0.11.1rc1"]
) == 0
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading