Files

347 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build, strip, verify, and package a native Moli release artifact."""
from __future__ import annotations
import argparse
import os
import re
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
import zipfile
from pathlib import Path
from typing import Sequence
REPO_ROOT = Path(__file__).resolve().parent.parent
MANIFEST_PATH = REPO_ROOT / "moli" / "Cargo.toml"
SEMVER_PATTERN = re.compile(
r"^(0|[1-9][0-9]*)\."
r"(0|[1-9][0-9]*)\."
r"(0|[1-9][0-9]*)"
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
TARGET_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
class ReleaseError(RuntimeError):
"""A release precondition or command failed."""
def display_command(command: Sequence[str]) -> str:
return shlex.join(str(part) for part in command)
def run_checked(
command: Sequence[str], *, capture_output: bool = False
) -> subprocess.CompletedProcess[str]:
rendered = display_command(command)
print(f"+ {rendered}", flush=True)
try:
return subprocess.run(
[str(part) for part in command],
cwd=REPO_ROOT,
check=True,
text=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
)
except FileNotFoundError as error:
raise ReleaseError(f"command not found: {command[0]}") from error
except subprocess.CalledProcessError as error:
if error.stdout:
print(error.stdout, end="", file=sys.stderr)
if error.stderr:
print(error.stderr, end="", file=sys.stderr)
raise ReleaseError(
f"command failed with exit code {error.returncode}: {rendered}"
) from error
def normalize_version(raw_version: str) -> str:
version = raw_version.removeprefix("v")
if not SEMVER_PATTERN.fullmatch(version):
raise ReleaseError(f"invalid semantic version: {raw_version}")
return version
def manifest_version() -> str:
with MANIFEST_PATH.open("rb") as manifest_file:
manifest = tomllib.load(manifest_file)
try:
value = manifest["package"]["version"]
except (KeyError, TypeError) as error:
raise ReleaseError(f"package.version is missing from {MANIFEST_PATH}") from error
if not isinstance(value, str):
raise ReleaseError(f"package.version in {MANIFEST_PATH} is not a string")
return value
def rust_host_target() -> str:
output = run_checked(["rustc", "-vV"], capture_output=True).stdout
for line in output.splitlines():
if line.startswith("host: "):
target = line.removeprefix("host: ").strip()
if not TARGET_PATTERN.fullmatch(target):
break
return target
raise ReleaseError("could not determine the Rust host target from `rustc -vV`")
def resolve_repo_path(raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = REPO_ROOT / path
return path.resolve()
def default_binary_path(target: str) -> Path:
target_dir = resolve_repo_path(os.environ.get("CARGO_TARGET_DIR", "target"))
suffix = ".exe" if "-windows-" in target else ""
return target_dir / "release" / f"moli{suffix}"
def build_release() -> None:
command = ["cargo", "build", "--locked", "--release", "--package", "moli"]
run_checked(command)
def find_strip_tool(target: str) -> str:
configured = os.environ.get("STRIP")
candidates = [configured] if configured else []
if "-windows-" in target:
candidates.append("llvm-strip")
if program_files := os.environ.get("ProgramFiles"):
llvm_strip = Path(program_files) / "LLVM" / "bin" / "llvm-strip.exe"
candidates.append(str(llvm_strip))
candidates.append("strip")
else:
candidates.append("strip")
for candidate in candidates:
if not candidate:
continue
resolved = shutil.which(candidate)
if resolved:
return resolved
candidate_path = Path(candidate)
if candidate_path.is_file():
return str(candidate_path.resolve())
names = ", ".join(candidates)
raise ReleaseError(f"no compatible strip tool found (tried: {names})")
def strip_and_sign(binary: Path, target: str) -> tuple[int, int]:
before = binary.stat().st_size
run_checked([find_strip_tool(target), str(binary)])
if target.endswith("-apple-darwin"):
# Stripping changes a signed Mach-O file, so replace the invalidated
# signature with an ad-hoc signature before verification and packaging.
run_checked(["codesign", "--force", "--sign", "-", str(binary)])
run_checked(["codesign", "--verify", "--strict", str(binary)])
after = binary.stat().st_size
return before, after
def binary_reported_version(binary: Path) -> str:
output = run_checked([str(binary), "--version"], capture_output=True).stdout.strip()
program, separator, version = output.partition(" ")
if program != "moli" or separator != " " or not version or " " in version:
raise ReleaseError(
"unexpected output from packaged binary `--version`: "
f"{output or '<empty>'}"
)
return version
def copy_release_materials(package_dir: Path) -> None:
files = [
REPO_ROOT / "README.md",
REPO_ROOT / "docs" / "RELEASING.md",
REPO_ROOT / "LICENSE-APACHE",
REPO_ROOT / "LICENSE-MIT",
REPO_ROOT / "license-metadata.json",
]
for source in files:
if not source.is_file():
raise ReleaseError(f"required release file is missing: {source}")
shutil.copy2(source, package_dir / source.name)
licenses_dir = REPO_ROOT / "licenses"
if not licenses_dir.is_dir():
raise ReleaseError(f"required license directory is missing: {licenses_dir}")
shutil.copytree(licenses_dir, package_dir / licenses_dir.name)
def write_archive(
package_dir: Path, archive_path: Path, package_name: str, target: str
) -> None:
if "-windows-" in target:
with zipfile.ZipFile(
archive_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for source in sorted(package_dir.rglob("*")):
if source.is_file():
relative = source.relative_to(package_dir).as_posix()
archive.write(source, arcname=f"{package_name}/{relative}")
return
with tarfile.open(
archive_path,
mode="w:gz",
format=tarfile.PAX_FORMAT,
compresslevel=9,
) as archive:
archive.add(package_dir, arcname=package_name, recursive=True)
def verify_archive(archive_path: Path, expected_binary: str, target: str) -> None:
if "-windows-" in target:
with zipfile.ZipFile(archive_path, mode="r") as archive:
bad_member = archive.testzip()
if bad_member:
raise ReleaseError(
f"archive verification failed for ZIP member: {bad_member}"
)
members = set(archive.namelist())
else:
with tarfile.open(archive_path, mode="r:gz") as archive:
members = set(archive.getnames())
if expected_binary not in members:
raise ReleaseError(
f"archive verification failed: {expected_binary} is not in {archive_path.name}"
)
def package_release(
*, version: str, target: str, binary: Path, output_dir: Path
) -> tuple[Path, int, int]:
package_name = f"moli-v{version}-{target}"
extension = ".zip" if "-windows-" in target else ".tar.gz"
archive_path = output_dir / f"moli-{target}{extension}"
output_dir.mkdir(parents=True, exist_ok=True)
if archive_path.exists():
raise ReleaseError(f"release output already exists: {archive_path}")
with tempfile.TemporaryDirectory(prefix=".moli-release-", dir=output_dir) as raw:
staging_dir = Path(raw)
package_dir = staging_dir / package_name
package_dir.mkdir()
packaged_binary_name = "moli.exe" if "-windows-" in target else "moli"
packaged_binary = package_dir / packaged_binary_name
shutil.copy2(binary, packaged_binary)
if "-windows-" not in target:
packaged_binary.chmod(packaged_binary.stat().st_mode | 0o111)
original_size, stripped_size = strip_and_sign(packaged_binary, target)
reported_version = binary_reported_version(packaged_binary)
if reported_version != version:
raise ReleaseError(
"packaged binary version does not match the requested version: "
f"expected {version}, got {reported_version or '<empty>'}"
)
copy_release_materials(package_dir)
(package_dir / "VERSION").write_text(f"{version}\n", encoding="utf-8")
staged_archive = staging_dir / archive_path.name
write_archive(package_dir, staged_archive, package_name, target)
verify_archive(
staged_archive,
f"{package_name}/{packaged_binary_name}",
target,
)
staged_archive.rename(archive_path)
return archive_path, original_size, stripped_size
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True, help="release version, with optional v")
parser.add_argument(
"--output-dir",
default="dist",
help="artifact output directory relative to the repository (default: dist)",
)
parser.add_argument(
"--binary",
help="prebuilt binary path (default: target/release/moli[.exe])",
)
parser.add_argument(
"--skip-build",
action="store_true",
help="package an existing binary without running cargo build",
)
parser.add_argument(
"--expected-target",
help="fail unless the native rustc host target matches this value",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
version = normalize_version(args.version)
declared_version = manifest_version()
if version != declared_version:
raise ReleaseError(
f"requested version {version} does not match "
f"moli/Cargo.toml ({declared_version})"
)
target = rust_host_target()
if args.expected_target and target != args.expected_target:
raise ReleaseError(
f"expected Rust host target {args.expected_target}, got {target}"
)
if not args.skip_build:
build_release()
binary = (
resolve_repo_path(args.binary)
if args.binary
else default_binary_path(target)
)
if not binary.is_file():
raise ReleaseError(f"release binary not found: {binary}")
output_dir = resolve_repo_path(args.output_dir)
archive, before, after = package_release(
version=version,
target=target,
binary=binary,
output_dir=output_dir,
)
print(f"Packaged target: {target}")
print(f"Staged binary: {before:,} -> {after:,} bytes after strip")
print(f"Created: {archive}")
return 0
except ReleaseError as error:
print(f"release error: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())