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
4 changes: 2 additions & 2 deletions .github/workflows/notify-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v3
- uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.DOCS_DISPATCH_TOKEN }}
repository: OpenAdaptAI/openadapt-maintenance
event-type: repo-updated
client-payload: '{"repo": "${{ github.repository }}", "sha": "${{ github.sha }}"}'
client-payload: '{"repo": "${{ github.repository }}", "sha": "${{ github.sha }}"}'
22 changes: 4 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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 }}
Expand All @@ -31,34 +31,20 @@ 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 }}

Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.29"

- name: Install dependencies
run: uv sync --extra dev --no-sources
run: uv sync --locked --extra dev --no-sources

- name: Guard against bare heavy imports in tests
run: |
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ line-length = 100
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"
Expand Down
124 changes: 124 additions & 0 deletions scripts/verify_release_lock.py
Original file line number Diff line number Diff line change
@@ -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<body>.*?)(?=^\[|\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<value>[^"]+)"\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<value>[^"]+)"\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<value>[^"]+)"\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())
90 changes: 90 additions & 0 deletions tests/test_release_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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.90.0", "0.89.0")
try:
release_lock.verify_release_lock(tmp_path)
except ValueError as exc:
assert "pyproject.toml=0.90.0, uv.lock=0.89.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.90.0", "0.89.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.89.0"',
'name = "example-package"\nversion = "0.90.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/release.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 "uv sync --locked --extra dev --no-sources" 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"
)
Loading
Loading