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)。
272 lines
11 KiB
Python
272 lines
11 KiB
Python
#!/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())
|