diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c24342..a14c2aa 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 }} @@ -28,33 +28,19 @@ jobs: echo "skip=true" >> $GITHUB_OUTPUT fi - - name: Set up Python - if: steps.check_skip.outputs.skip != 'true' - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install uv - if: steps.check_skip.outputs.skip != 'true' - uses: astral-sh/setup-uv@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..114a68c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,12 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - run: uv sync --extra dev + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - 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 78ee1cc..a31d4b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,12 @@ 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 +# 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" [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..ce5f87b --- /dev/null +++ b/tests/test_release_contract.py @@ -0,0 +1,91 @@ +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") + test_workflow = (ROOT / ".github/workflows/test.yml").read_text( + 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 && " + "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") + 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: + 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" },