wavepeek 2.1.0

Command-line tool for RTL waveform inspection with deterministic machine-friendly output.
Documentation
#!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import pathlib
import re
import sys
from dataclasses import dataclass
from typing import Any, Sequence

from extract_release_notes import extract_release_notes

VERSION_RE = re.compile(r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$")
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")

TARGET_LABELS = {
    "x86_64-unknown-linux-gnu": "Linux x86_64",
    "aarch64-unknown-linux-gnu": "Linux arm64",
    "x86_64-apple-darwin": "macOS Intel",
    "aarch64-apple-darwin": "macOS Apple Silicon",
    "x86_64-pc-windows-msvc": "Windows x86_64",
}
TARGET_ORDER = [
    "x86_64-unknown-linux-gnu",
    "aarch64-unknown-linux-gnu",
    "x86_64-apple-darwin",
    "aarch64-apple-darwin",
    "x86_64-pc-windows-msvc",
]
STATIC_ARCHIVES = {
    "x86_64-unknown-linux-gnu": "wavepeek-x86_64-unknown-linux-gnu.tar.gz",
    "aarch64-unknown-linux-gnu": "wavepeek-aarch64-unknown-linux-gnu.tar.gz",
    "x86_64-apple-darwin": "wavepeek-x86_64-apple-darwin.tar.gz",
    "aarch64-apple-darwin": "wavepeek-aarch64-apple-darwin.tar.gz",
    "x86_64-pc-windows-msvc": "wavepeek-x86_64-pc-windows-msvc.zip",
}


@dataclass(frozen=True)
class DownloadArtifact:
    target: str
    platform: str
    archive: str
    checksum: str | None


def fail(message: str) -> None:
    print(f"error: release-body: {message}", file=sys.stderr)
    raise SystemExit(1)


def validate_version(version: str) -> str:
    if VERSION_RE.fullmatch(version) is None:
        fail(f"version must be stable SemVer X.Y.Z, got {version!r}")
    return version


def validate_repository(repository: str) -> str:
    if REPOSITORY_RE.fullmatch(repository) is None:
        fail(f"repository must have owner/name form, got {repository!r}")
    return repository


def load_json(path: pathlib.Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except OSError as error:
        fail(f"failed to read dist manifest {path}: {error}")
    except json.JSONDecodeError as error:
        fail(f"dist manifest {path} is not valid JSON: {error}")
    if not isinstance(data, dict):
        fail(f"dist manifest {path} must contain a JSON object")
    return data


def artifact_target(artifact: dict[str, Any]) -> str | None:
    targets = artifact.get("target_triples")
    if not isinstance(targets, list) or len(targets) != 1:
        return None
    target = targets[0]
    return target if isinstance(target, str) else None


def artifacts_from_manifest(manifest: dict[str, Any]) -> list[DownloadArtifact]:
    raw_artifacts = manifest.get("artifacts")
    if not isinstance(raw_artifacts, dict):
        fail("dist manifest is missing an artifacts object")

    by_target: dict[str, DownloadArtifact] = {}
    for name, raw in raw_artifacts.items():
        if not isinstance(name, str) or not isinstance(raw, dict):
            fail("dist manifest artifacts must be an object of artifact objects")
        if raw.get("kind") != "executable-zip":
            continue
        target = artifact_target(raw)
        if target not in TARGET_LABELS:
            continue
        archive = raw.get("name", name)
        checksum = raw.get("checksum")
        if not isinstance(archive, str) or not archive:
            fail(f"dist manifest artifact for {target} has invalid name")
        if checksum is not None and not isinstance(checksum, str):
            fail(f"dist manifest artifact {archive} has invalid checksum")
        by_target[target] = DownloadArtifact(
            target=target,
            platform=TARGET_LABELS[target],
            archive=archive,
            checksum=checksum,
        )

    missing = [target for target in TARGET_ORDER if target not in by_target]
    if missing:
        fail("dist manifest is missing executable archive(s) for: " + ", ".join(missing))
    return [by_target[target] for target in TARGET_ORDER]


def static_artifacts() -> list[DownloadArtifact]:
    return [
        DownloadArtifact(
            target=target,
            platform=TARGET_LABELS[target],
            archive=STATIC_ARCHIVES[target],
            checksum=f"{STATIC_ARCHIVES[target]}.sha256",
        )
        for target in TARGET_ORDER
    ]


def checksum_assets(manifest: dict[str, Any] | None) -> tuple[bool, list[str]]:
    if manifest is None:
        return True, [f"{archive}.sha256" for archive in STATIC_ARCHIVES.values()]
    raw_artifacts = manifest.get("artifacts")
    if not isinstance(raw_artifacts, dict):
        fail("dist manifest is missing an artifacts object")
    has_unified = "sha256.sum" in raw_artifacts
    per_artifact = sorted(
        name
        for name, raw in raw_artifacts.items()
        if isinstance(name, str)
        and name.endswith(".sha256")
        and isinstance(raw, dict)
        and raw.get("kind") == "checksum"
    )
    if not has_unified and not per_artifact:
        fail("dist manifest does not list checksum artifacts")
    return has_unified, per_artifact


def pages_base(repository: str) -> str:
    owner, name = repository.split("/", maxsplit=1)
    return f"https://{owner.lower()}.github.io/{name}/"


def crate_status_text(crate_published: str, version: str) -> str:
    if crate_published == "true":
        return f"The crate is published at https://crates.io/crates/wavepeek/{version}."
    if crate_published == "false":
        return "The crates.io package has not been published yet; retry the downstream crate workflow."
    return "Crates.io publication is handled by a downstream workflow; check https://crates.io/crates/wavepeek for current status."


def release_asset_url(repository: str, version: str, asset: str) -> str:
    return f"https://github.com/{repository}/releases/download/v{version}/{asset}"


def render_download_table(artifacts: Sequence[DownloadArtifact], repository: str, version: str) -> list[str]:
    lines = ["| Platform | Archive | Checksum |", "| --- | --- | --- |"]
    for artifact in artifacts:
        archive_url = release_asset_url(repository, version, artifact.archive)
        if artifact.checksum:
            checksum_name = artifact.checksum
        else:
            checksum_name = "sha256.sum"
        checksum_url = release_asset_url(repository, version, checksum_name)
        lines.append(
            f"| {artifact.platform} | [`{artifact.archive}`]({archive_url}) | "
            f"[`{checksum_name}`]({checksum_url}) |"
        )
    return lines


def render_release_body(
    *,
    version: str,
    repository: str,
    changelog_text: str,
    manifest: dict[str, Any] | None,
    crate_published: str,
) -> str:
    version = validate_version(version)
    repository = validate_repository(repository)
    notes = extract_release_notes(changelog_text, version).strip()
    artifacts = artifacts_from_manifest(manifest) if manifest is not None else static_artifacts()
    has_unified_checksum, per_artifact_checksums = checksum_assets(manifest)
    docs_root = pages_base(repository)
    docs_version = f"{docs_root}{version}/"

    shell_url = release_asset_url(repository, version, "wavepeek-installer.sh")
    powershell_url = release_asset_url(repository, version, "wavepeek-installer.ps1")
    sample_archive = artifacts[0].archive

    lines: list[str] = []
    lines.extend(["## Release Notes", "", notes, ""])
    lines.extend(
        [
            f"## Install wavepeek {version}",
            "",
            "macOS and Linux:",
            "",
            "    curl --proto '=https' --tlsv1.2 -LsSf " + shell_url + " | sh",
            "",
            "Windows PowerShell:",
            "",
            "    powershell -ExecutionPolicy Bypass -c \"irm " + powershell_url + " | iex\"",
            "",
            "The installer places `wavepeek` under the configured XDG-style user binary path and does not require a Rust toolchain.",
            "",
            f"## Download wavepeek {version}",
            "",
        ]
    )
    lines.extend(render_download_table(artifacts, repository, version))
    lines.extend(
        [
            "",
            "Additional release assets include `wavepeek-installer.sh`, `wavepeek-installer.ps1`, `dist-manifest.json`, `source.tar.gz`, and checksum files generated by `cargo-dist`.",
            "",
            "## Verify checksums",
            "",
        ]
    )
    if has_unified_checksum:
        lines.extend(
            [
                "Download `sha256.sum` and the artifact you want, then run:",
                "",
                "    sha256sum --check sha256.sum --ignore-missing",
                "",
            ]
        )
    if per_artifact_checksums:
        lines.extend(
            [
                "Per-artifact checksum files are also available. For example:",
                "",
                f"    sha256sum --check {sample_archive}.sha256",
                "",
            ]
        )
    lines.extend(
        [
            "## Verify GitHub Artifact Attestations",
            "",
            "Download an artifact and verify its GitHub attestation with:",
            "",
            f"    gh attestation verify {sample_archive} --repo {repository}",
            "",
            "## FSDB support",
            "",
            "Prebuilt binaries are VCD/FST-only. FSDB support remains source-only and requires a Linux x86_64 host with a valid Synopsys Verdi / FSDB Reader SDK environment:",
            "",
            f"    cargo install --locked wavepeek --version {version} --features fsdb",
            "",
            "## Cargo fallback",
            "",
            "If a prebuilt artifact is not suitable for your platform, install from crates.io with Rust:",
            "",
            f"    cargo install --locked wavepeek --version {version}",
            "",
            "## Documentation",
            "",
            f"Versioned documentation: {docs_version}",
            "",
            f"Latest documentation and installer aliases: {docs_root}",
            "",
            "## crates.io",
            "",
            crate_status_text(crate_published, version),
            "",
        ]
    )
    return "\n".join(lines)


def parse_args(argv: Sequence[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Render the GitHub Release body for wavepeek.")
    parser.add_argument("--version", required=True, help="Release version without leading v.")
    parser.add_argument("--repository", required=True, help="GitHub repository owner/name.")
    parser.add_argument("--changelog", type=pathlib.Path, default=pathlib.Path("CHANGELOG.md"))
    parser.add_argument("--dist-manifest", type=pathlib.Path)
    parser.add_argument(
        "--crate-published",
        choices=("true", "false", "unknown"),
        default="unknown",
        help="crates.io publication status to render.",
    )
    return parser.parse_args(list(argv))


def main(argv: Sequence[str] | None = None) -> int:
    args = parse_args(sys.argv[1:] if argv is None else argv)
    try:
        try:
            changelog_text = args.changelog.read_text(encoding="utf-8")
        except OSError as error:
            fail(f"failed to read changelog {args.changelog}: {error}")
        manifest = load_json(args.dist_manifest) if args.dist_manifest else None
        sys.stdout.write(
            render_release_body(
                version=args.version,
                repository=args.repository,
                changelog_text=changelog_text,
                manifest=manifest,
                crate_published=args.crate_published,
            )
        )
    except SystemExit as error:
        return int(error.code) if isinstance(error.code, int) else 1
    return 0


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