Files
Unlucky-Launcher/packaging/make_artifacts.py
T
AskaEth 4c1a6c54e5 fix(release): M51 r1 —— linux-arm64 改用 ubuntu-22.04-arm(与 x64 同 glibc 2.35 地板)
P2-1:24.04-arm 会给出 2.39 地板,把 Debian 12 / Raspberry Pi OS Bookworm
(2.36)挡在门外,且「arm64 只有 24.04 档」这句前提是错的(同段注释自己就承认
ubuntu-22.04-arm 有效)。改 os 为 ubuntu-22.04-arm,注释改成「与 x64 同地板
(2.35),覆盖 Debian 12 / Ubuntu 22.04 arm64 / RPi OS Bookworm」;
README §3/§4/§7.5/§9 与 release-notes 的 Downloads/Support matrix 全量订正
2.39 → 2.35+,删掉「arm64 地板高于 x64」的对比措辞。槽位名、产物名、六槽位
齐全性检查与打包脚本均未变。

P2-2:rustup target add 步骤改 env TARGET + `rustup target add "$TARGET"`,
并补 shell: bash(此前是全文件唯一没显式 shell 的 run,在 Windows 槽位会落进
pwsh);Linux 图形栈依赖那步也补了 shell: bash,现在所有 run 都显式 bash、
run 块内零 `${{ }}` 直插。

顺带 nit:host_slot() 报错改回原始 arch token(`riscv64` 不再显示成 None),
README §7.5 宣传的那个用例现在诊断可读。

验证:yaml 结构断言(六槽位 / linux-arm64=ubuntu-22.04-arm / 齐全性循环 6 /
run 块 ${{ }} 零泄漏 / run 步骤全带 shell)、全部 run 块 bash -n、grep 2.39
零残留、host_slot 7 组合注入 + freebsd/riscv64 报错含原名、image_arch 真 exe、
windows-arm64 stub 全流程、架构守卫拦截、linux-arm64 归档两次 sha256 一致
(f949d0f5… 与上一轮相同)、windows-x64 原生回归(冒烟 + sha256sum -c)。
2026-11-01 14:16:29 +08:00

