from __future__ import annotations
import hashlib
import importlib.util
import re
import sys
import tarfile
import tomllib
import typing as typ
from pathlib import Path
import pytest
import yaml
if typ.TYPE_CHECKING:
from collections.abc import Iterator, Mapping
REPO_ROOT = Path(__file__).resolve().parents[2]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
CI_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml"
MANIFEST = REPO_ROOT / "Cargo.toml"
SCRIPT_PATH = REPO_ROOT / "scripts" / "release_archive.py"
sys.path.insert(0, str(SCRIPT_PATH.parent))
SPEC = importlib.util.spec_from_file_location("release_archive", SCRIPT_PATH)
assert SPEC is not None, f"cannot build an import spec for {SCRIPT_PATH}"
release_archive = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None, f"import spec for {SCRIPT_PATH} has no loader"
sys.modules[SPEC.name] = release_archive
SPEC.loader.exec_module(release_archive)
RELEASE_WRITER_JOBS = (
"create-release",
"build-assets",
"audit-draft-assets",
"publish-release",
)
def load_workflow(path: Path) -> Mapping[str, typ.Any]:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def workflow_job(path: Path, name: str) -> Mapping[str, typ.Any]:
jobs = load_workflow(path)["jobs"]
assert name in jobs, f"{path.name} has no job named {name}"
return jobs[name]
def iter_jobs(path: Path) -> Iterator[tuple[str, Mapping[str, typ.Any]]]:
yield from load_workflow(path)["jobs"].items()
def job_checks_out(job: Mapping[str, typ.Any]) -> bool:
return any(
str(step.get("uses", "")).startswith("actions/checkout@")
for step in job.get("steps", ())
)
GH_INVOCATION = re.compile(r"(?:^|[\s;&|(])gh\s")
def step_runs_gh(step: Mapping[str, typ.Any]) -> bool:
return GH_INVOCATION.search(str(step.get("run", ""))) is not None
def step_sets_gh_repo(job: Mapping[str, typ.Any], step: Mapping[str, typ.Any]) -> bool:
return "GH_REPO" in (job.get("env") or {}) or "GH_REPO" in (step.get("env") or {})
def binstall_metadata() -> Mapping[str, str]:
manifest = tomllib.loads(MANIFEST.read_text(encoding="utf-8"))
return manifest["package"]["metadata"]["binstall"]
def production_binaries() -> tuple[str, ...]:
manifest = tomllib.loads(MANIFEST.read_text(encoding="utf-8"))
return tuple(
entry["name"] for entry in manifest["bin"] if "required-features" not in entry
)
def render_binstall_template(template: str, values: Mapping[str, str]) -> str:
rendered = template
for key, value in values.items():
rendered = rendered.replace(f"{{ {key} }}", value)
assert "{" not in rendered, f"unsubstituted placeholder in {rendered!r}"
return rendered
def build_archive(repo: Path, target: str, version: str) -> Path:
binaries = release_archive.DEFAULT_BINARIES
extension = release_archive.binary_extension(target)
release_dir = repo / "target" / target / "release"
release_dir.mkdir(parents=True)
for binary in binaries:
(release_dir / f"{binary}{extension}").write_text(binary, encoding="utf-8")
return release_archive.stage_archive(
release_archive.ReleaseArchiveSpec(
repo=repo,
target=target,
version=version,
dist_dir=repo / "dist",
binaries=binaries,
)
)
@pytest.fixture(name="version")
def version_fixture() -> str:
return "9.9.9"
def unresolved_gh_steps(workflow: Path) -> Iterator[str]:
for job_name, job in iter_jobs(workflow):
if not job_checks_out(job):
yield from _unresolved_gh_steps_in_job(job_name, job)
def _unresolved_gh_steps_in_job(
job_name: str, job: Mapping[str, typ.Any]
) -> Iterator[str]:
for step in job.get("steps", ()):
if step_runs_gh(step) and not step_sets_gh_repo(job, step):
yield f"job {job_name} step {step.get('name', '<unnamed>')!r}"
@pytest.mark.parametrize("workflow", [RELEASE_WORKFLOW, CI_WORKFLOW])
def test_gh_steps_resolve_the_repository(workflow: Path) -> None:
unresolved = list(unresolved_gh_steps(workflow))
assert not unresolved, (
f"{workflow.name} runs gh without a checkout or GH_REPO in: "
+ "; ".join(unresolved)
)
def test_create_release_avoids_verify_tag_without_a_checkout() -> None:
job = workflow_job(RELEASE_WORKFLOW, "create-release")
scripts = "\n".join(str(step.get("run", "")) for step in job["steps"])
if job_checks_out(job):
pytest.skip("job has a checkout, so --verify-tag can resolve the tag")
assert "--verify-tag" not in scripts, (
"create-release has no checkout, so --verify-tag cannot resolve the tag"
)
assert "git/ref/tags" in scripts, "tag existence must still be checked"
@pytest.mark.parametrize("job_name", RELEASE_WRITER_JOBS)
def test_release_jobs_request_contents_write(job_name: str) -> None:
job = workflow_job(RELEASE_WORKFLOW, job_name)
assert job.get("permissions", {}).get("contents") == "write", (
f"job {job_name} must request contents: write, found "
f"{job.get('permissions', {}).get('contents')!r}"
)
def test_upload_step_publishes_archives_and_sidecars() -> None:
job = workflow_job(RELEASE_WORKFLOW, "build-assets")
uploads = [
step for step in job["steps"] if "gh release upload" in str(step.get("run", ""))
]
assert len(uploads) == 1, "expected exactly one release upload step"
run = str(uploads[0]["run"])
assert "dist/*.tgz" in run, f"the upload step publishes no archive:\n{run}"
assert "dist/*.tgz.sha256" in run, (
f"the upload step publishes no checksum sidecar:\n{run}"
)
def test_sidecars_exist_before_the_upload_step() -> None:
steps = workflow_job(RELEASE_WORKFLOW, "build-assets")["steps"]
runs = [str(step.get("run", "")) for step in steps]
writes_sidecar = [
index for index, run in enumerate(runs) if ".sha256" in run and ">" in run
]
uploads = [index for index, run in enumerate(runs) if "gh release upload" in run]
assert writes_sidecar, "no step guarantees a .sha256 sidecar"
assert uploads, "no step uploads release assets"
assert min(writes_sidecar) < min(uploads), (
"the sidecar must be guaranteed before the upload step, not after"
)
def test_default_binaries_match_the_manifest_production_binaries() -> None:
assert set(release_archive.DEFAULT_BINARIES) == set(production_binaries()), (
f"packaged binaries {release_archive.DEFAULT_BINARIES} drifted from the "
f"ungated manifest binaries {production_binaries()}"
)
@pytest.mark.parametrize(
"target",
[
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
],
)
def test_archive_layout_matches_binstall_metadata(
tmp_path: Path, version: str, target: str
) -> None:
metadata = binstall_metadata()
manifest = tomllib.loads(MANIFEST.read_text(encoding="utf-8"))
values = {
"repo": manifest["package"]["repository"],
"name": manifest["package"]["name"],
"target": target,
"version": version,
"archive-suffix": f".{metadata['pkg-fmt']}",
"binary-ext": release_archive.binary_extension(target),
}
archive = build_archive(tmp_path, target, version)
expected_url = render_binstall_template(metadata["pkg-url"], values)
assert archive.name == expected_url.rsplit("/", 1)[-1], (
f"archive {archive.name!r} does not match the rendered pkg-url "
f"{expected_url!r}"
)
with tarfile.open(archive, "r:gz") as tar:
members = {member.name for member in tar.getmembers() if member.isfile()}
expected_members = {
render_binstall_template(metadata["bin-dir"], {**values, "bin": binary})
for binary in release_archive.DEFAULT_BINARIES
}
assert members == expected_members, (
f"archive members {sorted(members)} do not match the rendered bin-dir "
f"template {sorted(expected_members)}"
)
@pytest.mark.parametrize(
"target", ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
)
def test_archive_sidecar_records_the_archive_digest(
tmp_path: Path, version: str, target: str
) -> None:
archive = build_archive(tmp_path, target, version)
sidecar = release_archive.checksum_sidecar_path(archive)
assert sidecar.name == f"{archive.name}.sha256", (
f"sidecar {sidecar.name!r} must be named after {archive.name!r}"
)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
assert sidecar.read_bytes() == f"{digest} {archive.name}\n".encode("ascii"), (
f"sidecar contents {sidecar.read_bytes()!r} are not a sha256sum line "
"with a Unix newline"
)