automate publishing: release github + release pypi

This commit is contained in:
ykcheungab
2026-09-12 16:50:55 +08:00
parent 388f34ca85
commit 174bceabc4
10 changed files with 1395 additions and 55 deletions
+312
View File
@@ -0,0 +1,312 @@
"""Tests for the publish gate, the GitHub plan and the PyPI command shape.
Nothing here touches the network, ``gh`` or an index: the mutating paths are
verified through their pure planning functions, which is exactly what the CLI
executes after the gate passes.
"""
from __future__ import annotations
import json
import sys
import urllib.error
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_ROOT = REPO_ROOT / "build"
if str(BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(BUILD_ROOT))
from release import github, publish, pypi # noqa: E402
def _wheel(tmp_path: Path, name: str, payload: bytes = b"wheel") -> Path:
path = tmp_path / name
path.write_bytes(payload)
return path
def _report_payload(version: str, wheels: list[Path], **overrides) -> dict:
payload = {
"schema": 1,
"version": version,
"ok": True,
"aborted": False,
"git": {"commit": "a" * 40},
"options": {"skip_tests": False, "allow_dirty": False},
"wheels": [
{"name": wheel.name, "sha256": publish.sha256_file(wheel), "size": wheel.stat().st_size}
for wheel in wheels
],
}
payload.update(overrides)
return payload
# --- gate -------------------------------------------------------------------
def test_gate_passes_for_matching_wheels(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, warnings = publish.evaluate_gate(
_report_payload("1.2.3", wheels), "1.2.3", {w.name: w for w in wheels}
)
assert problems == []
assert warnings == []
def test_gate_rejects_version_mismatch(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, _ = publish.evaluate_gate(
_report_payload("9.9.9", wheels), "1.2.3", {w.name: w for w in wheels}
)
assert any("report is for version" in problem for problem in problems)
def test_gate_rejects_failed_report(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, _ = publish.evaluate_gate(
_report_payload("1.2.3", wheels, ok=False), "1.2.3", {w.name: w for w in wheels}
)
assert any("result is FAIL" in problem for problem in problems)
def test_gate_rejects_waived_guard_options(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels, options={"skip_tests": True, "no_runtime": True})
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
assert any("--skip-tests" in problem and "--no-runtime" in problem for problem in problems)
def test_gate_rejects_changed_wheel(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
wheels[0].write_bytes(b"tampered")
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
assert any("changed after preflight" in problem for problem in problems)
def test_gate_rejects_uncertified_and_missing_wheels(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
extra = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-win_amd64.whl")
problems, _ = publish.evaluate_gate(
payload, "1.2.3", {w.name: w for w in [*wheels, extra]}
)
assert any("not covered by the report" in problem for problem in problems)
def test_gate_warns_on_commit_drift(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
problems, warnings = publish.evaluate_gate(
payload, "1.2.3", {w.name: w for w in wheels}, head_commit="b" * 40
)
assert problems == []
assert any("HEAD is" in warning for warning in warnings)
def test_load_gate_requires_a_report(tmp_path, monkeypatch):
monkeypatch.setattr(publish, "REPO_ROOT", tmp_path, raising=False)
with pytest.raises(publish.PublishGateError, match="no preflight report"):
publish.load_gate(tmp_path, "1.2.3")
def test_load_gate_explains_waived_report(tmp_path):
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
report_dir.mkdir(parents=True)
wheel_dir = tmp_path / "SDK" / "1.2.3"
wheel_dir.mkdir(parents=True)
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
wheel.write_bytes(b"wheel")
(report_dir / "preflight.json").write_text(
json.dumps(_report_payload("1.2.3", [wheel], options={"skip_tests": True})),
encoding="utf-8",
)
with pytest.raises(publish.PublishGateError, match="--skip-tests"):
publish.load_gate(tmp_path, "1.2.3")
def test_load_gate_returns_certified_wheels(tmp_path):
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
report_dir.mkdir(parents=True)
wheel_dir = tmp_path / "SDK" / "1.2.3"
wheel_dir.mkdir(parents=True)
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
wheel.write_bytes(b"wheel")
(report_dir / "preflight.json").write_text(
json.dumps(_report_payload("1.2.3", [wheel])), encoding="utf-8"
)
gate = publish.load_gate(tmp_path, "1.2.3")
assert [record.name for record in gate.wheels] == [wheel.name]
assert gate.wheels[0].sha256 == publish.sha256_file(wheel)
assert gate.git_commit == "a" * 40
# --- github -----------------------------------------------------------------
def test_github_steps_shape(tmp_path):
assets = [_wheel(tmp_path, f"orisys-1.2.3-cp3n{n}-cp3n{n}-win_amd64.whl") for n in (0, 1)]
steps = github.build_github_steps(
version="1.2.3",
branch="main",
tag="v1.2.3",
assets=assets,
notes_file=None,
)
by_description = {step.description: step.argv for step in steps}
assert by_description["create tag"] == ["git", "tag", "v1.2.3"]
assert by_description["push branch"] == ["git", "push", "origin", "main"]
assert by_description["push tag"] == ["git", "push", "origin", "v1.2.3"]
release_argv = by_description["create GitHub Release with assets"]
assert release_argv[:2] == ["gh", "release"]
assert "v1.2.3" in release_argv
assert "--verify-tag" in release_argv
assert "--latest" in release_argv
assert release_argv[-2:] == [str(asset) for asset in assets]
def test_github_steps_include_notes_file(tmp_path):
notes = tmp_path / "notes.md"
notes.write_text("hello", encoding="utf-8")
steps = github.build_github_steps(
version="1.2.3", branch="main", tag="v1.2.3", assets=[], notes_file=notes
)
release_argv = next(
step.argv for step in steps if step.description.startswith("create GitHub Release")
)
assert "--notes-file" in release_argv
assert str(notes) in release_argv
def test_release_notes_list_every_wheel():
notes = github.build_release_notes(
"1.2.3", [Path("a.whl"), Path("b.whl")], "c" * 40
)
assert "a.whl" in notes and "b.whl" in notes
assert "cccccccccccc" in notes
def test_manifest_scope_excludes_internal_docs():
"""The public repo must not receive our internal process documents."""
entry = github.manifest_entry()
docs_entry = next(item for item in entry["sync"] if item["from"] == "docs")
assert set(docs_entry["include"]) == {"get_started.md", "api.md"}
assert "publishing.md" not in docs_entry["include"]
assert "pypi_readme.md" not in docs_entry["include"]
def test_manifest_scope_covers_examples_and_sensor_json_only():
entry = github.manifest_entry()
examples_entry = next(item for item in entry["sync"] if item["from"] == "examples")
# Whole tree (no include list), with caches excluded.
assert "include" not in examples_entry
assert set(examples_entry["exclude"]) >= {"__pycache__", "*.pyc"}
sensors_entry = next(item for item in entry["sync"] if item["from"] == "sensors")
assert sensors_entry["include"]
assert all(
name.endswith(".json") for name in sensors_entry["include"]
), "binary sensor assets belong in the wheel, not the public repo"
def test_manifest_checks_use_the_python_placeholder():
entry = github.manifest_entry()
for command in entry["checks"]:
assert command.startswith("{python} "), command
# --- pypi -------------------------------------------------------------------
def test_twine_upload_argv_targets_the_repository():
argv = pypi.twine_upload_argv([Path("a.whl")], "testpypi")
assert argv[:3] == [sys.executable, "-m", "twine"]
assert "--repository" in argv and "testpypi" in argv
assert argv[-1] == "a.whl"
def test_twine_upload_argv_defaults_to_pypi():
argv = pypi.twine_upload_argv([Path("a.whl")], None)
assert "--repository" not in argv
def test_credentials_hint_reports_missing(monkeypatch, tmp_path):
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
hint = pypi.credentials_hint()
assert hint is not None
assert "TWINE_PASSWORD" in hint
def test_credentials_hint_accepts_env(monkeypatch):
monkeypatch.setenv("TWINE_USERNAME", "__token__")
monkeypatch.setenv("TWINE_PASSWORD", "pypi-secret")
assert pypi.credentials_hint() is None
def test_credentials_hint_accepts_pypirc(monkeypatch, tmp_path):
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
(tmp_path / ".pypirc").write_text("[pypi]\n", encoding="utf-8")
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
assert pypi.credentials_hint() is None
def test_index_state_parses_versions(monkeypatch):
class Response:
def __init__(self, payload):
self._payload = json.dumps(payload).encode()
def read(self, *args):
return self._payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(
pypi.urllib.request,
"urlopen",
lambda *a, **k: Response({"releases": {"1.2.3": [{"filename": "a"}], "0.0.0": []}}),
)
state = pypi.index_state("pypi")
assert state.known
assert state.versions == ["1.2.3"]
def test_index_state_reports_lookup_failure(monkeypatch):
def _raise(*args, **kwargs):
raise urllib.error.URLError("offline")
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
state = pypi.index_state("pypi")
assert not state.known
assert "offline" in state.error
def test_index_files_returns_none_when_version_absent(monkeypatch):
class Response:
def read(self, *args):
return json.dumps({"releases": {}}).encode()
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
assert pypi.index_files("pypi", "1.2.3") is None
def test_index_json_urls_cover_both_indexes():
assert pypi.INDEX_JSON["testpypi"].startswith("https://test.pypi.org/")
assert pypi.INDEX_JSON["pypi"].startswith("https://pypi.org/")