272 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Build the single-file release artifacts for the current platform.
Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
Windows arm64 unlucky-launcher-<ver>-windows-arm64.exe (bare exe, cross-built)
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
all SHA256SUMS.txt
`--version` is taken from the cargo workspace so a tag can never drift away from
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
selling point) and the same three documents are attached to the Release next to it.
Slot and build directory are overridable because a cross build's binaries live in
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
passes both from its matrix, and the executable header is read back so a binary can
never be shipped under a slot name it was not built for.
Usage:
cargo build --release --locked
python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke]
# cross build (arm64 exe from an x64 host):
python3 packaging/make_artifacts.py --slot windows-arm64 \\
--build-dir target/aarch64-pc-windows-msvc/release
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import platform
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path
from typing import NoReturn
LAUNCHER_BIN = "unlucky-launcher"
CLI_BIN = "ull"
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
SUMS_NAME = "SHA256SUMS.txt"
DEFAULT_BUILD_DIR = "target/release"
# 发布矩阵:槽位 -> (OS 家族, 架构, 产物形态)。键名即产物文件名里的槽位段,
# 单一真相源是 packaging/README.md §1;未知槽位一律拒绝,不出"凑合能跑"的产物。
SLOTS = {
"windows-x64": ("windows", "x64", "exe"),
"windows-arm64": ("windows", "arm64", "exe"),
"linux-x64": ("linux", "x64", "tar"),
"linux-arm64": ("linux", "arm64", "tar"),
"macos-arm64": ("macos", "arm64", "tar"),
"macos-x64": ("macos", "x64", "tar"),
}
# `sys.platform` / `platform.machine()` 的别名归一(aarch64 与 arm64 是同一架构的两种叫法)。
OS_FAMILIES = (("win", "windows"), ("linux", "linux"), ("darwin", "macos"))
MACHINE_ALIASES = {"amd64": "x64", "x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
# 可执行文件头里的机器码 -> 架构(PE / ELF / Mach-O)。
IMAGE_ARCH = {
("pe", 0x8664): "x64",
("pe", 0xAA64): "arm64",
("elf", 0x3E): "x64",
("elf", 0xB7): "arm64",
("macho", 0x01000007): "x64",
("macho", 0x0100000C): "arm64",
}
REPO_ROOT = Path(__file__).resolve().parent.parent
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def workspace_version() -> str:
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
try:
raw = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
encoding="utf-8",
errors="replace",
).stdout
except (OSError, subprocess.CalledProcessError) as exc:
fail(f"cargo metadata failed: {exc}")
packages = json.loads(raw)["packages"]
versions = {pkg["version"] for pkg in packages}
if len(versions) != 1:
fail(f"workspace members disagree on version: {sorted(versions)}")
return versions.pop()
def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
"""Map the build host to a matrix slot.
Both arguments are injectable so the mapping can be exercised for a host we do not
own (no ARM machine here); they default to the real ones.
"""
os_name = (sys.platform if os_name is None else os_name).lower()
raw_machine = platform.machine() if machine is None else machine
arch = MACHINE_ALIASES.get(raw_machine.lower())
family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
for slot, (slot_os, slot_arch, _) in SLOTS.items():
if (slot_os, slot_arch) == (family, arch):
return slot
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
def binary(name: str, build_dir: Path, family: str) -> Path:
path = build_dir / (f"{name}.exe" if family == "windows" else name)
if not path.is_file():
fail(f"missing {path}; run `cargo build --release --locked` first")
return path
def image_arch(path: Path) -> str | None:
"""Architecture stamped into the executable header, or `None` if unreadable."""
with path.open("rb") as handle:
head = handle.read(0x40)
if head[:4] == b"\x7fELF":
order = "little" if head[5] == 1 else "big"
return IMAGE_ARCH.get(("elf", int.from_bytes(head[18:20], order)))
if head[:2] == b"MZ":
handle.seek(int.from_bytes(head[0x3C:0x40], "little"))
pe = handle.read(6)
if pe[:4] == b"PE\0\0":
return IMAGE_ARCH.get(("pe", int.from_bytes(pe[4:6], "little")))
return None
if head[:4] == b"\xcf\xfa\xed\xfe": # thin 64-bit little-endian Mach-O
return IMAGE_ARCH.get(("macho", int.from_bytes(head[4:8], "little")))
return None
def verify_arch(path: Path, slot: str) -> None:
"""Prove the binary really is built for the slot it is about to be named after."""
want = SLOTS[slot][1]
got = image_arch(path)
if got is None:
fail(f"cannot read the machine type of {path}; refusing to ship an unverified binary")
if got != want:
fail(f"{path} is a {got} binary but slot {slot} says {want}")
print(f"arch: {path.name} -> {got} (ok for {slot})")
def smoke_version(cli: Path, version: str) -> None:
"""`ull --version` must report the workspace version (guards tag/version drift)."""
proc = subprocess.run(
[str(cli), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
out = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode != 0:
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
if version not in out:
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
print(f"smoke: {cli.name} --version -> {version} (ok)")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
sums = out_dir / SUMS_NAME
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
return sums
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
"""tar.gz with one top-level dir holding the binary + compliance docs."""
stem = f"unlucky-launcher-{version}-{tag}"
stage = out_dir / stem
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
shutil.copy2(launcher, stage / launcher.name)
for doc in COMPLIANCE_DOCS:
source = REPO_ROOT / doc
if not source.is_file():
fail(f"compliance file {source} is missing")
shutil.copy2(source, stage / doc)
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
archive = out_dir / f"{stem}.tar.gz"
with archive.open("wb") as raw, gzip.GzipFile(
fileobj=raw, mode="wb", compresslevel=9, mtime=0
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
for entry in sorted(stage.iterdir()):
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
info.mtime = 0
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mode = 0o755 if entry.name == launcher.name else 0o644
with entry.open("rb") as handle:
tar.addfile(info, handle)
shutil.rmtree(stage)
return archive
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
args = parser.parse_args()
version = workspace_version()
if args.version:
expected = args.version[1:] if args.version.startswith("v") else args.version
if expected != version:
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
native = host_slot()
slot = args.slot or native
family, _, kind = SLOTS[slot]
build_dir = Path(args.build_dir)
if not build_dir.is_absolute():
build_dir = REPO_ROOT / build_dir
launcher = binary(LAUNCHER_BIN, build_dir, family)
verify_arch(launcher, slot)
# 跨槽位产物(宿主 x64 / 目标 arm64)在本机根本执行不了,冒烟只能等 CI 真机;
# 拿"跳过了"当通过是假证据,所以把跳过原因原样打出来。
if args.no_smoke or slot != native:
reason = "--no-smoke" if args.no_smoke else f"host is {native}, artifact is {slot}"
print(f"smoke: skipped ({reason})")
else:
smoke_version(binary(CLI_BIN, build_dir, family), version)
out_dir = Path(args.out)
if not out_dir.is_absolute():
out_dir = REPO_ROOT / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if kind == "exe":
asset = out_dir / f"unlucky-launcher-{version}-{slot}.exe"
shutil.copy2(launcher, asset)
else:
asset = stage_archive(out_dir, slot, version, launcher)
sums = write_sums(out_dir, [asset])
size_mb = asset.stat().st_size / (1024 * 1024)
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}")
print(f"sums: {sums.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())