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" },