diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index d89b967..69ccdf1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,11 +1,11 @@ --- name: release -description: Cut a release of genlayer-testing-suite. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.29.x" or "ship a new version". +description: Cut a release or release candidate of genlayer-testing-suite. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. --- # Release skill — genlayer-testing-suite -This repo follows a branch-per-major release model. There is no auto-bump on push. A release happens when a human (or you on their behalf) runs `scripts/release.sh` on the target stable branch. +This repo follows a branch-per-release-line model. There is no auto-bump on push. A final release is cut from its stable branch; an RC is cut from the matching `*-dev` integration branch. ## When to use this skill @@ -18,12 +18,13 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## What this repo's release model expects -- Branches are named after the major they ship: `v0.29` (current stable). When `v0.19` opens, the previous `v0.29` stays read-only for back-ports. +- Branches are named after the release line they ship: `v0.29` (stable) and `v0.30-dev` (integration). When `v0.30` becomes stable, the previous `v0.29` stays available for back-ports. - Tags live within those branches: `v0.29.1`, `v0.29.2`, ... - **Semver-zero rule**: this package is still on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` IS a major bump. `scripts/release.sh` refuses both `minor` and `major` keywords without `--allow-major` while we're on 0.x. -- A major (= minor on 0.x) bump means cutting a new branch (`v0.19`) — not tagging on top of the current one. -- `CHANGELOG.md` is updated in the release commit (python-semantic-release with explicit version). -- `publish.yml` fires on the tag push and does the PyPI publish + GitHub Release. +- A major (= minor on 0.x) bump means cutting a new branch (`v0.30`) — not tagging on top of the current one. +- `CHANGELOG.md` is updated in the release commit by python-semantic-release; an explicit requested version must match the version computed from release history and conventional commits. +- Final tags are cut from `vX.Y`; RC tags such as `v0.30.0-rc.1` are cut from `vX.Y-dev`. +- `publish.yml` verifies that the tag is the current owning branch head, publishes to PyPI, and marks RC GitHub Releases as prereleases. ## Steps @@ -31,13 +32,15 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re - Which version? If unspecified, ask whether it's patch or explicit. - If they say "minor" or "major" while we're on 0.x, surface that this means cutting a new branch — confirm before proceeding. -2. **Switch to the target branch + sync.** +2. **Switch to the owning branch + sync.** ```bash git checkout v0.29 git pull --ff-only origin v0.29 ``` If the working tree isn't clean, stop and surface what's there. + For `v0.30.0-rc.1`, use `v0.30-dev` instead. The script rejects final versions on a dev branch and prereleases on a stable branch. + 3. **Verify the head is shippable.** - Latest CI green: ```bash @@ -50,9 +53,10 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re 4. **Run the release script.** ```bash - scripts/release.sh # or patch + scripts/release.sh # final on vX.Y + scripts/release.sh --allow-major # first RC of a new 0.x line ``` - It bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. + First run the same command with `--dry-run`; it exercises all read-only preflight and version-policy checks. The real command bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): X.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. 5. **Watch the publish workflow.** ```bash @@ -69,7 +73,8 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## Things to refuse - **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new branch. -- **Releasing from `main`** — `main` is retired. +- **Releasing from `main`** — it is only a static/default alias, not a release branch. +- **A final tag from `*-dev`, or an RC tag from the stable branch** — the tag must belong to the exact owning branch. - **Hand-editing `pyproject.toml` to bump the version** — the script keeps pyproject, the CHANGELOG entry, the commit message, and the tag in lockstep. - **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag locally and on origin, re-run the script). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3724609..222e68e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,17 +1,19 @@ name: Publish Package to PyPI # Tag-driven publish. The release is cut by a human (or Claude via the -# release skill) running scripts/release.sh on the target stable branch +# release skill) running scripts/release.sh on the owning version branch # — that script bumps pyproject.toml (and glsim/__init__.py), updates # CHANGELOG.md, commits, tags vX.Y.Z, and pushes both the branch commit # and the tag. This workflow fires on the tag push, runs tests, # sanity-checks the tag matches pyproject.toml, builds, and publishes # to PyPI. It never bumps or tags by itself. on: - workflow_dispatch: push: tags: - - "v*" + - "v*.*.*" + +permissions: + contents: write jobs: run-tests: @@ -34,16 +36,22 @@ jobs: - name: Install Python run: uv python install 3.12 - - name: Verify tag matches pyproject.toml version + - name: Verify tag, package version, and owning branch run: | TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag ($TAG_VERSION) and pyproject.toml ($PKG_VERSION) disagree — refusing to publish." >&2 - echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 + NORMALIZED_VERSION="$(python scripts/release_version.py verify-tag "$TAG_VERSION" "$PKG_VERSION")" + EXPECTED_BRANCH="$(python scripts/release_version.py branch "$TAG_VERSION")" + git fetch --no-tags origin \ + "refs/heads/$EXPECTED_BRANCH:refs/remotes/origin/$EXPECTED_BRANCH" + TAG_COMMIT="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" + BRANCH_HEAD="$(git rev-parse "origin/$EXPECTED_BRANCH")" + if [ "$TAG_COMMIT" != "$BRANCH_HEAD" ]; then + echo "Tag $GITHUB_REF_NAME points to $TAG_COMMIT, but $EXPECTED_BRANCH is at $BRANCH_HEAD." >&2 + echo "Re-cut the release from the current owning branch head via scripts/release.sh." >&2 exit 1 fi - echo "Tag $GITHUB_REF_NAME matches pyproject.toml $PKG_VERSION." + echo "Tag $GITHUB_REF_NAME matches package $NORMALIZED_VERSION and $EXPECTED_BRANCH@$BRANCH_HEAD." - name: Clean previous builds run: rm -rf -- dist build *.egg-info @@ -64,6 +72,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + RELEASE_FLAGS=() + if [ "$(python scripts/release_version.py is-prerelease "$GITHUB_REF_NAME")" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi NOTES="$(awk -v ver="$GITHUB_REF_NAME" ' $0 ~ "^## \\[?" substr(ver, 2) {capture=1; next} capture && /^## / {exit} @@ -74,4 +86,5 @@ jobs: fi gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9af11b..ee6a644 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,3 +40,6 @@ jobs: # Unit tests only — integration tests here download the GenVM SDK. - name: Action | Run direct runner unit tests run: uv run gltest tests/gltest_direct/test_sdk_loader.py + + - name: Action | Validate release version policy + run: uv run pytest tests/test_release_version.py diff --git a/CLAUDE.md b/CLAUDE.md index f13d265..acb791f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,4 +168,4 @@ Multi-file contracts use a `runner.json` manifest: 2. Contracts are discovered automatically from the contracts directory 3. Transaction receipts include consensus information and triggered transactions 4. LLM-based contract methods use `gl.eq_principle_prompt_non_comparative()` -5. Version is managed automatically via semantic release (no manual updates) +5. Versions are updated only by `scripts/release.sh` via semantic release; do not edit them manually diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d81e6cc..755d454 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ the default/static GitHub branch. ## Releases -Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. See `.claude/skills/release/SKILL.md` for the full flow. +Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. Release candidates are cut from the active `*-dev` branch (for example, `v0.30.0-rc.1` from `v0.30-dev`), while final versions are cut from the matching stable branch. See `.claude/skills/release/SKILL.md` for the full flow. **Semver-zero rule**: this package is on 0.x, so minor IS the breaking-change boundary. `0.29 → 0.30` is a major bump and needs a new branch — the script refuses `minor`/`major` keywords without `--allow-major`. @@ -153,7 +153,7 @@ The project uses automated semantic versioning based on commit messages: | `feat!:`, `fix!:`, or `BREAKING CHANGE:` | **Major** version bump | 1.0.0 → 2.0.0 | | `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `build:`, `ci:` | **No** version bump | Version stays the same | -**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Releases are cut from the stable branch using the release automation described above. +**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Final releases are cut from the stable branch and release candidates from its matching `*-dev` branch using the release automation described above. ### Improving Documentation diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 3d5ba44..d56d15a 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -41,8 +41,11 @@ When an integration train is ready, open a promotion PR from the integration branch to the matching stable branch, for example `v0.30-dev` to `v0.30`. 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 -using a version tag after the stable branch is ready. +cross-repo `E2E Tests`. The final package release is cut from the stable branch +using a version tag after the stable branch is ready. A release candidate may +be cut earlier from the matching integration branch; RC tags use +`vX.Y.Z-rc.N`, publish as PyPI prereleases, and never substitute for the +promotion PR's final release gate. ## `main` diff --git a/pyproject.toml b/pyproject.toml index bea205d..1b58363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "pytest", - "genlayer-py>=0.18.0,<0.20.0", + "genlayer-py>=0.19.0rc1,<0.20.0", "colorama>=0.4.6", "pyyaml", "python-dotenv" diff --git a/releaserc.toml b/releaserc.toml index ac4b94a..e0a3e19 100644 --- a/releaserc.toml +++ b/releaserc.toml @@ -11,11 +11,16 @@ allow_zero_version = true no_git_verify = false tag_format = "v{version}" -[semantic_release.branches.main] -match = "main" +[semantic_release.branches.stable] +match = "^v[0-9]+\\.[0-9]+$" prerelease_token = "rc" prerelease = false +[semantic_release.branches.dev] +match = "^v[0-9]+\\.[0-9]+-dev$" +prerelease_token = "rc" +prerelease = true + [semantic_release.changelog] exclude_commit_patterns = [] mode = "update" @@ -62,4 +67,4 @@ insecure = false [semantic_release.publish] dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = true \ No newline at end of file +upload_to_vcs_release = true diff --git a/scripts/release.sh b/scripts/release.sh index 8abd0a2..04d87a7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Cut a release on the current stable branch. +# Cut a release on the current owning version branch. # # Bumps pyproject.toml, updates CHANGELOG.md via python-semantic-release, # commits, tags vX.Y.Z, and pushes both the branch commit and the tag. @@ -7,38 +7,44 @@ # GitHub Release). # # Releases are deliberate. There is no auto-bump on push; only this -# script is supposed to create release tags. Run from the major branch -# you want to ship a release on (e.g. v0.29 for v0.29.x). +# script is supposed to create release tags. Run from the release line +# that owns the version: vX.Y for finals and vX.Y-dev for RCs. # # Usage: -# scripts/release.sh # explicit semver — recommended -# scripts/release.sh patch # 0.18.0 → 0.18.1 -# scripts/release.sh minor # 0.18.0 → 0.19.0 — refused unless --allow-major (see below) -# scripts/release.sh major # 0.18.0 → 1.0.0 — refused unless --allow-major +# scripts/release.sh # final release from vX.Y +# scripts/release.sh # release candidate from vX.Y-dev +# scripts/release.sh patch # 0.29.0 → 0.29.1 +# scripts/release.sh minor # 0.29.0 → 0.30.0 — needs --allow-major +# scripts/release.sh major # 0.29.0 → 1.0.0 — needs --allow-major # scripts/release.sh --allow-major +# scripts/release.sh --dry-run [--allow-major] # # Semver-zero rule: while the major is 0, the MINOR is the breaking- -# change boundary (per semver). 0.18 → 0.19 IS a major bump. The script -# refuses both `minor` and `major` keywords without --allow-major while -# the current major is 0. Patches stay automatic-friendly. +# change boundary. The script refuses minor/major changes without +# --allow-major. # # Pre-flight (each check refuses to proceed on failure): -# - On a v[.] branch (refuses on main / feature branches) +# - On vX.Y for a final or vX.Y-dev for a release candidate # - Working tree clean # - Local HEAD matches origin/ -# - Latest CI run on HEAD is green +# - Tests workflow on that exact HEAD is green set -euo pipefail ALLOW_MAJOR=0 -if [ "${1:-}" = "--allow-major" ]; then - ALLOW_MAJOR=1 +DRY_RUN=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --allow-major) ALLOW_MAJOR=1 ;; + --dry-run) DRY_RUN=1 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac shift -fi +done VERSION_ARG="${1:-}" if [ -z "$VERSION_ARG" ]; then - echo "Usage: $0 [--allow-major] |patch|minor|major" >&2 + echo "Usage: $0 [--dry-run] [--allow-major] ||patch|minor|major" >&2 exit 2 fi @@ -46,16 +52,13 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" branch="$(git rev-parse --abbrev-ref HEAD)" -if ! [[ "$branch" =~ ^v[0-9]+(\.[0-9]+)?(-dev)?$ ]]; then +if ! [[ "$branch" =~ ^v[0-9]+\.[0-9]+(-dev)?$ ]]; then cat >&2 </dev/null 2>&1; then - status="$(gh run list --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" - case "$status" in - success) ;; - "" ) - echo "Warning: no CI run found for $local_sha on $branch. Continuing anyway." >&2 - ;; - *) - echo "Latest CI on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 - exit 1 - ;; - esac +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required to verify the release head's native tests." >&2 + exit 1 fi +status="$(gh run list --workflow tests.yml --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" +case "$status" in + success) ;; + "" ) + echo "No Tests workflow run found for $local_sha on $branch. Refusing to release an unverified head." >&2 + exit 1 + ;; + *) + echo "Latest Tests workflow on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 + exit 1 + ;; +esac -current_version="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - -# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +release_flags=() case "$VERSION_ARG" in major|minor|patch) - next_version="$(python3 - "$current_version" "$VERSION_ARG" <<'PY' -import sys -cur = sys.argv[1].split(".") -kind = sys.argv[2] -major, minor, patch = int(cur[0]), int(cur[1]), int(cur[2]) -if kind == "major": - print(f"{major+1}.0.0") -elif kind == "minor": - print(f"{major}.{minor+1}.0") -elif kind == "patch": - print(f"{major}.{minor}.{patch+1}") -PY -)" + release_flags+=("--$VERSION_ARG") ;; *) - next_version="$VERSION_ARG" + requested_version="$(python3 scripts/release_version.py normalize "$VERSION_ARG")" || exit 2 ;; esac -if ! [[ "$next_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Not a valid semver: $next_version" >&2 - exit 2 +if [[ "$branch" == *-dev ]]; then + release_flags+=(--as-prerelease --prerelease-token rc) +fi + +computed_raw="$( + uvx --from 'python-semantic-release==10.0.2' \ + semantic-release -c releaserc.toml version --print "${release_flags[@]}" +)" +next_version="$(python3 scripts/release_version.py normalize "$computed_raw")" || exit 2 + +if [[ -n "${requested_version:-}" && "$requested_version" != "$next_version" ]]; then + cat >&2 </dev/null + +last_tag="$(git describe --tags --abbrev=0 --match 'v*.*.*' 2>/dev/null || true)" +if [[ -z "$last_tag" ]]; then + echo "No previous release tag is reachable from $branch; refusing to infer release boundaries." >&2 + exit 1 +fi +last_version="$(python3 scripts/release_version.py normalize "$last_tag")" || exit 2 +cur_major="${last_version%%.*}" next_major="${next_version%%.*}" -cur_minor="$(echo "$current_version" | cut -d. -f2)" +cur_minor="$(echo "$last_version" | cut -d. -f2)" next_minor="$(echo "$next_version" | cut -d. -f2)" # Semver-zero: while major == 0, MINOR bumps are major bumps. @@ -130,12 +143,11 @@ if [ "$cur_major" = "0" ]; then if [ "$next_major" != "0" ] || [ "$next_minor" != "$cur_minor" ]; then if [ "$ALLOW_MAJOR" -ne 1 ]; then cat >&2 <&2 </dev/null +if ! git rev-parse --verify --quiet "refs/tags/v$next_version" >/dev/null; then + echo "Release tool did not create the expected tag v$next_version; refusing to push." >&2 + exit 1 +fi + git push origin "$branch" git push origin "v$next_version" diff --git a/scripts/release_version.py b/scripts/release_version.py new file mode 100755 index 0000000..3deaaf5 --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Validate GenLayer Testing Suite release versions and owning branches.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import sys + + +_VERSION_PATTERN = re.compile( + r"^v?(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:(?:-?rc\.?(?P[1-9][0-9]*)))?$", + re.IGNORECASE, +) +_BRANCH_PATTERN = re.compile( + r"^v(?P0|[1-9][0-9]*)\." r"(?P0|[1-9][0-9]*)(?P-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}, " + f"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 " + f"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 | branch | " + "is-prerelease | validate | " + "verify-tag " + ) + + +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)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py new file mode 100644 index 0000000..4a555a3 --- /dev/null +++ b/tests/test_release_version.py @@ -0,0 +1,112 @@ +import importlib.util +from pathlib import Path +import sys +import tomllib + +import pytest +import yaml + + +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.30.0", "0.30.0", "v0.30", False), + ("v0.30.0-rc.1", "0.30.0-rc.1", "v0.30-dev", True), + ("0.30.0rc2", "0.30.0-rc.2", "v0.30-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.30.0", "not a release branch"), + ("v0.29-dev", "0.30.0-rc.1", "belongs to v0.30"), + ("v0.30", "0.30.0-rc.1", "must be cut from v0.30-dev"), + ("v0.30-dev", "0.30.0", "must be cut from v0.30"), + ], +) +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.30-dev", "0.30.0rc1") + + assert version.normalized == "0.30.0-rc.1" + + +@pytest.mark.parametrize("version", ["0.30.0-alpha.1", "0.30.0-rc.0", "00.30.0"]) +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.30.0-rc.1", "0.30.0rc1"] + ) + == 0 + ) + + +def test_semantic_release_routes_stable_and_rc_branches(): + config_path = Path(__file__).parents[1] / "releaserc.toml" + branch_config = tomllib.loads(config_path.read_text())["semantic_release"][ + "branches" + ] + + assert branch_config == { + "stable": { + "match": r"^v[0-9]+\.[0-9]+$", + "prerelease_token": "rc", + "prerelease": False, + }, + "dev": { + "match": r"^v[0-9]+\.[0-9]+-dev$", + "prerelease_token": "rc", + "prerelease": True, + }, + } + + +def test_publish_workflow_is_tag_only_and_enforces_release_provenance(): + workflow_path = Path(__file__).parents[1] / ".github" / "workflows" / "publish.yml" + workflow = yaml.load(workflow_path.read_text(), Loader=yaml.BaseLoader) + + assert workflow["on"] == {"push": {"tags": ["v*.*.*"]}} + assert workflow["permissions"] == {"contents": "write"} + + publish_steps = workflow["jobs"]["publish-to-pypi"]["steps"] + verification = next( + step["run"] + for step in publish_steps + if step["name"] == "Verify tag, package version, and owning branch" + ) + assert "release_version.py verify-tag" in verification + assert "release_version.py branch" in verification + assert 'TAG_COMMIT" != "$BRANCH_HEAD' in verification + + github_release = next( + step["run"] for step in publish_steps if step["name"] == "Create GitHub Release" + ) + assert "release_version.py is-prerelease" in github_release + assert "--prerelease" in github_release diff --git a/uv.lock b/uv.lock index f85bc87..2afb723 100644 --- a/uv.lock +++ b/uv.lock @@ -688,14 +688,14 @@ wheels = [ [[package]] name = "genlayer-py" -version = "0.18.0" +version = "0.19.0rc1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "web3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/99/8f2d98de18bb1c6533a7b04487d6333564a94fb0b85e972042292cceafda/genlayer_py-0.18.0.tar.gz", hash = "sha256:184954732e16b61f1a8465ff8a8ab8bdcd99952b5285271ad47d523f68136940", size = 45155, upload-time = "2026-04-22T14:27:11.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9c/5b5938ac9065e881be2746a6dc1aefb6a858c4109397ab08de33ddb4d050/genlayer_py-0.19.0rc1.tar.gz", hash = "sha256:7c295bbccf48afc14c84e25f874b94b7c2d1ea9df438f2efa16d1a49dd99f952", size = 90613, upload-time = "2026-09-03T00:05:08.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f8/a1fdd3e7c5a7c79fce774aacd535fec2bddfc742d0feabbb8770118d8b67/genlayer_py-0.18.0-py3-none-any.whl", hash = "sha256:d18cb57a6ee2c09d8db32659b9f152d4cef50d3c2308da2975bab7ae41b2e5a0", size = 58336, upload-time = "2026-04-22T14:27:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/65/f3/67eecbb71cf373f7128e822ece2d0de3ea4c976d31533163f18ab139dc34/genlayer_py-0.19.0rc1-py3-none-any.whl", hash = "sha256:3707c6741c98b71b7cec48f6133fa7264e373ea9a584bc2d69ad63a11f026efc", size = 106598, upload-time = "2026-09-03T00:05:06.849Z" }, ] [[package]] @@ -724,7 +724,7 @@ requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, { name = "eth-account", marker = "extra == 'sim'", specifier = ">=0.10" }, { name = "fastapi", marker = "extra == 'sim'", specifier = ">=0.100" }, - { name = "genlayer-py", specifier = ">=0.18.0,<0.20.0" }, + { name = "genlayer-py", specifier = ">=0.19.0rc1,<0.20.0" }, { name = "httpx", marker = "extra == 'sim'", specifier = ">=0.24" }, { name = "numpy", marker = "extra == 'sim'", specifier = ">=1.26" }, { name = "pytest" },