Files
Unlucky-Launcher/packaging/make_artifacts.py
T
AskaEth 1c7285cea9 feat(cli): GUI 二进制内建 CLI 子命令分发,ull 纳入发布资产(M82)
一个二进制两种用法:两个 GUI 的 main.rs 在进 UI 之前分发子命令,命中就把控制权交给
unlucky_cli::run —— 不建窗口、不探后端、不扫字体、不 dlopen 图形库。

分发判据只有一条,子命令表不重复:crates/cli 的解析器是唯一真相源。
- 首个 token 是裸词 → 子命令位置,交 CLI(未知子命令由 CLI 报错 + 提示 --help,退出 1,
  不静默开窗)
- 首个 token 是 flag → 只有 CLI 解析出真命令才算 CLI 调用;--data-dir X 解析成 Welcome、
  --screenshot / --renderer / --diff 等 GUI 参数解析失败,一律仍走 GUI(CI 脚本零改动)

顺带:unlucky-launcher --version 以前会直接开窗,现在走 CLI 分发报版本。

packaging:ull 作为独立裸资产随每个槽位发布(make_artifacts.py + release.yml 的 assets
数组与 SHA256SUMS glob),矩阵完整性检查改成 GUI/CLI 各查一次。

app-iced 的 x11 / wayland / linux-theme-detection 收进 target 专属的 cfg(target_os) 段。

门禁实跑:cargo fmt --all --check 通过;cargo clippy --workspace --all-targets -D warnings
通过(exit 0);cargo test --workspace 通过(51 个测试二进制 / 1369 个用例,含本次新增的
5 + 5 条分发判据用例)。
2026-11-02 08:08:09 +08:00

297 lines
13 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
every slot ull-<ver>-<slot>[.exe] (bare CLI binary, no archive)
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.
The CLI ships as its **own bare asset** on every platform rather than inside the tar.gz:
the point of `ull` is a small single file you download and run on a machine with no
display, and an archive it has to be extracted from adds a step for that user. The
launcher is the archive because it needs the license texts beside it; the Release
carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
The launcher binary also answers every CLI subcommand, so `ull` is not a second
interface — it is the version of it that links no graphics stack at all.
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_cli(out_dir: Path, version: str, slot: str, cli: Path) -> Path:
"""Copy the CLI out as its own bare asset (`ull-<ver>-<slot>[.exe]`)."""
name = f"{CLI_BIN}-{version}-{slot}" + (".exe" if cli.suffix == ".exe" else "")
asset = out_dir / name
shutil.copy2(cli, asset)
# 裸文件下载即可运行:Windows 宿主机上源文件没有 x 位,这里补上(UNIX 侧才有意义)。
asset.chmod(0o755)
return asset
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)
# CLI 与 GUI 同一矩阵槽位、各自独立资产:架构同样回读文件头校验,不给 arm64 槽位混进 x64 的 ull。
cli = binary(CLI_BIN, build_dir, family)
verify_arch(cli, slot)
cli_asset = stage_cli(out_dir, version, slot, cli)
sums = write_sums(out_dir, [asset, cli_asset])
for item in (asset, cli_asset):
size_mb = item.stat().st_size / (1024 * 1024)
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
print(f"sums: {sums.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())