Files
XStats/scripts/release_provenance.py
T

136 lines
5.0 KiB
Python

#!/usr/bin/env python3
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Record and verify that release artifacts match the source Git history."""
import hashlib
import json
import subprocess
import sys
from pathlib import Path
ALLOWED_RELEASE_FILES = {
"project.yml",
"CHANGELOG.md",
"ReleaseNotes.json",
"README.md",
"README.en.md",
"README.ja.md",
"README.ko.md",
"Assets/readme/activity.svg",
"Assets/readme/activity.zh.svg",
}
class ProvenanceError(RuntimeError):
pass
def git(repo: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
["git", *arguments], cwd=repo, check=check, capture_output=True
)
def git_text(repo: Path, *arguments: str) -> str:
return git(repo, *arguments).stdout.decode().strip()
def git_paths(repo: Path, *arguments: str) -> set[str]:
output = git(repo, *arguments).stdout
return {item.decode() for item in output.split(b"\0") if item}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def prepare(repo: Path) -> str:
changed = set()
changed |= git_paths(repo, "diff", "--name-only", "-z")
changed |= git_paths(repo, "diff", "--cached", "--name-only", "-z")
changed |= git_paths(repo, "ls-files", "--others", "--exclude-standard", "-z")
disallowed = sorted(changed - ALLOWED_RELEASE_FILES)
if disallowed:
raise ProvenanceError("构建前存在未提交的源码改动:" + ", ".join(disallowed))
return git_text(repo, "rev-parse", "HEAD")
def record(repo: Path, output: Path, base_commit: str, version: str, build: str) -> None:
payload = {
"format": 1,
"base_commit": base_commit,
"version": version,
"build": build,
"project_sha256": sha256(repo / "project.yml"),
"changelog_sha256": sha256(repo / "CHANGELOG.md"),
}
notes = repo / "ReleaseNotes.json"
if notes.is_file():
payload["release_notes_sha256"] = sha256(notes)
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(output.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(output)
def verify(repo: Path, provenance: Path, version: str, build: str) -> None:
try:
payload = json.loads(provenance.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ProvenanceError(f"无法读取发布来源记录:{error}") from error
if payload.get("format") != 1:
raise ProvenanceError("不支持的发布来源记录格式")
if payload.get("version") != version or payload.get("build") != build:
raise ProvenanceError("发布来源记录与当前版本或构建号不一致")
if sha256(repo / "project.yml") != payload.get("project_sha256"):
raise ProvenanceError("project.yml 与构建时不一致")
if sha256(repo / "CHANGELOG.md") != payload.get("changelog_sha256"):
raise ProvenanceError("CHANGELOG.md 与构建时不一致")
notes = repo / "ReleaseNotes.json"
if notes.exists() or "release_notes_sha256" in payload:
if not notes.is_file() or sha256(notes) != payload.get("release_notes_sha256"):
raise ProvenanceError("ReleaseNotes.json 与构建时不一致")
if git(repo, "status", "--porcelain", "--untracked-files=all").stdout:
raise ProvenanceError("发布前工作区或暂存区不干净")
base_commit = payload.get("base_commit")
if not isinstance(base_commit, str) or not base_commit:
raise ProvenanceError("发布来源记录缺少基础提交")
if git(repo, "merge-base", "--is-ancestor", base_commit, "HEAD", check=False).returncode != 0:
raise ProvenanceError("构建时的基础提交不是当前 HEAD 的祖先")
changed = git_paths(repo, "diff", "--name-only", "-z", base_commit, "HEAD")
disallowed = sorted(changed - ALLOWED_RELEASE_FILES)
if disallowed:
raise ProvenanceError("构建后源码发生变化:" + ", ".join(disallowed))
def main() -> None:
repo = Path.cwd()
command = sys.argv[1] if len(sys.argv) > 1 else ""
try:
if command == "prepare" and len(sys.argv) == 2:
print(prepare(repo))
elif command == "record" and len(sys.argv) == 6:
record(repo, Path(sys.argv[2]), sys.argv[3], sys.argv[4], sys.argv[5])
elif command == "verify" and len(sys.argv) == 5:
verify(repo, Path(sys.argv[2]), sys.argv[3], sys.argv[4])
else:
raise ProvenanceError(
"用法:release_provenance.py prepare | record <文件> <基础提交> <版本> <构建号> | "
"verify <文件> <版本> <构建号>"
)
except ProvenanceError as error:
raise SystemExit(f"发布来源校验失败:{error}") from error
if __name__ == "__main__":
main()