feat(release): M51 —— 发布矩阵补 Linux arm64 与 Windows arm64 两槽位(六槽位)

- release.yml:加 linux-arm64(原生 ubuntu-24.04-arm runner)与 windows-arm64
  (windows-latest 上交叉编译到 aarch64-pc-windows-msvc);产物根改由
  matrix.build_dir 参数化,dumpbin 自检与打包都吃矩阵路径而非硬编码 target/release;
  交叉槽位多两步(rustup target add + ARM64 库体检),release job 的槽位齐全性
  检查从四扩到六。
- make_artifacts.py:--slot / --build-dir 参数化 + 六槽位映射;打包前回读可执行文件头
  (PE 机器码 / ELF e_machine / Mach-O cputype)校验架构,跨槽位构建自动跳过
  ull --version 冒烟并打印原因(不在 x64 上假装跑得动 arm64 二进制)。
- packaging/README.md + release-notes.md:两张支持矩阵同步加 Windows 11 ARM64 与
  Linux arm64 行;arm64 glibc 地板如实写 2.39(高于 x64 的 2.35),并说明与 Termux
  实验级的区别(这两个是正式发布槽位)。

本地验证:宿主判定注入(七个组合 + 非法宿主 exit 1)、文件头架构识别(真 x64 exe +
合成 PE/ELF/Mach-O 头)、windows-arm64 stub 全流程、架构守卫真拦、linux-arm64 归档
两次 sha256 一致、windows-x64 原生回归(冒烟 + sha256sum -c)。arm64 真机构建由 CI 补。
This commit is contained in:
AskaEth committed 2026-11-01 13:48:37 +08:00
1 parent 0bfe22aa6f
commit 15caa1b61f
4 files changed
+288 -46

No files matched your search

+106 -31
View File
@@ -3,11 +3,13 @@
Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
all SHA256SUMS.txt
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).
@@ -15,9 +17,17 @@ The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which
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
@@ -26,7 +36,6 @@ import argparse
import gzip
import hashlib
import json
import os
import platform
import shutil
import subprocess
@@ -40,9 +49,32 @@ 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
TARGET_DIR = REPO_ROOT / "target" / "release"
def fail(message: str) -> NoReturn:
@@ -72,28 +104,59 @@ def workspace_version() -> str:
return versions.pop()
def platform_tag() -> str:
"""Map the build host to the artifact matrix slot (hard-fail on unsupported ones)."""
os_name = sys.platform
machine = platform.machine().lower()
if os_name.startswith("win") and machine in ("amd64", "x86_64"):
return "windows-x64"
if os_name.startswith("linux") and machine in ("x86_64", "amd64"):
return "linux-x64"
if os_name == "darwin" and machine in ("arm64", "aarch64"):
return "macos-arm64"
if os_name == "darwin" and machine in ("x86_64", "amd64"):
return "macos-x64"
fail(f"unsupported build host {os_name}/{machine}; matrix is windows-x64 / linux-x64 / macos-arm64 / macos-x64")
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()
machine = MACHINE_ALIASES.get(
(platform.machine() if machine is None else 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, machine):
return slot
fail(f"unsupported build host {os_name}/{machine}; matrix is {' / '.join(SLOTS)}")
def binary(name: str) -> Path:
path = TARGET_DIR / (f"{name}.exe" if sys.platform.startswith("win") else name)
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(
@@ -108,7 +171,7 @@ def smoke_version(cli: Path, version: str) -> None:
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)")
print(f"smoke: {cli.name} --version -> {version} (ok)")
def sha256(path: Path) -> str:
@@ -160,6 +223,8 @@ 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()
@@ -168,22 +233,32 @@ def main() -> int:
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}")
tag = platform_tag()
launcher = binary(LAUNCHER_BIN)
cli = binary(CLI_BIN)
if not args.no_smoke:
smoke_version(cli, 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 tag == "windows-x64":
asset = out_dir / f"unlucky-launcher-{version}-{tag}.exe"
if kind == "exe":
asset = out_dir / f"unlucky-launcher-{version}-{slot}.exe"
shutil.copy2(launcher, asset)
else:
asset = stage_archive(out_dir, tag, version, launcher)
asset = stage_archive(out_dir, slot, version, launcher)
sums = write_sums(out_dir, [asset])
size_mb = asset.stat().st_size / (1024 * 1024)