Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,19 @@ jobs:

- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.12.5"

- name: Install the locked release runtime
run: uv sync --locked --extra release

- name: Python Semantic Release
- name: Run the locked Python Semantic Release CLI
id: release
uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1
with:
github_token: ${{ secrets.ADMIN_TOKEN }}
env:
GH_TOKEN: ${{ secrets.ADMIN_TOKEN }}
run: >-
uv run --locked --extra release
python scripts/run_semantic_release.py

- name: Verify release workspace
if: steps.release.outputs.released == 'true'
Expand Down
15 changes: 12 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ dev = [
]
release = [
"uv==0.12.5",
# Build inside this reviewed environment. An isolated `uv build` would
# resolve the backend again and bypass uv.lock.
"hatchling==1.31.0",
# Keep the release CLI outside its Docker action. That action resolves
# transitive packages during each run, so the fixed action SHA did not
# prevent GitPython 3.1.60 from breaking PSR 10.6.1 on 2026-08-25.
"python-semantic-release==10.6.1",
# Remove this cap only with the reviewed PSR release that contains
# https://github.com/python-semantic-release/python-semantic-release/pull/1477.
"GitPython==3.1.59",
]
all = [
"openadapt-tray[macos-native]",
Expand All @@ -71,7 +81,7 @@ Repository = "https://github.com/OpenAdaptAI/openadapt-tray"


[build-system]
requires = ["hatchling"]
requires = ["hatchling==1.31.0"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
Expand Down Expand Up @@ -120,11 +130,10 @@ commit_message = "chore: release {version}"
allow_zero_version = true
major_on_zero = false
build_command = """
python -m pip install --disable-pip-version-check "uv==0.12.5" &&
python scripts/check_release_consistency.py --write-lock &&
uv lock --locked --offline &&
git add uv.lock &&
uv build --wheel --sdist &&
uv build --no-build-isolation --wheel --sdist &&
python scripts/check_release_consistency.py --require-dist
"""

Expand Down
92 changes: 92 additions & 0 deletions scripts/run_semantic_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Run the exact semantic-release CLI from the reviewed uv environment."""

from __future__ import annotations

import os
import subprocess
import sys
from collections.abc import Callable, Mapping, Sequence
from importlib import metadata
from pathlib import Path

REQUIRED_RUNTIME = {
"python-semantic-release": "10.6.1",
"GitPython": "3.1.59",
}
REQUIRED_ENVIRONMENT = ("GH_TOKEN", "GITHUB_OUTPUT")


class ReleaseRuntimeError(RuntimeError):
"""The installed release runtime differs from the reviewed lock."""


def verify_runtime(
version_reader: Callable[[str], str] = metadata.version,
) -> None:
"""Refuse a release when either load-bearing package has drifted."""

for distribution, expected in REQUIRED_RUNTIME.items():
try:
actual = version_reader(distribution)
except metadata.PackageNotFoundError as exc:
raise ReleaseRuntimeError(
f"required release package is not installed: {distribution}"
) from exc
if actual != expected:
raise ReleaseRuntimeError(
f"{distribution} version differs: expected {expected}; got {actual}"
)


def release_command(python_executable: str = sys.executable) -> list[str]:
"""Return the console entry point installed beside the active Python."""

# Do not resolve the Python symlink. uv places console scripts beside the
# virtual-environment link, not beside the managed interpreter target.
cli = Path(python_executable).absolute().with_name("semantic-release")
if not cli.is_file() or not os.access(cli, os.X_OK):
raise ReleaseRuntimeError(
f"semantic-release is not executable beside the active Python: {cli}"
)
return [str(cli), "-v", "version"]


def run_release(
*,
environment: Mapping[str, str] = os.environ,
version_reader: Callable[[str], str] = metadata.version,
python_executable: str = sys.executable,
runner: Callable[..., subprocess.CompletedProcess[object]] = subprocess.run,
) -> int:
"""Run PSR unchanged so it writes its native GitHub Action outputs."""

missing = [name for name in REQUIRED_ENVIRONMENT if not environment.get(name)]
if missing:
raise ReleaseRuntimeError(
"release environment is missing: " + ", ".join(missing)
)
verify_runtime(version_reader)
result = runner(
release_command(python_executable),
env=dict(environment),
check=False,
)
return result.returncode


def main(argv: Sequence[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if argv:
print("run_semantic_release.py accepts no arguments", file=sys.stderr)
return 2
try:
return run_release()
except (OSError, ReleaseRuntimeError) as exc:
print(f"RELEASE RUNTIME REFUSED: {exc}", file=sys.stderr)
return 1


if __name__ == "__main__":
raise SystemExit(main())
111 changes: 97 additions & 14 deletions tests/test_release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import re
import shutil
import stat
import subprocess
from pathlib import Path

import pytest
Expand All @@ -10,6 +12,11 @@
release_versions,
synchronize_release_lock,
)
from scripts.run_semantic_release import (
REQUIRED_RUNTIME,
ReleaseRuntimeError,
run_release,
)

ROOT = Path(__file__).resolve().parents[1]

Expand All @@ -20,15 +27,14 @@ def test_release_versions_are_synchronized() -> None:


def test_release_uv_pin_is_declared_once() -> None:
"""The `release` extra and the build command must install the same uv.

The pin lives in two places in pyproject.toml. Only the `release` extra is
reflected in uv.lock, so if a bump touches one and not the other, the
release build silently installs a uv that is neither declared nor locked.
"""
"""The reviewed lock must supply the only uv used during a release."""
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
pins = set(re.findall(r'"uv==([^"]+)"', pyproject))
assert len(pins) == 1, f"pyproject.toml pins uv at more than one version: {pins}"
assert pyproject.count('"uv==0.12.5"') == 1

build_command = re.search(r'(?s)build_command = """(.*?)"""', pyproject)
assert build_command
assert "pip install" not in build_command.group(1)
assert "uv build --no-build-isolation --wheel --sdist" in build_command.group(1)


def test_semantic_release_refreshes_and_stages_lock_before_tagging() -> None:
Expand All @@ -42,19 +48,16 @@ def test_semantic_release_refreshes_and_stages_lock_before_tagging() -> None:
assert "$PACKAGE_NAME" not in pyproject
assert "uv lock --upgrade-package" not in pyproject

install = pyproject.index(
'python -m pip install --disable-pip-version-check "uv==0.12.5"'
)
synchronize = pyproject.index(
"python scripts/check_release_consistency.py --write-lock"
)
validate = pyproject.index("uv lock --locked --offline")
stage = pyproject.index("git add uv.lock")
build = pyproject.index("uv build --wheel --sdist")
build = pyproject.index("uv build --no-build-isolation --wheel --sdist")
verify = pyproject.index(
"python scripts/check_release_consistency.py --require-dist"
)
assert install < synchronize < validate < stage < build < verify
assert synchronize < validate < stage < build < verify

build_command = re.search(
r'(?s)build_command = """(.*?)"""', pyproject
Expand Down Expand Up @@ -139,8 +142,88 @@ def test_release_actions_are_pinned_to_commits() -> None:
assert all(re.fullmatch(r"[0-9a-f]{40}", revision) for revision in uses)


def test_release_uses_the_reviewed_locked_psr_runtime() -> None:
workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
lock = (ROOT / "uv.lock").read_text(encoding="utf-8")

assert "python-semantic-release/python-semantic-release@" not in workflow
assert 'version: "0.12.5"' in workflow
assert "uv sync --locked --extra release" in workflow
assert "uv run --locked --extra release" in workflow
assert "python scripts/run_semantic_release.py" in workflow
assert "GH_TOKEN: ${{ secrets.ADMIN_TOKEN }}" in workflow
assert '"python-semantic-release==10.6.1"' in pyproject
assert '"GitPython==3.1.59"' in pyproject
assert pyproject.count('"hatchling==1.31.0"') == 2
assert re.search(
r'(?ms)^name = "python-semantic-release"\nversion = "10\.6\.1"$', lock
)
assert re.search(r'(?ms)^name = "gitpython"\nversion = "3\.1\.59"$', lock)
assert re.search(r'(?ms)^name = "hatchling"\nversion = "1\.31\.0"$', lock)


def test_locked_wrapper_preserves_psr_github_outputs(tmp_path: Path) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runtime_dir = tmp_path / "runtime"
runtime_dir.mkdir()
real_python = runtime_dir / "python"
real_python.touch()
python = bin_dir / "python"
python.symlink_to(real_python)
cli = bin_dir / "semantic-release"
cli.touch()
cli.chmod(cli.stat().st_mode | stat.S_IXUSR)
output = tmp_path / "github-output"
environment = {"GH_TOKEN": "test-token", "GITHUB_OUTPUT": str(output)}

def fake_runner(
command: list[str],
*,
env: dict[str, str],
check: bool,
) -> subprocess.CompletedProcess[object]:
assert command == [str(cli), "-v", "version"]
assert check is False
Path(env["GITHUB_OUTPUT"]).write_text(
"released=true\nversion=0.3.3\ntag=v0.3.3\n",
encoding="utf-8",
)
return subprocess.CompletedProcess(command, 0)

result = run_release(
environment=environment,
version_reader=lambda name: REQUIRED_RUNTIME[name],
python_executable=str(python),
runner=fake_runner,
)

assert result == 0
assert output.read_text(encoding="utf-8").splitlines() == [
"released=true",
"version=0.3.3",
"tag=v0.3.3",
]


def test_locked_wrapper_refuses_runtime_drift(tmp_path: Path) -> None:
with pytest.raises(ReleaseRuntimeError, match="GitPython version differs"):
run_release(
environment={
"GH_TOKEN": "test-token",
"GITHUB_OUTPUT": str(tmp_path / "output"),
},
version_reader=lambda name: (
"3.1.60" if name == "GitPython" else REQUIRED_RUNTIME[name]
),
runner=lambda *args, **kwargs: subprocess.CompletedProcess(args, 0),
)


def test_release_uses_protected_branch_credential_everywhere() -> None:
workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")
assert "token: ${{ secrets.ADMIN_TOKEN }}" in workflow
assert workflow.count("github_token: ${{ secrets.ADMIN_TOKEN }}") == 2
assert workflow.count("github_token: ${{ secrets.ADMIN_TOKEN }}") == 1
assert workflow.count("GH_TOKEN: ${{ secrets.ADMIN_TOKEN }}") == 1
assert "secrets.GITHUB_TOKEN" not in workflow
Loading