From 03f7db12a428d30ce21f92acda04065388c60d51 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 15 Sep 2026 19:57:36 -0400 Subject: [PATCH 1/5] Fix release version drift and prepare 3.0.1 --- .github/workflows/build_wheels.yml | 31 ++++- ALPS_VERSION.txt | 2 +- CONTRIBUTING.md | 28 ++++ pyproject.toml | 2 +- script/check_release_version.py | 110 ++++++++++++++++ test/packaging/test_release_version.py | 172 +++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 script/check_release_version.py create mode 100644 test/packaging/test_release_version.py diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 94900c764..5b5d18606 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -11,8 +11,23 @@ on: - master jobs: + check_version: + name: Check release version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - run: python -m pip install packaging + - name: Test release validation + run: python -m unittest discover -s test/packaging -v + - name: Check package, SDK, and tag versions + run: python script/check_release_version.py + build_wheels: name: Build wheels on ${{ matrix.plat.os }} + needs: [check_version] runs-on: ${{ matrix.plat.os }} strategy: matrix: @@ -73,6 +88,7 @@ jobs: build_sdist: name: Build source distribution + needs: [check_version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -87,15 +103,19 @@ jobs: upload_pypi: - needs: [build_wheels, build_sdist] + needs: [check_version, build_wheels, build_sdist] runs-on: ubuntu-latest environment: pypi permissions: + contents: read id-token: write - #if: github.event_name == 'release' && github.event.action == 'published' - # or, alternatively, upload to PyPI on every tag starting with 'v' (remove on: release above to use this) - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release' + if: startsWith(github.ref, 'refs/tags/v') steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - run: python -m pip install packaging - uses: actions/download-artifact@v8 with: # unpacks all CIBW artifacts into dist/ @@ -103,6 +123,9 @@ jobs: path: dist merge-multiple: true + - name: Check every distribution before publishing + run: python script/check_release_version.py --dist dist + - uses: pypa/gh-action-pypi-publish@release/v1 #with: # To test: diff --git a/ALPS_VERSION.txt b/ALPS_VERSION.txt index 3f684d2d9..cb2b00e4f 100644 --- a/ALPS_VERSION.txt +++ b/ALPS_VERSION.txt @@ -1 +1 @@ -2.3.4 +3.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7f1a1636..e25b4fc5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Contributions at every level — from a one-line bug report to a new simulation - [Getting started with the code](#getting-started-with-the-code) - [Making a change](#making-a-change) - [Submitting a pull request](#submitting-a-pull-request) +- [Preparing a release](#preparing-a-release) - [Review process](#review-process) - [Code style](#code-style) - [Recognition](#recognition) @@ -147,6 +148,33 @@ For substantial changes — new simulation applications, new libraries, signific --- +## Preparing a release + +Update both `ALPS_VERSION.txt` (the C++ SDK version) and `[project].version` +in `pyproject.toml` before creating a release tag. For a final release, both +must be `X.Y.Z` and the tag must be `vX.Y.Z`. For a prerelease such as +`vX.Y.Z-beta.1`, keep the SDK core at `X.Y.Z` and use the Python version +`X.Y.Zb1`. The other supported tag suffixes are `alpha.N`, `rc.N`, and `dev.N`. + +Validate the intended tag locally using Python 3.11 or newer: + +```bash +python -m pip install packaging +python script/check_release_version.py --ref refs/tags/vX.Y.Z +``` + +The packaging workflow checks these versions before building and checks every +wheel and source distribution, including its embedded metadata, before upload. +Only a tag push publishes to PyPI. Merge and validate the release commit before +tagging it. + +If a published tag contains the wrong version, rerunning its workflow will +rebuild the same incorrect artifacts. Prepare a new patch release with both +version files updated and a new tag; do not move an existing published tag or +use `skip-existing` to hide a version mismatch. + +--- + ## Review process ALPS uses a consensus-based review model: diff --git a/pyproject.toml b/pyproject.toml index 17faed7e6..d9284be88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ cmake.define.ALPS_PYTHON_WHEEL = "ON" [project] name = "pyalps" -version = "2.3.4b1" +version = "3.0.1" authors = [ { name="Sergei Iskakov", email="siskakov@umich.edu" }, { name="Fei Lin", email="feilin.physics@gmail.com" } diff --git a/script/check_release_version.py b/script/check_release_version.py new file mode 100644 index 000000000..7400d5efa --- /dev/null +++ b/script/check_release_version.py @@ -0,0 +1,110 @@ +"""Check release versions before building or publishing (Python >= 3.11).""" + +import argparse +from email.parser import BytesParser +import os +from pathlib import Path +import re +import tarfile +import tomllib +import zipfile + +from packaging.utils import ( + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) +from packaging.version import Version + + +CORE_PATTERN = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" +TAG_PATTERN = rf"v{CORE_PATTERN}(?:-(?:alpha|beta|rc|dev)\.[0-9]+)?" + + +def check_version(root: Path, ref: str) -> Version: + core = (root / "ALPS_VERSION.txt").read_text().strip() + if not re.fullmatch(CORE_PATTERN, core): + raise ValueError("ALPS_VERSION.txt must contain MAJOR.MINOR.PATCH") + + with (root / "pyproject.toml").open("rb") as stream: + project = tomllib.load(stream)["project"] + version = Version(project["version"]) + if version.release != Version(core).release: + raise ValueError( + f"pyproject.toml version {version} disagrees with ALPS_VERSION.txt ({core})" + ) + + if ref.startswith("refs/tags/"): + tag = ref.removeprefix("refs/tags/") + if not re.fullmatch(TAG_PATTERN, tag): + raise ValueError( + f"Invalid release tag {tag!r}; expected vMAJOR.MINOR.PATCH" + "[-{alpha,beta,rc,dev}.N]" + ) + if Version(tag) != version: + raise ValueError( + f"Release tag {tag} disagrees with pyproject.toml ({version})" + ) + + return version + + +def check_distributions(directory: Path, expected: Version) -> None: + artifacts = sorted(directory.iterdir()) + if not artifacts: + raise ValueError(f"No distributions found in {directory}") + + for artifact in artifacts: + if artifact.suffix == ".whl": + name, version, _, _ = parse_wheel_filename(artifact.name) + with zipfile.ZipFile(artifact) as archive: + members = [ + member for member in archive.namelist() + if member.count("/") == 1 and member.endswith(".dist-info/METADATA") + ] + if len(members) != 1: + raise ValueError(f"{artifact.name}: expected one wheel METADATA file") + metadata = archive.read(members[0]) + elif artifact.name.endswith(".tar.gz"): + name, version = parse_sdist_filename(artifact.name) + with tarfile.open(artifact) as archive: + members = [ + member for member in archive.getmembers() + if member.isfile() and member.name.count("/") == 1 + and member.name.endswith("/PKG-INFO") + ] + if len(members) != 1: + raise ValueError(f"{artifact.name}: expected one sdist PKG-INFO file") + with archive.extractfile(members[0]) as stream: + metadata = stream.read() + else: + raise ValueError(f"Unexpected distribution: {artifact.name}") + + if name != "pyalps" or version != expected: + raise ValueError(f"{artifact.name}: expected a pyalps {expected} distribution") + headers = BytesParser().parsebytes(metadata) + if ( + canonicalize_name(headers.get("Name", "")) != "pyalps" + or Version(headers.get("Version", "")) != expected + ): + raise ValueError(f"{artifact.name}: metadata does not describe pyalps {expected}") + print(f"Verified {artifact.name}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--ref", default=os.environ.get("GITHUB_REF", "")) + parser.add_argument("--dist", type=Path) + args = parser.parse_args() + try: + version = check_version(args.root, args.ref) + print(f"Release version: {version}") + if args.dist is not None: + check_distributions(args.dist, version) + except (OSError, ValueError, KeyError, tarfile.TarError, zipfile.BadZipFile) as error: + parser.exit(1, f"Release version check failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/test/packaging/test_release_version.py b/test/packaging/test_release_version.py new file mode 100644 index 000000000..46b792a33 --- /dev/null +++ b/test/packaging/test_release_version.py @@ -0,0 +1,172 @@ +"""Regression coverage for release version drift and stale build artifacts.""" + +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest +import zipfile + +from packaging.version import Version + + +SCRIPT = Path(__file__).resolve().parents[2] / "script" / "check_release_version.py" +SPEC = importlib.util.spec_from_file_location("check_release_version", SCRIPT) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +class ReleaseVersionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def write_versions(self, core="3.0.0", python="3.0.0"): + (self.root / "ALPS_VERSION.txt").write_text(core + "\n") + (self.root / "pyproject.toml").write_text( + f'[project]\nname = "pyalps"\nversion = "{python}"\n' + ) + + def test_final_release(self): + self.write_versions() + self.assertEqual( + release.check_version(self.root, "refs/tags/v3.0.0"), Version("3.0.0") + ) + + def test_v3_release_regression(self): + self.write_versions("2.3.4", "2.3.4b1") + with self.assertRaisesRegex(ValueError, "Release tag v3.0.0 disagrees"): + release.check_version(self.root, "refs/tags/v3.0.0") + + def test_ci_rejects_original_release_using_github_ref(self): + self.write_versions("2.3.4", "2.3.4b1") + result = subprocess.run( + [sys.executable, str(SCRIPT), "--root", str(self.root)], + env={**os.environ, "GITHUB_REF": "refs/tags/v3.0.0"}, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("Release tag v3.0.0 disagrees", result.stderr) + + def test_sdk_and_python_must_agree_even_on_branches(self): + for ref in ("", "refs/heads/master", "refs/pull/142/merge", "refs/tags/v3.0.0"): + with self.subTest(ref=ref): + self.write_versions("2.3.4", "3.0.0") + with self.assertRaisesRegex(ValueError, "disagrees with ALPS_VERSION.txt"): + release.check_version(self.root, ref) + + def test_non_tag_refs_are_not_release_tags(self): + self.write_versions() + for ref in ("", "refs/heads/master", "refs/heads/v4.0.0", "refs/pull/142/merge"): + with self.subTest(ref=ref): + self.assertEqual(release.check_version(self.root, ref), Version("3.0.0")) + + def test_prerelease_tags_match_pep440_versions(self): + for label, suffix in ( + ("alpha.1", "a1"), ("beta.2", "b2"), ("rc.3", "rc3"), ("dev.4", ".dev4") + ): + with self.subTest(label=label): + self.write_versions(python="3.0.0" + suffix) + self.assertEqual( + release.check_version(self.root, "refs/tags/v3.0.0-" + label), + Version("3.0.0" + suffix), + ) + + def test_prerelease_cannot_be_published_as_final_or_different_prerelease(self): + for python, tag in ( + ("3.0.0b1", "v3.0.0"), + ("3.0.0", "v3.0.0-beta.1"), + ("3.0.0b1", "v3.0.0-beta.2"), + ): + with self.subTest(python=python, tag=tag): + self.write_versions(python=python) + with self.assertRaisesRegex(ValueError, "disagrees with pyproject.toml"): + release.check_version(self.root, "refs/tags/" + tag) + + def test_malformed_tags_fail_closed(self): + self.write_versions() + for tag in ( + "v3.0", "v3.0.0-beta", "v3.0.0-final", "v3.0.0_1", "v03.0.0", "v3.0.0+local" + ): + with self.subTest(tag=tag): + with self.assertRaisesRegex(ValueError, "Invalid release tag"): + release.check_version(self.root, "refs/tags/" + tag) + + def test_invalid_numeric_core(self): + for core in ("3.0", "v3.0.0", "3.0.0-beta.1", "3.0.0\n2.3.4", "03.0.0"): + with self.subTest(core=core): + self.write_versions(core=core) + with self.assertRaisesRegex(ValueError, "must contain MAJOR.MINOR.PATCH"): + release.check_version(self.root, "") + + +class DistributionVersionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.dist = Path(self.temp.name) + + def artifact(self, kind, version="3.0.0", metadata_version=None, name="pyalps"): + metadata = ( + f"Metadata-Version: 2.1\nName: {name}\n" + f"Version: {metadata_version or version}\n" + ).encode() + if kind == "wheel": + path = self.dist / f"{name}-{version}-cp313-cp313-manylinux_2_28_x86_64.whl" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"{name}-{version}.dist-info/METADATA", metadata) + else: + path = self.dist / f"{name}-{version}.tar.gz" + with tarfile.open(path, "w:gz") as archive: + member = tarfile.TarInfo(f"{name}-{version}/PKG-INFO") + member.size = len(metadata) + archive.addfile(member, io.BytesIO(metadata)) + return path + + def test_matching_wheel_and_sdist(self): + for version in ("3.0.0", "3.0.0b1"): + with self.subTest(version=version): + paths = [self.artifact(kind, version) for kind in ("wheel", "sdist")] + release.check_distributions(self.dist, Version(version)) + for path in paths: + path.unlink() + + def test_stale_artifact_rejects_the_entire_batch(self): + self.artifact("wheel") + self.artifact("sdist", "2.3.4b1") + with self.assertRaisesRegex(ValueError, "expected a pyalps 3.0.0"): + release.check_distributions(self.dist, Version("3.0.0")) + + def test_renaming_an_old_artifact_does_not_fix_its_version(self): + for kind in ("wheel", "sdist"): + with self.subTest(kind=kind): + path = self.artifact(kind, metadata_version="2.3.4b1") + with self.assertRaisesRegex( + ValueError, "metadata does not describe pyalps 3.0.0" + ): + release.check_distributions(self.dist, Version("3.0.0")) + path.unlink() + + def test_other_project_is_rejected(self): + self.artifact("wheel", name="other") + with self.assertRaisesRegex(ValueError, "expected a pyalps 3.0.0"): + release.check_distributions(self.dist, Version("3.0.0")) + + def test_empty_dist_is_rejected(self): + with self.assertRaisesRegex(ValueError, "No distributions"): + release.check_distributions(self.dist, Version("3.0.0")) + + def test_unexpected_file_is_rejected(self): + (self.dist / "README.txt").write_text("not a distribution") + with self.assertRaisesRegex(ValueError, "Unexpected distribution"): + release.check_distributions(self.dist, Version("3.0.0")) + + +if __name__ == "__main__": + unittest.main() From 1950cc6f682d7c4c1deae8b816f283857b1819d1 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 15 Sep 2026 20:05:34 -0400 Subject: [PATCH 2/5] Keep the corrected release at 3.0.0 --- ALPS_VERSION.txt | 2 +- CONTRIBUTING.md | 7 +++++-- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ALPS_VERSION.txt b/ALPS_VERSION.txt index cb2b00e4f..4a36342fc 100644 --- a/ALPS_VERSION.txt +++ b/ALPS_VERSION.txt @@ -1 +1 @@ -3.0.1 +3.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e25b4fc5a..6e75f3af7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,8 +169,11 @@ Only a tag push publishes to PyPI. Merge and validate the release commit before tagging it. If a published tag contains the wrong version, rerunning its workflow will -rebuild the same incorrect artifacts. Prepare a new patch release with both -version files updated and a new tag; do not move an existing published tag or +rebuild the same incorrect artifacts. Correct both version files first. If +the intended version has no distributions on PyPI, maintainers can approve +resetting the tag to the validated correction and publishing that version. +If the intended version already has distributions, prepare a new patch +release instead: PyPI does not allow replacing uploaded filenames. Do not use `skip-existing` to hide a version mismatch. --- diff --git a/pyproject.toml b/pyproject.toml index d9284be88..bfc1072e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ cmake.define.ALPS_PYTHON_WHEEL = "ON" [project] name = "pyalps" -version = "3.0.1" +version = "3.0.0" authors = [ { name="Sergei Iskakov", email="siskakov@umich.edu" }, { name="Fei Lin", email="feilin.physics@gmail.com" } From 9f5ca4423e54441086a702400afa0261989e815a Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 15 Sep 2026 22:26:24 -0400 Subject: [PATCH 3/5] Simplify release validation tests and workflow --- .github/workflows/build_wheels.yml | 22 +-- test/packaging/test_release_version.py | 249 +++++++++++-------------- 2 files changed, 114 insertions(+), 157 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 5b5d18606..850a0faf2 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -19,9 +19,9 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.13' - - run: python -m pip install packaging + - run: python -m pip install packaging pytest - name: Test release validation - run: python -m unittest discover -s test/packaging -v + run: python -m pytest test/packaging -q - name: Check package, SDK, and tag versions run: python script/check_release_version.py @@ -31,10 +31,8 @@ jobs: runs-on: ${{ matrix.plat.os }} strategy: matrix: - # macos-13 is an intel runner, macos-14 is apple silicon - plat: + plat: - { os: ubuntu-latest, target: "", arch: x86_64, homebrew: ''} - #- { os: macos-13, target: "13.0" , arch: x86_64, homebrew: '/usr/local'} #DEPRECATED. Too old. - { os: macos-15, target: "15.0" , arch: arm64, homebrew: '/opt/homebrew'} - { os: macos-26, target: "26.0" , arch: arm64, homebrew: '/opt/homebrew'} @@ -70,15 +68,6 @@ jobs: Boost_ROOT_DIR=/Users/runner/work/ALPS/ALPS/boost_1_87_0 MACOSX_DEPLOYMENT_TARGET=${{ matrix.plat.target }} CXXFLAGS="-stdlib=libc++" -# CIBW_ENVIRONMENT: > - - # env: - # CIBW_SOME_OPTION: value - # ... - # with: - # package-dir: . - # output-dir: wheelhouse - # config-file: "{package}/pyproject.toml" - uses: actions/upload-artifact@v7 with: @@ -103,7 +92,7 @@ jobs: upload_pypi: - needs: [check_version, build_wheels, build_sdist] + needs: [build_wheels, build_sdist] runs-on: ubuntu-latest environment: pypi permissions: @@ -127,6 +116,3 @@ jobs: run: python script/check_release_version.py --dist dist - uses: pypa/gh-action-pypi-publish@release/v1 - #with: - # To test: - # repository-url: https://test.pypi.org/legacy/ diff --git a/test/packaging/test_release_version.py b/test/packaging/test_release_version.py index 46b792a33..1425c74d7 100644 --- a/test/packaging/test_release_version.py +++ b/test/packaging/test_release_version.py @@ -7,12 +7,10 @@ import subprocess import sys import tarfile -import tempfile -import unittest import zipfile from packaging.version import Version - +import pytest SCRIPT = Path(__file__).resolve().parents[2] / "script" / "check_release_version.py" SPEC = importlib.util.spec_from_file_location("check_release_version", SCRIPT) @@ -20,153 +18,126 @@ SPEC.loader.exec_module(release) -class ReleaseVersionTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) - - def write_versions(self, core="3.0.0", python="3.0.0"): - (self.root / "ALPS_VERSION.txt").write_text(core + "\n") - (self.root / "pyproject.toml").write_text( +@pytest.fixture +def versions(tmp_path): + def write(core="3.0.0", python="3.0.0"): + (tmp_path / "ALPS_VERSION.txt").write_text(core + "\n") + (tmp_path / "pyproject.toml").write_text( f'[project]\nname = "pyalps"\nversion = "{python}"\n' ) + return tmp_path + return write - def test_final_release(self): - self.write_versions() - self.assertEqual( - release.check_version(self.root, "refs/tags/v3.0.0"), Version("3.0.0") - ) - def test_v3_release_regression(self): - self.write_versions("2.3.4", "2.3.4b1") - with self.assertRaisesRegex(ValueError, "Release tag v3.0.0 disagrees"): - release.check_version(self.root, "refs/tags/v3.0.0") - - def test_ci_rejects_original_release_using_github_ref(self): - self.write_versions("2.3.4", "2.3.4b1") - result = subprocess.run( - [sys.executable, str(SCRIPT), "--root", str(self.root)], - env={**os.environ, "GITHUB_REF": "refs/tags/v3.0.0"}, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 1) - self.assertIn("Release tag v3.0.0 disagrees", result.stderr) - - def test_sdk_and_python_must_agree_even_on_branches(self): - for ref in ("", "refs/heads/master", "refs/pull/142/merge", "refs/tags/v3.0.0"): - with self.subTest(ref=ref): - self.write_versions("2.3.4", "3.0.0") - with self.assertRaisesRegex(ValueError, "disagrees with ALPS_VERSION.txt"): - release.check_version(self.root, ref) - - def test_non_tag_refs_are_not_release_tags(self): - self.write_versions() - for ref in ("", "refs/heads/master", "refs/heads/v4.0.0", "refs/pull/142/merge"): - with self.subTest(ref=ref): - self.assertEqual(release.check_version(self.root, ref), Version("3.0.0")) - - def test_prerelease_tags_match_pep440_versions(self): - for label, suffix in ( - ("alpha.1", "a1"), ("beta.2", "b2"), ("rc.3", "rc3"), ("dev.4", ".dev4") - ): - with self.subTest(label=label): - self.write_versions(python="3.0.0" + suffix) - self.assertEqual( - release.check_version(self.root, "refs/tags/v3.0.0-" + label), - Version("3.0.0" + suffix), - ) - - def test_prerelease_cannot_be_published_as_final_or_different_prerelease(self): - for python, tag in ( - ("3.0.0b1", "v3.0.0"), - ("3.0.0", "v3.0.0-beta.1"), - ("3.0.0b1", "v3.0.0-beta.2"), - ): - with self.subTest(python=python, tag=tag): - self.write_versions(python=python) - with self.assertRaisesRegex(ValueError, "disagrees with pyproject.toml"): - release.check_version(self.root, "refs/tags/" + tag) - - def test_malformed_tags_fail_closed(self): - self.write_versions() - for tag in ( - "v3.0", "v3.0.0-beta", "v3.0.0-final", "v3.0.0_1", "v03.0.0", "v3.0.0+local" - ): - with self.subTest(tag=tag): - with self.assertRaisesRegex(ValueError, "Invalid release tag"): - release.check_version(self.root, "refs/tags/" + tag) - - def test_invalid_numeric_core(self): - for core in ("3.0", "v3.0.0", "3.0.0-beta.1", "3.0.0\n2.3.4", "03.0.0"): - with self.subTest(core=core): - self.write_versions(core=core) - with self.assertRaisesRegex(ValueError, "must contain MAJOR.MINOR.PATCH"): - release.check_version(self.root, "") - - -class DistributionVersionTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.dist = Path(self.temp.name) - - def artifact(self, kind, version="3.0.0", metadata_version=None, name="pyalps"): +@pytest.mark.parametrize("ref", [ + "", "refs/heads/master", "refs/heads/v4.0.0", "refs/pull/142/merge", "refs/tags/v3.0.0" +]) +def test_matching_versions(versions, ref): + assert release.check_version(versions(), ref) == Version("3.0.0") + + +def test_ci_rejects_original_release_using_github_ref(versions): + result = subprocess.run( + [sys.executable, release.__file__, "--root", str(versions("2.3.4", "2.3.4b1"))], + env={**os.environ, "GITHUB_REF": "refs/tags/v3.0.0"}, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert "Release tag v3.0.0 disagrees" in result.stderr + + +@pytest.mark.parametrize("ref", [ + "", "refs/heads/master", "refs/pull/142/merge", "refs/tags/v3.0.0" +]) +def test_sdk_and_python_must_agree_even_on_branches(versions, ref): + with pytest.raises(ValueError, match="disagrees with ALPS_VERSION.txt"): + release.check_version(versions("2.3.4", "3.0.0"), ref) + + +@pytest.mark.parametrize("label,suffix", [ + ("alpha.1", "a1"), ("beta.2", "b2"), ("rc.3", "rc3"), ("dev.4", ".dev4") +]) +def test_prerelease_tags_match_pep440_versions(versions, label, suffix): + python = "3.0.0" + suffix + version = release.check_version(versions(python=python), "refs/tags/v3.0.0-" + label) + assert version == Version(python) + + +@pytest.mark.parametrize("python,tag", [ + ("3.0.0b1", "v3.0.0"), ("3.0.0", "v3.0.0-beta.1"), ("3.0.0b1", "v3.0.0-beta.2") +]) +def test_prerelease_cannot_be_published_as_final_or_different_prerelease(versions, python, tag): + with pytest.raises(ValueError, match="disagrees with pyproject.toml"): + release.check_version(versions(python=python), "refs/tags/" + tag) + + +@pytest.mark.parametrize("tag", [ + "v3.0", "v3.0.0-beta", "v3.0.0-final", "v3.0.0_1", "v03.0.0", "v3.0.0+local" +]) +def test_malformed_tags_fail_closed(versions, tag): + with pytest.raises(ValueError, match="Invalid release tag"): + release.check_version(versions(), "refs/tags/" + tag) + + +@pytest.mark.parametrize("core", ["3.0", "v3.0.0", "3.0.0-beta.1", "3.0.0\n2.3.4", "03.0.0"]) +def test_invalid_numeric_core(versions, core): + with pytest.raises(ValueError, match="must contain MAJOR.MINOR.PATCH"): + release.check_version(versions(core=core), "") + + +@pytest.fixture +def artifact(tmp_path): + def write(kind, version="3.0.0", metadata_version=None, name="pyalps"): metadata = ( - f"Metadata-Version: 2.1\nName: {name}\n" - f"Version: {metadata_version or version}\n" + f"Metadata-Version: 2.1\nName: {name}\nVersion: {metadata_version or version}\n" ).encode() if kind == "wheel": - path = self.dist / f"{name}-{version}-cp313-cp313-manylinux_2_28_x86_64.whl" + path = tmp_path / f"{name}-{version}-cp313-cp313-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(path, "w") as archive: archive.writestr(f"{name}-{version}.dist-info/METADATA", metadata) else: - path = self.dist / f"{name}-{version}.tar.gz" + path = tmp_path / f"{name}-{version}.tar.gz" with tarfile.open(path, "w:gz") as archive: member = tarfile.TarInfo(f"{name}-{version}/PKG-INFO") member.size = len(metadata) archive.addfile(member, io.BytesIO(metadata)) - return path - - def test_matching_wheel_and_sdist(self): - for version in ("3.0.0", "3.0.0b1"): - with self.subTest(version=version): - paths = [self.artifact(kind, version) for kind in ("wheel", "sdist")] - release.check_distributions(self.dist, Version(version)) - for path in paths: - path.unlink() - - def test_stale_artifact_rejects_the_entire_batch(self): - self.artifact("wheel") - self.artifact("sdist", "2.3.4b1") - with self.assertRaisesRegex(ValueError, "expected a pyalps 3.0.0"): - release.check_distributions(self.dist, Version("3.0.0")) - - def test_renaming_an_old_artifact_does_not_fix_its_version(self): - for kind in ("wheel", "sdist"): - with self.subTest(kind=kind): - path = self.artifact(kind, metadata_version="2.3.4b1") - with self.assertRaisesRegex( - ValueError, "metadata does not describe pyalps 3.0.0" - ): - release.check_distributions(self.dist, Version("3.0.0")) - path.unlink() - - def test_other_project_is_rejected(self): - self.artifact("wheel", name="other") - with self.assertRaisesRegex(ValueError, "expected a pyalps 3.0.0"): - release.check_distributions(self.dist, Version("3.0.0")) - - def test_empty_dist_is_rejected(self): - with self.assertRaisesRegex(ValueError, "No distributions"): - release.check_distributions(self.dist, Version("3.0.0")) - - def test_unexpected_file_is_rejected(self): - (self.dist / "README.txt").write_text("not a distribution") - with self.assertRaisesRegex(ValueError, "Unexpected distribution"): - release.check_distributions(self.dist, Version("3.0.0")) - - -if __name__ == "__main__": - unittest.main() + return write + + +@pytest.mark.parametrize("version", ["3.0.0", "3.0.0b1"]) +def test_matching_wheel_and_sdist(tmp_path, artifact, version): + artifact("wheel", version) + artifact("sdist", version) + release.check_distributions(tmp_path, Version(version)) + + +def test_stale_artifact_rejects_the_entire_batch(tmp_path, artifact): + artifact("wheel") + artifact("sdist", "2.3.4b1") + with pytest.raises(ValueError, match="expected a pyalps 3.0.0"): + release.check_distributions(tmp_path, Version("3.0.0")) + + +@pytest.mark.parametrize("kind", ["wheel", "sdist"]) +def test_renaming_an_old_artifact_does_not_fix_its_version(tmp_path, artifact, kind): + artifact(kind, metadata_version="2.3.4b1") + with pytest.raises(ValueError, match="metadata does not describe pyalps 3.0.0"): + release.check_distributions(tmp_path, Version("3.0.0")) + + +def test_other_project_is_rejected(tmp_path, artifact): + artifact("wheel", name="other") + with pytest.raises(ValueError, match="expected a pyalps 3.0.0"): + release.check_distributions(tmp_path, Version("3.0.0")) + + +def test_empty_dist_is_rejected(tmp_path): + with pytest.raises(ValueError, match="No distributions"): + release.check_distributions(tmp_path, Version("3.0.0")) + + +def test_unexpected_file_is_rejected(tmp_path): + (tmp_path / "README.txt").write_text("not a distribution") + with pytest.raises(ValueError, match="Unexpected distribution"): + release.check_distributions(tmp_path, Version("3.0.0")) From c25a41d9796c7c529432df87640a1f62bc462f1e Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 15 Sep 2026 23:10:08 -0400 Subject: [PATCH 4/5] Build Python 3.14 wheels and backfill existing releases --- .github/workflows/build_wheels.yml | 36 +++++++++++++++++++++++------- CONTRIBUTING.md | 10 +++++++-- pyproject.toml | 1 + 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 850a0faf2..b103b305d 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -9,13 +9,27 @@ on: pull_request: branches: - master + workflow_dispatch: + inputs: + release_tag: + description: 'Existing release tag to add CPython 3.14 wheels to' + required: true + type: string + +env: + RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} jobs: check_version: name: Check release version runs-on: ubuntu-latest + outputs: + source_sha: ${{ steps.source.outputs.commit }} steps: - uses: actions/checkout@v7 + id: source + with: + ref: ${{ github.event_name == 'workflow_dispatch' && env.RELEASE_REF || github.sha }} - uses: actions/setup-python@v6 with: python-version: '3.13' @@ -23,7 +37,7 @@ jobs: - name: Test release validation run: python -m pytest test/packaging -q - name: Check package, SDK, and tag versions - run: python script/check_release_version.py + run: python script/check_release_version.py --ref "$RELEASE_REF" build_wheels: name: Build wheels on ${{ matrix.plat.os }} @@ -38,6 +52,8 @@ jobs: steps: - uses: actions/checkout@v7 + with: + ref: ${{ needs.check_version.outputs.source_sha }} # Install Fortran compiler based on OS - name: Install dependencies @@ -54,9 +70,9 @@ jobs: fi - name: Build wheels - uses: pypa/cibuildwheel@v2.22.0 + uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* cp313-* + CIBW_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'cp314-*' || 'cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*' }} CIBW_ARCHS: ${{ matrix.plat.arch }} CIBW_ARCHS_MACOS: ${{ matrix.plat.arch }} @@ -81,6 +97,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ needs.check_version.outputs.source_sha }} - name: Build sdist run: pipx run build --sdist @@ -92,27 +110,29 @@ jobs: upload_pypi: - needs: [build_wheels, build_sdist] + needs: [check_version, build_wheels, build_sdist] runs-on: ubuntu-latest environment: pypi permissions: contents: read id-token: write - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v7 + with: + ref: ${{ needs.check_version.outputs.source_sha }} - uses: actions/setup-python@v6 with: python-version: '3.13' - run: python -m pip install packaging - uses: actions/download-artifact@v8 with: - # unpacks all CIBW artifacts into dist/ - pattern: cibw-* + # Backfills upload only the new wheels; the published sdist stays intact. + pattern: ${{ github.event_name == 'workflow_dispatch' && 'cibw-wheels-*' || 'cibw-*' }} path: dist merge-multiple: true - name: Check every distribution before publishing - run: python script/check_release_version.py --dist dist + run: python script/check_release_version.py --ref "$RELEASE_REF" --dist dist - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e75f3af7..c7976a009 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,8 +165,14 @@ python script/check_release_version.py --ref refs/tags/vX.Y.Z The packaging workflow checks these versions before building and checks every wheel and source distribution, including its embedded metadata, before upload. -Only a tag push publishes to PyPI. Merge and validate the release commit before -tagging it. +Tag pushes publish the full release to PyPI. Merge and validate the release +commit before tagging it. + +To add the missing CPython 3.14 wheels to an existing release, manually run +`build_wheels.yml` with `release_tag` set to that tag (for example, `v3.0.0`). +This builds from the tag's validated commit and uploads only the three +CPython 3.14 wheels. Existing wheels and the source distribution are not +uploaded again, and the release tag does not need to move. If a published tag contains the wrong version, rerunning its workflow will rebuild the same incorrect artifacts. Correct both version files first. If diff --git a/pyproject.toml b/pyproject.toml index bfc1072e1..332fb595e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ classifiers = [ 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: Implementation :: CPython', "Operating System :: POSIX", From ffc8c0536cc3097d3d2fc8526961dace505572d5 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 15 Sep 2026 23:55:20 -0400 Subject: [PATCH 5/5] Simplify release workflows after the 3.0.0 backfill --- .github/workflows/build.yml | 23 +++++++++++++++--- .github/workflows/build_wheels.yml | 39 +++++++++--------------------- CONTRIBUTING.md | 11 +++------ 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 263047c50..9ccf58816 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,25 @@ name: ALPS CI/CD -on: [push, pull_request] +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux-build: - name: Build ALPS on ${{ matrix.plat.os }} + name: >- + ALPS / ${{ matrix.plat.os }} / ${{ matrix.plat.c_compiler }}-${{ matrix.plat.c_version }} / + Python ${{ matrix.plat.py_version }} / Boost 1.${{ matrix.plat.boost_version }} / + C++${{ matrix.plat.cxx_standard || '17' }} runs-on: ${{ matrix.plat.os }} # Worst observed healthy job is ~60 min cold; a hang otherwise burns the # 6-hour GitHub default. @@ -110,7 +125,9 @@ jobs: cmake --build build -j 2 -t test macos-build: - name: Build ALPS on ${{ matrix.plat.os }} / ${{ matrix.plat.c_compiler }} + name: >- + ALPS / ${{ matrix.plat.os }} / ${{ matrix.plat.c_compiler }} / + Python ${{ matrix.plat.py_version }} / Boost 1.${{ matrix.plat.boost_version }} / C++17 runs-on: ${{ matrix.plat.os }} # Intel runners are slow and variable; worst observed healthy job ~85 min. timeout-minutes: 120 diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index b103b305d..06c3ca26d 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -3,33 +3,23 @@ name: ALPS Python Packaging CI/CD on: push: branches: - - master # the default branch + - master tags: - - 'v*' # this triggers the workflow when a tag starting with 'v' is pushed + - 'v*' pull_request: branches: - master - workflow_dispatch: - inputs: - release_tag: - description: 'Existing release tag to add CPython 3.14 wheels to' - required: true - type: string -env: - RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: check_version: name: Check release version runs-on: ubuntu-latest - outputs: - source_sha: ${{ steps.source.outputs.commit }} steps: - uses: actions/checkout@v7 - id: source - with: - ref: ${{ github.event_name == 'workflow_dispatch' && env.RELEASE_REF || github.sha }} - uses: actions/setup-python@v6 with: python-version: '3.13' @@ -37,7 +27,7 @@ jobs: - name: Test release validation run: python -m pytest test/packaging -q - name: Check package, SDK, and tag versions - run: python script/check_release_version.py --ref "$RELEASE_REF" + run: python script/check_release_version.py build_wheels: name: Build wheels on ${{ matrix.plat.os }} @@ -52,8 +42,6 @@ jobs: steps: - uses: actions/checkout@v7 - with: - ref: ${{ needs.check_version.outputs.source_sha }} # Install Fortran compiler based on OS - name: Install dependencies @@ -72,7 +60,7 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v4.2.0 env: - CIBW_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'cp314-*' || 'cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*' }} + CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* cp313-* cp314-* CIBW_ARCHS: ${{ matrix.plat.arch }} CIBW_ARCHS_MACOS: ${{ matrix.plat.arch }} @@ -97,8 +85,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - with: - ref: ${{ needs.check_version.outputs.source_sha }} - name: Build sdist run: pipx run build --sdist @@ -110,29 +96,26 @@ jobs: upload_pypi: - needs: [check_version, build_wheels, build_sdist] + needs: [build_wheels, build_sdist] runs-on: ubuntu-latest environment: pypi permissions: contents: read id-token: write - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v7 - with: - ref: ${{ needs.check_version.outputs.source_sha }} - uses: actions/setup-python@v6 with: python-version: '3.13' - run: python -m pip install packaging - uses: actions/download-artifact@v8 with: - # Backfills upload only the new wheels; the published sdist stays intact. - pattern: ${{ github.event_name == 'workflow_dispatch' && 'cibw-wheels-*' || 'cibw-*' }} + pattern: cibw-* path: dist merge-multiple: true - name: Check every distribution before publishing - run: python script/check_release_version.py --ref "$RELEASE_REF" --dist dist + run: python script/check_release_version.py --dist dist - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7976a009..fb8adeb49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,14 +165,9 @@ python script/check_release_version.py --ref refs/tags/vX.Y.Z The packaging workflow checks these versions before building and checks every wheel and source distribution, including its embedded metadata, before upload. -Tag pushes publish the full release to PyPI. Merge and validate the release -commit before tagging it. - -To add the missing CPython 3.14 wheels to an existing release, manually run -`build_wheels.yml` with `release_tag` set to that tag (for example, `v3.0.0`). -This builds from the tag's validated commit and uploads only the three -CPython 3.14 wheels. Existing wheels and the source distribution are not -uploaded again, and the release tag does not need to move. +Tag pushes publish the full release to PyPI, including CPython 3.9–3.14 wheels. +Merge and validate the release commit before tagging it. Keep tags fixed once +their release has been published. If a published tag contains the wrong version, rerunning its workflow will rebuild the same incorrect artifacts. Correct both version files first. If