From abee850ec7aeb85236f83b3b9a79e7040a1e2add Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 15 Jul 2026 18:29:19 -0400 Subject: [PATCH 1/4] fix: keep release lock metadata consistent --- .github/workflows/publish.yml | 16 ++--- .github/workflows/test.yml | 4 +- pyproject.toml | 8 +++ scripts/verify_release_lock.py | 124 +++++++++++++++++++++++++++++++++ tests/test_release_contract.py | 77 ++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 scripts/verify_release_lock.py create mode 100644 tests/test_release_contract.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c24342..ad57a7d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 token: ${{ secrets.ADMIN_TOKEN }} @@ -30,31 +30,27 @@ jobs: - name: Set up Python if: steps.check_skip.outputs.skip != 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' - name: Install uv if: steps.check_skip.outputs.skip != 'true' - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4 - name: Python Semantic Release if: steps.check_skip.outputs.skip != 'true' id: release - uses: python-semantic-release/python-semantic-release@v9.15.2 + uses: python-semantic-release/python-semantic-release@7b3f71697ccfbaef884e1e754b6364e974b134cf # v9.15.2 with: github_token: ${{ secrets.ADMIN_TOKEN }} - - name: Build package - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - run: uv build - - name: Publish to PyPI if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 - name: Publish to GitHub Releases if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - uses: python-semantic-release/publish-action@v9.15.2 + uses: python-semantic-release/publish-action@b9c41d4b0754dee5a6c7188d42b33f66e3a8aafd # v9.15.2 with: github_token: ${{ secrets.ADMIN_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 919515d..be8cc9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4 - run: uv sync --extra dev - run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 78ee1cc..79b0e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,14 @@ testpaths = ["tests"] version_toml = ["pyproject.toml:project.version"] commit_message = "chore: release {version}" major_on_zero = false +# Semantic Release changes only project metadata. Stamp that version into the +# existing editable lock entry without resolving dependencies, stage it in the +# generated release commit, and build exactly those reviewed inputs. +build_command = """ +python scripts/verify_release_lock.py --write +git add uv.lock +uv build +""" [tool.semantic_release.branches.main] match = "main" diff --git a/scripts/verify_release_lock.py b/scripts/verify_release_lock.py new file mode 100644 index 0000000..6c93e69 --- /dev/null +++ b/scripts/verify_release_lock.py @@ -0,0 +1,124 @@ +"""Verify that project metadata and the editable uv lock entry agree.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _quoted_table_value(text: str, table: str, key: str) -> str: + table_match = re.search( + rf"(?ms)^\[{re.escape(table)}\]\s*$\n(?P.*?)(?=^\[|\Z)", + text, + ) + if table_match is None: + raise ValueError(f"missing [{table}] table") + value_match = re.search( + rf'(?m)^{re.escape(key)}\s*=\s*"(?P[^"]+)"\s*$', + table_match.group("body"), + ) + if value_match is None: + raise ValueError(f"missing quoted [{table}].{key}") + return value_match.group("value") + + +def _editable_lock_entry(lock_text: str, package_name: str) -> tuple[int, int, str]: + matches: list[tuple[int, int, str]] = [] + for block_match in re.finditer( + r"(?ms)^\[\[package\]\]\s*$\n.*?(?=^\[\[package\]\]\s*$|\Z)", + lock_text, + ): + block = block_match.group(0) + name_match = re.search(r'(?m)^name\s*=\s*"(?P[^"]+)"\s*$', block) + if name_match is None or name_match.group("value") != package_name: + continue + if not re.search( + r'(?m)^source\s*=\s*\{\s*editable\s*=\s*"\."\s*\}\s*$', + block, + ): + continue + version_match = re.search( + r'(?m)^version\s*=\s*"(?P[^"]+)"\s*$', block + ) + if version_match is None: + raise ValueError(f"editable {package_name!r} lock entry has no version") + matches.append( + ( + block_match.start() + version_match.start("value"), + block_match.start() + version_match.end("value"), + version_match.group("value"), + ) + ) + if len(matches) != 1: + raise ValueError( + f"expected exactly one editable {package_name!r} lock entry; " + f"found {len(matches)}" + ) + return matches[0] + + +def _project_identity(root: Path) -> tuple[str, str]: + project_text = (root / "pyproject.toml").read_text(encoding="utf-8") + return ( + _quoted_table_value(project_text, "project", "name"), + _quoted_table_value(project_text, "project", "version"), + ) + + +def release_versions(root: Path = ROOT) -> tuple[str, str]: + """Return ``(project_version, editable_lock_version)``.""" + package_name, project_version = _project_identity(root) + lock_text = (root / "uv.lock").read_text(encoding="utf-8") + _, _, lock_version = _editable_lock_entry(lock_text, package_name) + return project_version, lock_version + + +def synchronize_release_lock(root: Path = ROOT) -> bool: + """Stamp only the editable root version, preserving reviewed resolution.""" + package_name, project_version = _project_identity(root) + lock_path = root / "uv.lock" + lock_text = lock_path.read_text(encoding="utf-8") + version_start, version_end, lock_version = _editable_lock_entry( + lock_text, package_name + ) + if lock_version == project_version: + return False + lock_path.write_text( + lock_text[:version_start] + project_version + lock_text[version_end:], + encoding="utf-8", + ) + return True + + +def verify_release_lock(root: Path = ROOT) -> None: + project_version, lock_version = release_versions(root) + if project_version != lock_version: + raise ValueError( + "release version drift: " + f"pyproject.toml={project_version}, uv.lock={lock_version}; " + "run `python scripts/verify_release_lock.py --write`" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--write", + action="store_true", + help="stamp the project version into the editable lock entry before checking", + ) + args = parser.parse_args() + try: + if args.write: + synchronize_release_lock() + verify_release_lock() + except ValueError as exc: + parser.exit(1, f"{exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py new file mode 100644 index 0000000..4c23c5b --- /dev/null +++ b/tests/test_release_contract.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "verify_release_lock.py" +SPEC = importlib.util.spec_from_file_location("verify_release_lock", SCRIPT) +assert SPEC and SPEC.loader +release_lock = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release_lock) + + +def _write_release_files(root: Path, project_version: str, lock_version: str) -> None: + (root / "pyproject.toml").write_text( + '[project]\nname = "example-package"\n' + f'version = "{project_version}"\n', + encoding="utf-8", + ) + (root / "uv.lock").write_text( + '[[package]]\nname = "dependency"\nversion = "8.1.8"\n' + 'source = { registry = "https://pypi.org/simple" }\n\n' + '[[package]]\nname = "example-package"\n' + f'version = "{lock_version}"\nsource = {{ editable = "." }}\n', + encoding="utf-8", + ) + + +def test_real_release_metadata_is_consistent() -> None: + project_version, lock_version = release_lock.release_versions() + assert project_version == lock_version + release_lock.verify_release_lock() + + +def test_release_lock_rejects_version_drift(tmp_path: Path) -> None: + _write_release_files(tmp_path, "0.4.0", "0.3.0") + try: + release_lock.verify_release_lock(tmp_path) + except ValueError as exc: + assert "pyproject.toml=0.4.0, uv.lock=0.3.0" in str(exc) + else: + raise AssertionError("version drift was accepted") + + +def test_sync_changes_only_editable_root_and_is_idempotent(tmp_path: Path) -> None: + _write_release_files(tmp_path, "0.4.0", "0.3.0") + before = (tmp_path / "uv.lock").read_text(encoding="utf-8") + assert release_lock.synchronize_release_lock(tmp_path) is True + after = (tmp_path / "uv.lock").read_text(encoding="utf-8") + assert after == before.replace( + 'name = "example-package"\nversion = "0.3.0"', + 'name = "example-package"\nversion = "0.4.0"', + ) + assert release_lock.synchronize_release_lock(tmp_path) is False + assert (tmp_path / "uv.lock").read_text(encoding="utf-8") == after + + +def test_release_configuration_is_fail_closed() -> None: + metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + workflow = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8") + assert "major_on_zero = false" in metadata + assert metadata.index("python scripts/verify_release_lock.py --write") < metadata.index( + "git add uv.lock" + ) < metadata.index("uv build") + assert "run: uv build" not in workflow + assert workflow.count("secrets.ADMIN_TOKEN") >= 3 + assert "secrets.GITHUB_TOKEN" not in workflow + + +def test_all_third_party_actions_are_commit_pinned() -> None: + action_pattern = re.compile(r"uses:\s*([^\s@]+)@([^\s#]+)") + for path in (ROOT / ".github" / "workflows").glob("*.yml"): + for action, action_ref in action_pattern.findall(path.read_text(encoding="utf-8")): + assert re.fullmatch(r"[0-9a-f]{40}", action_ref), ( + f"{path.name}: {action}@{action_ref} is not pinned to a commit" + ) diff --git a/uv.lock b/uv.lock index 6ffd440..678dcb8 100644 --- a/uv.lock +++ b/uv.lock @@ -160,7 +160,7 @@ wheels = [ [[package]] name = "openadapt-types" -version = "0.1.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, From 2f3f25a07a3966d77ac0d788c9a27a95da23eb83 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 15 Jul 2026 18:45:28 -0400 Subject: [PATCH 2/4] fix: make release builds self-contained --- .github/workflows/publish.yml | 10 ---------- .github/workflows/test.yml | 9 +++++++-- pyproject.toml | 4 +++- tests/test_release_contract.py | 14 +++++++++++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad57a7d..a14c2aa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,16 +28,6 @@ jobs: echo "skip=true" >> $GITHUB_OUTPUT fi - - name: Set up Python - if: steps.check_skip.outputs.skip != 'true' - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.12' - - - name: Install uv - if: steps.check_skip.outputs.skip != 'true' - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4 - - name: Python Semantic Release if: steps.check_skip.outputs.skip != 'true' id: release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be8cc9c..114a68c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4 - - run: uv sync --extra dev + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + - run: uv sync --locked --extra dev - run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 79b0e63..c8527cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,8 +53,10 @@ commit_message = "chore: release {version}" major_on_zero = false # Semantic Release changes only project metadata. Stamp that version into the # existing editable lock entry without resolving dependencies, stage it in the -# generated release commit, and build exactly those reviewed inputs. +# generated release commit, and build exactly those reviewed inputs. The PSR +# action is a Docker action, so uv must be installed inside its container. build_command = """ +python -m pip install uv==0.11.29 python scripts/verify_release_lock.py --write git add uv.lock uv build diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 4c23c5b..2a2d3c3 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -59,13 +59,21 @@ def test_sync_changes_only_editable_root_and_is_idempotent(tmp_path: Path) -> No def test_release_configuration_is_fail_closed() -> None: metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") workflow = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8") + test_workflow = (ROOT / ".github/workflows/test.yml").read_text( + encoding="utf-8" + ) assert "major_on_zero = false" in metadata - assert metadata.index("python scripts/verify_release_lock.py --write") < metadata.index( - "git add uv.lock" - ) < metadata.index("uv build") + assert metadata.index("python -m pip install uv==0.11.29") < metadata.index( + "python scripts/verify_release_lock.py --write" + ) < metadata.index("git add uv.lock") < metadata.index("uv build") assert "run: uv build" not in workflow + assert "astral-sh/setup-uv" not in workflow + assert "actions/setup-python" not in workflow assert workflow.count("secrets.ADMIN_TOKEN") >= 3 assert "secrets.GITHUB_TOKEN" not in workflow + assert 'version: "0.11.29"' in test_workflow + assert 'python-version: "3.12"' in test_workflow + assert "uv sync --locked --extra dev" in test_workflow def test_all_third_party_actions_are_commit_pinned() -> None: From 580ff66a71b3bfc539d85c2fee991c5b9ae53419 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 15 Jul 2026 20:03:16 -0400 Subject: [PATCH 3/4] fix: fail closed when release tooling is unavailable --- pyproject.toml | 7 +------ tests/test_release_contract.py | 5 +++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c8527cc..2505740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,12 +55,7 @@ major_on_zero = false # existing editable lock entry without resolving dependencies, stage it in the # generated release commit, and build exactly those reviewed inputs. The PSR # action is a Docker action, so uv must be installed inside its container. -build_command = """ -python -m pip install uv==0.11.29 -python scripts/verify_release_lock.py --write -git add uv.lock -uv build -""" +build_command = "python -m pip install uv==0.11.29 && python scripts/verify_release_lock.py --write && git add uv.lock && uv build" [tool.semantic_release.branches.main] match = "main" diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 2a2d3c3..3256db7 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -63,6 +63,11 @@ def test_release_configuration_is_fail_closed() -> None: encoding="utf-8" ) assert "major_on_zero = false" in metadata + assert ( + "python -m pip install uv==0.11.29 && " + "python scripts/verify_release_lock.py --write && " + "git add uv.lock && uv build" + ) in metadata assert metadata.index("python -m pip install uv==0.11.29") < metadata.index( "python scripts/verify_release_lock.py --write" ) < metadata.index("git add uv.lock") < metadata.index("uv build") From c90ceacbbad27e91b034691f4fa02337b3045af9 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 15 Jul 2026 20:10:13 -0400 Subject: [PATCH 4/4] fix: preserve zero-major release policy --- pyproject.toml | 1 + tests/test_release_contract.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2505740..a31d4b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ testpaths = ["tests"] version_toml = ["pyproject.toml:project.version"] commit_message = "chore: release {version}" major_on_zero = false +allow_zero_version = true # Semantic Release changes only project metadata. Stamp that version into the # existing editable lock entry without resolving dependencies, stage it in the # generated release commit, and build exactly those reviewed inputs. The PSR diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 3256db7..ce5f87b 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -63,6 +63,7 @@ def test_release_configuration_is_fail_closed() -> None: encoding="utf-8" ) assert "major_on_zero = false" in metadata + assert "allow_zero_version = true" in metadata assert ( "python -m pip install uv==0.11.29 && " "python scripts/verify_release_lock.py --write && "