Files
shorebird-workspace/scripts/write_release_manifest.py
2026-06-25 20:08:23 +08:00

178 lines
5.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Write a JSON manifest for CI release artifacts.
Every non-sidecar file under the input directory must have a sibling
`<file>.sha256` sidecar in the format written by scripts/write_sha256.py:
<hex sha256> <basename>
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
from pathlib import Path, PurePosixPath
import sys
def digest_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def is_plain_file(path: Path) -> bool:
return path.is_file() and not path.is_symlink()
def parse_sidecar(path: Path) -> tuple[str, str]:
text = path.read_text(encoding="utf-8").strip()
parts = text.split()
if len(parts) != 2:
raise ValueError(f"expected '<sha256> <filename>', got {text!r}")
digest, filename = parts
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
raise ValueError(f"invalid sha256 digest {digest!r}")
return digest, filename
def is_safe_relative_path(path: str) -> bool:
if not path or path == ".":
return False
if "\\" in path or "\x00" in path or path.endswith("/"):
return False
if any(ord(character) < 32 for character in path):
return False
candidate = PurePosixPath(path)
if candidate.is_absolute():
return False
if any(part in ("", ".", "..") for part in candidate.parts):
return False
if candidate.parts and ":" in candidate.parts[0]:
return False
return True
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("input_dir", type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--github-sha", default="")
parser.add_argument(
"--require",
action="append",
default=[],
metavar="GLOB",
help="Require at least one artifact path matching this glob. May be repeated.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
input_dir = args.input_dir
output_path = args.output
if not input_dir.is_dir():
print(f"missing input directory: {input_dir}", file=sys.stderr)
return 66
artifacts = []
errors = []
for artifact_path in sorted(path for path in input_dir.rglob("*") if path.is_file()):
if artifact_path.is_symlink():
errors.append(f"{artifact_path.relative_to(input_dir).as_posix()}: symlink artifacts are not allowed")
continue
if artifact_path.suffix == ".sha256":
continue
if artifact_path.resolve() == output_path.resolve():
continue
artifact_relative_path = artifact_path.relative_to(input_dir).as_posix()
if not is_safe_relative_path(artifact_relative_path):
errors.append(f"{artifact_relative_path}: unsafe artifact path")
continue
if artifact_path.stat().st_size <= 0:
errors.append(f"{artifact_relative_path}: empty artifacts are not allowed")
continue
artifact_relative = PurePosixPath(artifact_relative_path)
sidecar_path = Path(f"{artifact_path}.sha256")
if not is_plain_file(sidecar_path):
errors.append(f"{artifact_relative_path}: missing .sha256 sidecar")
continue
actual_digest = digest_file(artifact_path)
try:
sidecar_digest, sidecar_filename = parse_sidecar(sidecar_path)
except ValueError as error:
errors.append(f"{sidecar_path.relative_to(input_dir).as_posix()}: {error}")
continue
if sidecar_digest != actual_digest:
errors.append(
f"{sidecar_path.relative_to(input_dir).as_posix()}: digest mismatch "
f"{sidecar_digest} != {actual_digest}"
)
if sidecar_filename != artifact_path.name:
errors.append(
f"{sidecar_path.relative_to(input_dir).as_posix()}: filename mismatch "
f"{sidecar_filename!r} != {artifact_path.name!r}"
)
artifacts.append(
{
"path": artifact_relative_path,
"artifact_group": artifact_relative.parts[0],
"filename": artifact_path.name,
"sha256": actual_digest,
"size": artifact_path.stat().st_size,
"sidecar": sidecar_path.relative_to(input_dir).as_posix(),
}
)
orphan_sidecars = []
for sidecar_path in sorted(input_dir.rglob("*.sha256")):
artifact_path = Path(str(sidecar_path)[: -len(".sha256")])
if sidecar_path.is_symlink():
orphan_sidecars.append(sidecar_path.relative_to(input_dir).as_posix())
elif not is_plain_file(artifact_path):
orphan_sidecars.append(sidecar_path.relative_to(input_dir).as_posix())
if orphan_sidecars:
errors.append(f"orphan .sha256 sidecars: {', '.join(orphan_sidecars)}")
artifact_paths = [artifact["path"] for artifact in artifacts]
for required_glob in args.require:
if not any(fnmatch.fnmatchcase(path, required_glob) for path in artifact_paths):
errors.append(f"missing required artifact matching {required_glob!r}")
if errors:
print(
"release manifest validation failed:\n"
+ "\n".join(f" {error}" for error in errors),
file=sys.stderr,
)
return 70
manifest = {
"format_version": 1,
"github_sha": args.github_sha,
"artifact_count": len(artifacts),
"artifacts": artifacts,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())