Files
pebrel/scripts/preview_release.py
T

427 lines
19 KiB
Python

#!/usr/bin/env python3
"""Validate and describe the complete cross-platform Preview asset set."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys
MIN_ASSET_SIZE = 1024 * 1024
VERSION_PATTERN = re.compile(
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?"
r"(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$"
)
PREVIEW_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$")
CHECKSUM_PLACEHOLDER = "<!-- PREVIEW_SHA256 -->"
PACKAGE_VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"\s*(?:#.*)?$')
RUNTIME_REPORTS = {
"windows-report.json": "windows-x86_64",
"linux-appimage-report.json": "linux-x86_64-appimage",
"linux-appimage-wayland-report.json": "linux-x86_64-appimage-wayland",
"linux-deb-report.json": "linux-x86_64-deb",
"macos-aarch64-report.json": "macos-aarch64",
"macos-x86_64-report.json": "macos-x86_64",
}
class ManifestError(ValueError):
"""The Preview asset set is incomplete or unsafe to publish."""
def verify_binary_freshness(binary: Path, repo: Path) -> None:
sources = [repo / name for name in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "CHANGELOG.md")]
for name in ("nebula_app", "nebula_terminal", "nebula_config", "nebula_config_derive",
"nebula_settings", "nebula_split", "nebula-completions", "assets", "extra", "third_party"):
root = repo / name
if root.is_dir():
sources.extend(path for path in root.rglob("*") if path.is_file())
timestamp = binary.stat().st_mtime_ns
stale = next((path for path in sources if path.is_file() and path.stat().st_mtime_ns > timestamp), None)
if stale:
raise ManifestError(f"binary is older than {stale.relative_to(repo)}; rebuild GPUI before packaging")
def validate_evidence(directory: Path, commit: str, *, windows_arm64: bool = False) -> None:
root = str(Path(__file__).resolve().parents[1])
if root not in sys.path:
sys.path.insert(0, root)
from scripts.conformance.harness import compare_reports, validate_common
golden = Path(__file__).resolve().parent / "conformance/golden"
reports = []
required_reports = dict(RUNTIME_REPORTS)
if windows_arm64:
required_reports["windows-arm64-report.json"] = "windows-aarch64"
for name, platform in required_reports.items():
report = json.loads((directory / name).read_text(encoding="utf-8"))
if report.get("platform") != platform or report.get("build", {}).get("commit") != commit:
raise ManifestError(f"wrong platform or source commit in {name}")
cases = report.get("cases", {})
if (report.get("schema_version") != 1 or len(cases) != report.get("summary", {}).get("total")
or any(case.get("status") not in {"passed", "skipped"} for case in cases.values())):
raise ManifestError(f"failed or incomplete conformance report: {name}")
errors = validate_common(report, golden)
if errors:
raise ManifestError(f"{name}: {'; '.join(errors)}")
reports.append(report)
errors = compare_reports(reports, golden)
if errors:
raise ManifestError("cross-platform mismatch: " + "; ".join(errors))
for architecture in ("aarch64", "x86_64"):
name = f"macos-{architecture}-launch.json"
report = json.loads((directory / name).read_text(encoding="utf-8"))
if (report.get("status") != "passed" or report.get("commit") != commit
or report.get("architecture") != architecture
or report.get("launch_method") != "launchservices"
or report.get("utf8_locale") is not True or report.get("home_cwd") is not True
or report.get("screenshot") != "captured" or report.get("rendered_text") is not True):
raise ManifestError(f"installed macOS application did not pass LaunchServices smoke test: {name}")
def verify_release(metadata: dict, directory: Path, version: str, preview_id: str,
commit: str, tag_commit: str) -> None:
expected_title = f"Pebrel {version} Preview {preview_id}"
expected_tag = f"preview-v{version}-{preview_id}"
if (metadata.get("name") != expected_title or metadata.get("tag_name") != expected_tag
or metadata.get("prerelease") is not True or metadata.get("draft") is not False
or tag_commit != commit):
raise ManifestError("remote Preview title, tag, commit, or prerelease state differs")
notes = (directory / "PREVIEW_NOTES.md").read_text(encoding="utf-8")
if (metadata.get("body") or "").strip() != notes.strip():
raise ManifestError("remote Preview notes differ from the verified local notes")
names = set(expected_asset_names(version, preview_id)) | {"SHA256SUMS"}
assets = metadata.get("assets") or []
if len(assets) != len(names) or {asset.get("name") for asset in assets} != names:
raise ManifestError("remote Preview asset names differ")
for asset in assets:
path = directory / asset["name"]
if (asset.get("label") not in (None, "") or asset.get("state") != "uploaded"
or asset.get("size") != path.stat().st_size
or asset.get("digest") != f"sha256:{sha256(path)}"):
raise ManifestError(f"remote asset label, state, size, or SHA256 differs: {path.name}")
def validate_labels(version: str, preview_id: str) -> None:
if not VERSION_PATTERN.fullmatch(version):
raise ManifestError(f"invalid Cargo package version: {version!r}")
if not PREVIEW_PATTERN.fullmatch(preview_id):
raise ManifestError(f"invalid Preview id: {preview_id!r}")
def read_cargo_package_version(manifest: Path) -> str:
"""Read only the top-level [package].version from one Cargo manifest."""
in_package = False
for raw_line in manifest.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if line == "[package]":
in_package = True
continue
if in_package and line.startswith("["):
break
if in_package:
match = PACKAGE_VERSION_PATTERN.fullmatch(line)
if match:
version = match.group(1)
if not VERSION_PATTERN.fullmatch(version):
raise ManifestError(
f"invalid [package].version in {manifest}: {version!r}"
)
return version
raise ManifestError(f"Cargo manifest has no [package].version: {manifest}")
def asset_version(version: str, preview_id: str) -> str:
validate_labels(version, preview_id)
return f"{version}-preview.{preview_id}"
def reviewed_notes(repo: Path, version: str, preview_id: str, macos_signing: str) -> str:
release = asset_version(version, preview_id)
source = repo / "docs" / "release-notes" / f"v{release}.md"
notes = source.read_text(encoding="utf-8").strip()
if not notes.startswith(f"# Pebrel {version} Preview {preview_id}\n"):
raise ManifestError("reviewed Preview notes have the wrong release title")
for heading in ("## English", "## 中文", "## Contributors", "## SHA256"):
if notes.splitlines().count(heading) != 1:
raise ManifestError(f"reviewed Preview notes require one {heading} section")
body, _, checksum_section = notes.rpartition("\n## SHA256\n")
if checksum_section.strip() != CHECKSUM_PLACEHOLDER or notes.count(CHECKSUM_PLACEHOLDER) != 1:
raise ManifestError("reviewed notes require the SHA256 placeholder, not stale checksums")
signing_line = f"macOS signing / macOS 签名: `{macos_signing}`"
if signing_line not in body.splitlines():
raise ManifestError("reviewed notes do not match the requested macOS signing mode")
english_start = body.index("\n## English\n") + 1
chinese_start = body.index("\n## 中文\n") + 1
contributors_start = body.index("\n## Contributors\n") + 1
if not english_start < chinese_start < contributors_start:
raise ManifestError("reviewed notes must order English, 中文, then Contributors")
english = body[english_start:chinese_start]
chinese = body[chinese_start:contributors_start]
pairs = (("Added", "新增"), ("Fixed", "修复"), ("Improved", "改进"))
categories = 0
for first, second in pairs:
present = f"### {first}" in english.splitlines()
if present != (f"### {second}" in chinese.splitlines()):
raise ManifestError("reviewed Preview change categories must be bilingual")
categories += present
if not categories:
raise ManifestError("reviewed Preview notes have no Added, Fixed, or Improved changes")
changelog = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
heading = re.search(rf"(?m)^## {re.escape(release)}(?: - [^\n]*)?\n", changelog)
if heading is None:
raise ManifestError(f"CHANGELOG.md is missing the {release} entry")
entry = re.split(r"(?m)^## ", changelog[heading.end():], maxsplit=1)[0].strip()
entry = re.sub(r"(?m)^#(?=##)", "", entry)
if entry != body[english_start:contributors_start].strip():
raise ManifestError("CHANGELOG.md and reviewed Preview notes describe different changes")
return notes + "\n"
def expected_asset_names(version: str, preview_id: str) -> tuple[str, ...]:
release = asset_version(version, preview_id)
return (
f"Pebrel-v{release}-linux-x86_64.AppImage",
f"Pebrel-v{release}-linux-x86_64.deb",
f"Pebrel-v{release}-linux-x86_64.tar.gz",
f"Pebrel-v{release}-macos-aarch64.dmg",
f"Pebrel-v{release}-macos-x86_64.dmg",
)
def _check_magic(path: Path) -> None:
with path.open("rb") as stream:
header = stream.read(8)
if path.suffix == ".dmg":
stream.seek(-512, os.SEEK_END)
trailer = stream.read(4)
else:
trailer = b""
if path.name.endswith(".AppImage") and not header.startswith(b"\x7fELF"):
raise ManifestError(f"AppImage is not an ELF executable: {path.name}")
if path.suffix == ".deb" and header != b"!<arch>\n":
raise ManifestError(f"Debian package has an invalid ar header: {path.name}")
if path.name.endswith(".tar.gz") and not header.startswith(b"\x1f\x8b"):
raise ManifestError(f"tar.gz has an invalid gzip header: {path.name}")
if path.suffix == ".dmg" and trailer != b"koly":
raise ManifestError(f"DMG has no UDIF trailer: {path.name}")
def validate_assets(directory: Path, version: str, preview_id: str) -> list[Path]:
expected = set(expected_asset_names(version, preview_id))
generated = {"SHA256SUMS", "PREVIEW_NOTES.md"}
if not directory.is_dir():
raise ManifestError(f"asset directory does not exist: {directory}")
actual = {path.name for path in directory.iterdir() if path.name not in generated}
missing = sorted(expected - actual)
unexpected = sorted(actual - expected)
if missing or unexpected:
details: list[str] = []
if missing:
details.append(f"missing: {', '.join(missing)}")
if unexpected:
details.append(f"unexpected: {', '.join(unexpected)}")
raise ManifestError("Preview asset manifest differs: " + "; ".join(details))
assets = [directory / name for name in sorted(expected)]
for path in assets:
if not path.is_file() or path.is_symlink():
raise ManifestError(f"asset is not a regular file: {path.name}")
size = path.stat().st_size
if size < MIN_ASSET_SIZE:
raise ManifestError(
f"asset is unexpectedly small ({size} bytes): {path.name}"
)
_check_magic(path)
return assets
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(content, encoding="utf-8", newline="\n")
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def checksum_text(assets: list[Path]) -> str:
return "".join(f"{sha256(path)} {path.name}\n" for path in assets)
def preview_notes(
version: str,
preview_id: str,
repository: str,
commit: str,
checksums: str,
macos_signing: str = "adhoc",
) -> str:
short_commit = commit[:12]
commit_url = f"https://github.com/{repository}/commit/{commit}"
title = f"Pebrel {version} Preview {preview_id}"
signing_en = (
"The macOS applications use ad-hoc signatures and are not Apple-notarized. Only open a verified, trusted download via System Settings > Privacy & Security > Open Anyway."
if macos_signing == "adhoc" else
"The macOS applications use Developer ID signatures; the final DMGs were accepted by Apple notarization and have stapled tickets."
)
signing_zh = (
"macOS 应用仅使用 ad-hoc 签名,尚未经过 Apple 公证;仅对已核验且可信的下载在系统设置 → 隐私与安全性中选择“仍要打开”。"
if macos_signing == "adhoc" else
"macOS 应用使用 Developer ID 签名;最终 DMG 已获 Apple 公证接受并附加公证票据。"
)
return f"""# {title}
## English
### Added
- Added automated Preview packages for Linux x86_64: AppImage, Debian package, and portable tar archive.
- Added native macOS Preview disk images for Apple Silicon and Intel.
### Verification
- Every package was built on a native GitHub-hosted runner from commit [`{short_commit}`]({commit_url}).
- Linux conformance ran against both the final AppImage and the installed Debian package.
- macOS conformance ran against the application from each final disk image, including a separate installed-copy LaunchServices startup check.
- Runtime reports were compared with a Windows build of the same commit. This does not certify native IME, display scaling, or interactive SSH authentication.
### Preview limitations
- These packages are for cross-platform testing and are not a stable release.
- {signing_en}
- Linux/macOS automatic update installation and platform-specific integrations remain outside this Preview.
## 中文
### 新增
- 新增由 CI 自动构建的 Linux x86_64 Preview 包:AppImage、Debian 安装包和便携 tar 归档。
- 新增 Apple Silicon 和 Intel 两种原生 macOS Preview 磁盘映像。
### 验证
- 所有安装包均由 GitHub 原生 runner 从提交 [`{short_commit}`]({commit_url}) 构建。
- Linux conformance 分别针对最终 AppImage 和已安装的 Debian 包运行。
- macOS conformance 针对每个最终 DMG 中的应用运行,并单独检查安装副本通过 LaunchServices 启动。
- Runtime 报告与同一提交的 Windows 构建进行了比较;这不代表原生输入法、显示缩放或交互式 SSH 认证已通过验收。
### Preview 限制
- 这些产物用于跨平台测试,不是稳定版本。
- {signing_zh}
- Linux/macOS 自动安装更新和平台专属集成功能不在本次 Preview 范围内。
## Contributors
- See the commit history for this Preview build / 本 Preview 的贡献者以提交历史为准。
---
## SHA256
```text
{checksums.rstrip()}
```
"""
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", nargs="?", type=Path)
parser.add_argument("--version")
parser.add_argument("--preview-id")
parser.add_argument("--write-checksums", type=Path)
parser.add_argument("--write-notes", type=Path)
parser.add_argument("--repository")
parser.add_argument("--commit")
parser.add_argument("--print-package-version", type=Path)
parser.add_argument("--check-binary", type=Path)
parser.add_argument("--check-reviewed-notes", action="store_true")
parser.add_argument("--use-reviewed-notes", action="store_true")
parser.add_argument("--evidence-dir", type=Path)
parser.add_argument("--macos-signing", choices=("adhoc", "developer-id"), default="adhoc")
parser.add_argument("--verify-release", type=Path)
parser.add_argument("--tag-commit")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.print_package_version:
print(read_cargo_package_version(args.print_package_version.resolve()))
return 0
if args.check_binary:
verify_binary_freshness(args.check_binary.resolve(), Path(__file__).resolve().parents[1])
return 0
source_notes = None
if args.check_reviewed_notes or args.use_reviewed_notes:
if not args.version or not args.preview_id:
raise ManifestError("reviewed notes require --version and --preview-id")
source_notes = reviewed_notes(Path(__file__).resolve().parents[1], args.version,
args.preview_id, args.macos_signing)
if args.check_reviewed_notes:
return 0
if args.evidence_dir:
if not args.commit:
raise ManifestError("--evidence-dir requires --commit")
validate_evidence(args.evidence_dir.resolve(), args.commit)
if args.directory is None or not args.version or not args.preview_id:
raise ManifestError(
"asset validation requires directory, --version, and --preview-id"
)
assets = validate_assets(args.directory.resolve(), args.version, args.preview_id)
if args.verify_release:
if not args.commit or not args.tag_commit:
raise ManifestError("--verify-release requires --commit and --tag-commit")
verify_release(json.loads(args.verify_release.read_text(encoding="utf-8")),
args.directory.resolve(), args.version, args.preview_id, args.commit, args.tag_commit)
checksums = checksum_text(assets)
if args.write_checksums:
write_atomic(args.write_checksums.resolve(), checksums)
if args.write_notes:
if not args.repository or not args.commit or not args.evidence_dir:
raise ManifestError(
"--write-notes requires --repository, --commit, and validated --evidence-dir"
)
if source_notes is not None:
notes = source_notes.replace(CHECKSUM_PLACEHOLDER, f"```text\n{checksums.rstrip()}\n```")
else:
notes = preview_notes(
args.version,
args.preview_id,
args.repository,
args.commit,
checksums,
args.macos_signing,
)
write_atomic(args.write_notes.resolve(), notes)
except (ManifestError, OSError, json.JSONDecodeError) as error:
print(f"preview manifest error: {error}", file=sys.stderr)
return 1
for path in assets:
print(f"{sha256(path)} {path.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())