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:
1 parent
0bfe22aa6f
commit
15caa1b61f
4 files changed
+288
-46
No files matched your search
+106
-31
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user