|
| 1 | +import argparse |
| 2 | +import hashlib |
| 3 | +import os |
| 4 | +import re |
| 5 | +import subprocess |
| 6 | +import tarfile |
| 7 | +import tempfile |
| 8 | +import zipfile |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") |
| 12 | +REQUIRED_FILES = {"VERSION", "README.md", "requirements.txt", "LICENSE"} |
| 13 | + |
| 14 | + |
| 15 | +def git_output(repository, *args): |
| 16 | + return subprocess.run( |
| 17 | + ["git", "-C", str(repository), *args], |
| 18 | + check=True, |
| 19 | + capture_output=True, |
| 20 | + text=True, |
| 21 | + ).stdout |
| 22 | + |
| 23 | + |
| 24 | +def sha256(path): |
| 25 | + digest = hashlib.sha256() |
| 26 | + with path.open("rb") as stream: |
| 27 | + for chunk in iter(lambda: stream.read(1024 * 1024), b""): |
| 28 | + digest.update(chunk) |
| 29 | + return digest.hexdigest() |
| 30 | + |
| 31 | + |
| 32 | +def resolve_commit(repository, ref): |
| 33 | + try: |
| 34 | + return git_output( |
| 35 | + repository, "rev-parse", "--verify", f"{ref}^{{commit}}" |
| 36 | + ).strip() |
| 37 | + except subprocess.CalledProcessError as error: |
| 38 | + raise RuntimeError("release ref must resolve to a commit") from error |
| 39 | + |
| 40 | + |
| 41 | +def archive_members(path): |
| 42 | + if path.suffix == ".zip": |
| 43 | + with zipfile.ZipFile(path) as archive: |
| 44 | + return {name for name in archive.namelist() if not name.endswith("/")} |
| 45 | + with tarfile.open(path, "r:gz") as archive: |
| 46 | + return {member.name for member in archive.getmembers() if member.isfile()} |
| 47 | + |
| 48 | + |
| 49 | +def validate_members(members, prefix): |
| 50 | + if not members or any(not member.startswith(prefix) for member in members): |
| 51 | + raise RuntimeError(f"release archive contains a path outside {prefix}") |
| 52 | + |
| 53 | + relative_members = {member.removeprefix(prefix) for member in members} |
| 54 | + missing = REQUIRED_FILES - relative_members |
| 55 | + if missing: |
| 56 | + raise RuntimeError("release archive is missing: " + ", ".join(sorted(missing))) |
| 57 | + |
| 58 | + unsafe = [ |
| 59 | + member |
| 60 | + for member in relative_members |
| 61 | + if ".git" in Path(member).parts |
| 62 | + or "__pycache__" in Path(member).parts |
| 63 | + or member.endswith((".pyc", ".pyo")) |
| 64 | + ] |
| 65 | + if unsafe: |
| 66 | + raise RuntimeError( |
| 67 | + "release archive contains generated metadata: " + ", ".join(unsafe) |
| 68 | + ) |
| 69 | + |
| 70 | + |
| 71 | +def build_release_assets(repository, ref, output_dir): |
| 72 | + repository = repository.resolve() |
| 73 | + output_dir = output_dir.resolve() |
| 74 | + commit = resolve_commit(repository, ref) |
| 75 | + version = git_output(repository, "show", f"{commit}:VERSION").strip() |
| 76 | + if not VERSION_PATTERN.fullmatch(version): |
| 77 | + raise RuntimeError(f"VERSION must be semantic x.y.z, got {version!r}") |
| 78 | + |
| 79 | + prefix = f"FunClip-{version}/" |
| 80 | + archive_names = [f"FunClip-{version}.tar.gz", f"FunClip-{version}.zip"] |
| 81 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 82 | + |
| 83 | + with tempfile.TemporaryDirectory( |
| 84 | + prefix=".funclip-release-", dir=output_dir |
| 85 | + ) as temporary: |
| 86 | + temporary_dir = Path(temporary) |
| 87 | + temporary_archives = [] |
| 88 | + for archive_name in archive_names: |
| 89 | + archive_path = temporary_dir / archive_name |
| 90 | + archive_format = "zip" if archive_name.endswith(".zip") else "tar.gz" |
| 91 | + subprocess.run( |
| 92 | + [ |
| 93 | + "git", |
| 94 | + "-C", |
| 95 | + str(repository), |
| 96 | + "archive", |
| 97 | + f"--format={archive_format}", |
| 98 | + f"--prefix={prefix}", |
| 99 | + f"--output={archive_path}", |
| 100 | + commit, |
| 101 | + ], |
| 102 | + check=True, |
| 103 | + ) |
| 104 | + validate_members(archive_members(archive_path), prefix) |
| 105 | + temporary_archives.append(archive_path) |
| 106 | + |
| 107 | + first_members = archive_members(temporary_archives[0]) |
| 108 | + if archive_members(temporary_archives[1]) != first_members: |
| 109 | + raise RuntimeError("tar.gz and zip archives contain different files") |
| 110 | + |
| 111 | + checksum_path = temporary_dir / "SHA256SUMS" |
| 112 | + checksum_path.write_text( |
| 113 | + "".join( |
| 114 | + f"{sha256(archive_path)} {archive_path.name}\n" |
| 115 | + for archive_path in temporary_archives |
| 116 | + ), |
| 117 | + encoding="utf-8", |
| 118 | + ) |
| 119 | + |
| 120 | + for path in [*temporary_archives, checksum_path]: |
| 121 | + os.replace(path, output_dir / path.name) |
| 122 | + |
| 123 | + return [output_dir / name for name in [*archive_names, "SHA256SUMS"]] |
| 124 | + |
| 125 | + |
| 126 | +def main(): |
| 127 | + parser = argparse.ArgumentParser( |
| 128 | + description="Build versioned FunClip release archives" |
| 129 | + ) |
| 130 | + parser.add_argument("--repository", type=Path, default=Path.cwd()) |
| 131 | + parser.add_argument("--ref", default="HEAD") |
| 132 | + parser.add_argument("--output-dir", type=Path, default=Path("dist")) |
| 133 | + args = parser.parse_args() |
| 134 | + |
| 135 | + for path in build_release_assets(args.repository, args.ref, args.output_dir): |
| 136 | + print(f"{sha256(path)} {path.name}") |
| 137 | + |
| 138 | + |
| 139 | +if __name__ == "__main__": |
| 140 | + main() |
0 commit comments