510 lines
19 KiB
Python
510 lines
19 KiB
Python
"""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 subprocess
|
|
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_github_steps_resume_skips_tag_creation(tmp_path):
|
|
"""A tag pushed by an earlier failed run must not block the release upload."""
|
|
assets = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
steps = github.build_github_steps(
|
|
version="1.2.3",
|
|
branch="main",
|
|
tag="v1.2.3",
|
|
assets=assets,
|
|
notes_file=None,
|
|
create_tag=False,
|
|
)
|
|
descriptions = [step.description for step in steps]
|
|
assert "create tag" not in descriptions
|
|
assert descriptions[-1] == "create GitHub Release with assets"
|
|
# The pushes stay: pushing an up-to-date ref is a safe no-op.
|
|
assert "push branch" in descriptions and "push tag" in descriptions
|
|
|
|
|
|
def test_non_github_remote_is_reported(tmp_path):
|
|
repo = tmp_path / "release"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
|
|
subprocess.run(
|
|
["git", "remote", "add", "origin", "ssh://git@internal.example:24928/orisys-tech/orisys-sdk.git"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
problem = github.non_github_remote_problem(repo)
|
|
assert problem is not None and "not a GitHub host" in problem
|
|
|
|
subprocess.run(
|
|
["git", "remote", "set-url", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
assert github.non_github_remote_problem(repo) is None
|
|
|
|
|
|
def _synthetic_publish_layout(tmp_path: Path, version: str, *, tag_exists: bool):
|
|
"""Build a fake SDK checkout + release repo and point the module at them."""
|
|
sdk = tmp_path / "sdk"
|
|
wheel_dir = sdk / "SDK" / version
|
|
wheel_dir.mkdir(parents=True)
|
|
wheels = [wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"]
|
|
wheels[0].write_bytes(b"wheel")
|
|
|
|
report_dir = sdk / "build" / "release" / "reports" / version
|
|
report_dir.mkdir(parents=True)
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload(version, wheels)), encoding="utf-8"
|
|
)
|
|
|
|
release = tmp_path / "orisys-sdk-release"
|
|
release.mkdir()
|
|
for command in (
|
|
["git", "init", "-q"],
|
|
["git", "config", "user.email", "test@example.invalid"],
|
|
["git", "config", "user.name", "test"],
|
|
["git", "remote", "add", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
|
):
|
|
subprocess.run(command, cwd=release, check=True, capture_output=True)
|
|
(release / "README.md").write_text("x", encoding="utf-8")
|
|
subprocess.run(["git", "add", "-A"], cwd=release, check=True, capture_output=True)
|
|
subprocess.run(["git", "commit", "-qm", "init"], cwd=release, check=True, capture_output=True)
|
|
if tag_exists:
|
|
subprocess.run(["git", "tag", f"v{version}"], cwd=release, check=True, capture_output=True)
|
|
|
|
manifest = sdk / "tools" / "downstream_sync_manifest.json"
|
|
manifest.parent.mkdir(parents=True)
|
|
manifest.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"downstreams": [
|
|
{"name": "orisys-sdk-release", "path": "../orisys-sdk-release", "sync": []}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return sdk, release, wheels[0]
|
|
|
|
|
|
def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch, capsys):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.3", tag_exists=True)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.3", apply=False, skip_fetch=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "already exist" in output and "skipping tag creation" in output
|
|
assert "git tag v1.2.3" not in output
|
|
assert "gh release create v1.2.3" in output
|
|
|
|
|
|
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.4", apply=False, skip_fetch=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "git tag v1.2.4" in output
|
|
|
|
|
|
def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.5", tag_exists=True)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: True)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.5", apply=False, skip_fetch=True)
|
|
)
|
|
assert exit_code == 1
|
|
assert "already exists; nothing to publish" in capsys.readouterr().out
|
|
|
|
|
|
def _synthetic_report_layout(tmp_path: Path, version: str) -> tuple[Path, Path]:
|
|
"""A fake SDK checkout with wheels and a passing preflight report."""
|
|
sdk = tmp_path / "sdk"
|
|
wheel_dir = sdk / "SDK" / version
|
|
wheel_dir.mkdir(parents=True)
|
|
wheel = wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"
|
|
wheel.write_bytes(b"wheel")
|
|
|
|
report_dir = sdk / "build" / "release" / "reports" / version
|
|
report_dir.mkdir(parents=True)
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload(version, [wheel])), encoding="utf-8"
|
|
)
|
|
return sdk, wheel
|
|
|
|
|
|
def test_upload_phases_default_is_testpypi_only():
|
|
assert pypi.upload_phases(pypi.Options(version="1.2.3")) == ["testpypi"]
|
|
|
|
|
|
def test_upload_phases_confirm_pypi_adds_the_real_index():
|
|
options = pypi.Options(version="1.2.3", confirm_pypi=True)
|
|
assert pypi.upload_phases(options) == ["testpypi", "pypi"]
|
|
|
|
|
|
def test_upload_phases_pypi_only_skips_the_rehearsal():
|
|
# The relevant case: a production token cannot be used against TestPyPI.
|
|
assert pypi.upload_phases(pypi.Options(version="1.2.3", pypi_only=True)) == ["pypi"]
|
|
|
|
|
|
def test_pypi_only_dry_run_targets_pypi_only(tmp_path, monkeypatch, capsys):
|
|
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(pypi, "index_state", lambda index, **k: pypi.IndexState(versions=[]))
|
|
from release import auth
|
|
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
|
|
exit_code = pypi.publish(
|
|
pypi.Options(version="1.2.3", apply=False, pypi_only=True, skip_check=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "--pypi-only" in output
|
|
assert "upload 1 wheel(s) to pypi" in output
|
|
assert "testpypi" not in output
|
|
|
|
|
|
def test_pypi_only_and_confirm_pypi_are_mutually_exclusive():
|
|
from release.cli import _cmd_pypi
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_cmd_pypi(["--version", "1.2.3", "--pypi-only", "--confirm-pypi"])
|
|
assert excinfo.value.code == 2
|
|
|
|
|
|
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_points_at_the_setup_command(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
hint = pypi.credentials_hint("pypi")
|
|
assert hint is not None
|
|
assert "release auth --set-token pypi" in hint
|
|
|
|
|
|
def test_credentials_hint_for_testpypi_mentions_pypi_only(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
hint = pypi.credentials_hint("testpypi")
|
|
assert hint is not None
|
|
assert "--set-token testpypi" in hint
|
|
assert "--pypi-only" in hint
|
|
|
|
|
|
def test_credentials_hint_silent_when_configured(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
path = tmp_path / ".pypirc"
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: path)
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
assert pypi.credentials_hint("pypi") 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/")
|