readcon-core 0.19.0

An oxidized single and multiple CON file reader and writer with FFI bindings for ergonomic C/C++ usage. Implements CON spec v3.
Documentation
#!/usr/bin/env python3
"""Refuse a tag publish whose tree, manifest, or filenames disagree.

The v0.18.0 GitHub Release received 38 wheels named 0.17.3. Those names
came from Python wheels built while the tag pointed at a tree whose
package version was still 0.17.3. cargo-dist archives do not put the
version in the filename; their version is `announcement_tag` and
`app_version` in dist-manifest.json. This check requires VERSION, the
git tag, those manifest fields, and every versioned filename to be one
version. An unversioned dist archive is accepted only when the manifest
lists it and that version matches.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import tomllib
from pathlib import Path

VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
# A version prefix is not enough. A wrap whose name contains the version is
# still a build product, not a release asset.
RELEASE_ENDINGS = (".tar.gz.sha256", ".tar.gz", ".whl")
ARTIFACTS_NAME = re.compile(r"^Artifacts-([A-Za-z0-9_]+(?:-[A-Za-z0-9_]+)+)\.toml$")
PREFIXES = (
    "readcon-core-clib-",
    "readcon-core-cxx-",
    "readcon_chemfiles-",
    "readcon-",
)
DIST_BINARY = re.compile(
    r"^readcon-core-(?:aarch64|x86_64)-.+\.(?:tar\.xz|zip)(?:\.sha256)?$"
)
UNVERSIONED = {
    "source.tar.gz",
    "source.tar.gz.sha256",
    "sha256.sum",
    "dist-manifest.json",
    "plan-dist-manifest.json",
}


def tag_version(tag: str) -> str:
    if tag.startswith("refs/tags/"):
        tag = tag[len("refs/tags/") :]
    if not tag.startswith("v") or VERSION_RE.fullmatch(tag[1:]) is None:
        raise SystemExit(f"tag {tag!r} is not vX.Y.Z")
    return tag[1:]


def versioned_name(name: str) -> str | None:
    for prefix in PREFIXES:
        if not name.startswith(prefix):
            continue
        rest = name[len(prefix) :]
        match = VERSION_RE.match(rest)
        if match is None:
            return None
        nxt = rest[match.end() : match.end() + 1]
        if nxt not in {"-", "."}:
            return None
        if not name.endswith(RELEASE_ENDINGS):
            return None
        return match.group(0)
    return None


def is_unversioned_dist(name: str) -> bool:
    return name in UNVERSIONED or DIST_BINARY.match(name) is not None


def manifest_names(path: Path, want: str) -> set[str]:
    data = json.loads(path.read_text(encoding="utf-8"))
    tag = data.get("announcement_tag")
    if tag != f"v{want}":
        raise SystemExit(f"{path.name} announcement_tag is {tag}, tag asks for v{want}")
    names: set[str] = set()
    for release in data.get("releases") or []:
        found = release.get("app_version")
        if found != want:
            raise SystemExit(f"{path.name} app_version is {found}, tag asks for {want}")
        names.update(release.get("artifacts") or [])
    names.update((data.get("artifacts") or {}).keys())
    return names


def check_julia_artifact(path: Path, want: str, files: list[Path]) -> None:
    """The clib job uploads one Artifacts-<target>.toml per built archive.

    The file is the Julia binding for that archive: its download URL has to
    name this tag, this target, and the clib tarball, and its sha256 has to
    be the bytes of that tarball. Any other toml stays unrecognized.
    """
    match = ARTIFACTS_NAME.fullmatch(path.name)
    if match is None:
        raise SystemExit(f"unrecognized release artifact name {path.name}")
    target = match.group(1)
    try:
        data = tomllib.loads(path.read_text(encoding="utf-8"))
    except tomllib.TOMLDecodeError as exc:
        raise SystemExit(f"{path.name} is not a Julia artifact manifest") from exc
    block = data.get("readcon_core")
    downloads = block.get("download") if isinstance(block, dict) else None
    if (
        not isinstance(downloads, list)
        or len(downloads) != 1
        or not isinstance(downloads[0], dict)
    ):
        raise SystemExit(f"{path.name} needs one readcon_core download")
    download = downloads[0]
    archive = f"readcon-core-clib-{want}-{target}.tar.gz"
    url = (
        f"https://github.com/lode-org/readcon-core/releases/download/v{want}/{archive}"
    )
    if download.get("url") != url:
        raise SystemExit(
            f"{path.name} url is {download.get('url')!r}, tag asks for {url}"
        )
    sha = download.get("sha256")
    if not isinstance(sha, str) or re.fullmatch(r"[0-9a-f]{64}", sha) is None:
        raise SystemExit(f"{path.name} sha256 is not 64 hex characters")
    tree = block.get("git-tree-sha1")
    if not isinstance(tree, str) or re.fullmatch(r"[0-9a-f]{40}", tree) is None:
        raise SystemExit(f"{path.name} git-tree-sha1 is not 40 hex characters")
    by_name = {item.name: item for item in files}
    archive_path = by_name.get(archive)
    sidecar = by_name.get(f"{archive}.sha256")
    if archive_path is None or sidecar is None:
        raise SystemExit(f"{path.name} does not sit next to {archive}")
    digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
    sidecar_hash = sidecar.read_text(encoding="utf-8").split()[0]
    if sha != digest or sidecar_hash != digest:
        raise SystemExit(f"{path.name} sha256 does not match {archive}")


def iter_files(directories: list[str]) -> list[Path]:
    files: list[Path] = []
    for directory in directories:
        root = Path(directory)
        if not root.is_dir():
            raise SystemExit(f"missing artifact directory {directory}")
        files.extend(path for path in sorted(root.iterdir()) if path.is_file())
    return files


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--tag", required=True)
    parser.add_argument("--version-file", default="VERSION")
    parser.add_argument("--dist", action="append", default=[])
    parser.add_argument("--manifest", action="append", default=[])
    args = parser.parse_args()
    want = tag_version(args.tag)
    got = Path(args.version_file).read_text(encoding="utf-8").strip()
    if got != want:
        raise SystemExit(f"VERSION is {got}, tag asks for {want}")
    files = iter_files(args.dist)
    manifests = [Path(item) for item in args.manifest]
    manifests.extend(path for path in files if path.name.endswith("dist-manifest.json"))
    listed: set[str] = set()
    for manifest in manifests:
        if not manifest.is_file():
            raise SystemExit(f"missing manifest {manifest}")
        listed.update(manifest_names(manifest, want))
    if args.dist and not files:
        raise SystemExit("no release artifacts found")
    for path in files:
        version = versioned_name(path.name)
        if version is not None:
            if version != want:
                raise SystemExit(
                    f"{path.name} is version {version}, tag asks for {want}"
                )
            print(f"OK {path.name}")
            continue
        if is_unversioned_dist(path.name):
            if not manifests:
                raise SystemExit(
                    f"{path.name} has no version in its name and no dist manifest"
                )
            if path.name not in listed and path.name not in UNVERSIONED:
                raise SystemExit(f"{path.name} is not listed in the dist manifest")
            print(f"OK {path.name} via dist manifest v{want}")
            continue
        if ARTIFACTS_NAME.fullmatch(path.name):
            check_julia_artifact(path, want, files)
            print(f"OK {path.name}")
            continue
        raise SystemExit(f"unrecognized release artifact name {path.name}")
    print(f"OK VERSION {got} tag v{want} files {len(files)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())