Skip to content

Commit 482a081

Browse files
ci: verify Python release artifacts (#504)
Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
1 parent ddc5261 commit 482a081

5 files changed

Lines changed: 112 additions & 1 deletion

File tree

.github/workflows/release.yml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,38 @@ jobs:
1919
python-version: "3.12"
2020

2121
- name: Install build
22-
run: python -m pip install build
22+
run: python -m pip install build twine
2323

2424
- name: Build distributions
2525
run: python -m build
2626

27+
- name: Verify distribution metadata
28+
run: python -m twine check dist/*
29+
30+
- name: Install and smoke-test wheel and sdist
31+
shell: bash
32+
run: |
33+
expected_version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
34+
if [[ "${{ github.event_name }}" == "release" ]] && [[ "${{ github.event.release.tag_name }}" != "v$expected_version" ]]; then
35+
echo "release tag ${{ github.event.release.tag_name }} does not match package version $expected_version" >&2
36+
exit 1
37+
fi
38+
index=0
39+
for artifact in dist/*.whl dist/*.tar.gz; do
40+
index=$((index + 1))
41+
venv="$RUNNER_TEMP/cmcp-dist-$index"
42+
python -m venv "$venv"
43+
"$venv/bin/python" -m pip install --disable-pip-version-check "$artifact"
44+
(
45+
cd "$RUNNER_TEMP"
46+
"$venv/bin/python" "$GITHUB_WORKSPACE/scripts/verify_python_distribution.py" \
47+
--expected-version "$expected_version" \
48+
--forbidden-source-root "$GITHUB_WORKSPACE/src"
49+
"$venv/bin/cmcp" --help >/dev/null
50+
)
51+
done
52+
test "$index" -eq 2
53+
2754
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
2855
with:
2956
name: dist

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- The unauthenticated health/readiness rate limiter now expires inactive source-address entries and caps tracked clients at 10,000. Source-address churn can no longer grow the in-memory limiter map for the lifetime of the gateway.
1414
- Upstream stdio children and provenance verdicts are now cached by complete execution and trust identity rather than the non-unique human-readable `display_name`. Distinct catalog servers sharing a label can no longer reuse another server's process or provenance result.
1515
- Built wheels now include the catalog-entry JSON Schema, and catalog loading fails closed if that schema is absent or unreadable. Previously source-tree tests validated catalog structure, but installed wheels omitted the schema and silently skipped that validation.
16+
- PyPI publication now installs and smoke-tests the exact wheel and source distribution before upload, including release-tag/version agreement, import provenance, runtime configuration, and the packaged CLI.
1617

1718
### Changed
1819

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ pytest tests/unit/ -v # unit tests
3737

3838
All four must pass before a PR is mergeable.
3939

40+
### Release artifact verification
41+
42+
The PyPI workflow installs the exact wheel and source distribution into separate
43+
clean environments before upload. It checks release-tag/version agreement,
44+
metadata and runtime versions, import provenance outside the checkout, core
45+
configuration construction, and the installed `cmcp` console entry point.
46+
4047
## Commit format
4148

4249
Follow [Conventional Commits](https://www.conventionalcommits.org/):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Smoke-test an installed CMCP distribution outside the checkout."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import sys
7+
from importlib.metadata import version
8+
from pathlib import Path
9+
10+
import cmcp_runtime
11+
import cmcp_verify
12+
from cmcp_runtime.config import Config
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser()
17+
parser.add_argument("--expected-version", required=True)
18+
parser.add_argument("--forbidden-source-root", required=True, type=Path)
19+
args = parser.parse_args()
20+
21+
installed_version = version("cmcp-runtime")
22+
if installed_version != args.expected_version:
23+
raise SystemExit(
24+
f"installed version {installed_version!r} != expected {args.expected_version!r}"
25+
)
26+
if cmcp_runtime.__version__ != installed_version:
27+
raise SystemExit(
28+
f"runtime version {cmcp_runtime.__version__!r} != metadata {installed_version!r}"
29+
)
30+
31+
forbidden_root = args.forbidden_source_root.resolve()
32+
for module in (cmcp_runtime, cmcp_verify):
33+
module_path = Path(module.__file__).resolve()
34+
if module_path.is_relative_to(forbidden_root):
35+
raise SystemExit(
36+
f"smoke test imported checkout source {module_path}, not the distribution"
37+
)
38+
39+
config = Config()
40+
if config.max_response_size_bytes <= 0:
41+
raise SystemExit("installed Config produced an invalid response-size bound")
42+
43+
sys.stdout.write(
44+
f"verified cmcp-runtime {installed_version} from "
45+
f"{Path(cmcp_runtime.__file__).resolve()}\n"
46+
)
47+
48+
49+
if __name__ == "__main__":
50+
main()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Tests for the standalone installed-distribution release smoke check."""
2+
3+
import subprocess
4+
import sys
5+
from importlib.metadata import version
6+
from pathlib import Path
7+
8+
9+
def test_distribution_smoke_script_exercises_installed_public_api(tmp_path: Path) -> None:
10+
script = Path(__file__).parents[2] / "scripts" / "verify_python_distribution.py"
11+
completed = subprocess.run(
12+
[
13+
sys.executable,
14+
str(script),
15+
"--expected-version",
16+
version("cmcp-runtime"),
17+
"--forbidden-source-root",
18+
str(tmp_path),
19+
],
20+
check=False,
21+
capture_output=True,
22+
text=True,
23+
)
24+
25+
assert completed.returncode == 0, completed.stderr
26+
assert "verified cmcp-runtime" in completed.stdout

0 commit comments

Comments
 (0)