ci(release): M164 arm64 判定权交回编译器 + 发布名单守卫(不翻牌)

根因(拿到的真实证据,不是猜的):v0.2.2(run 42322062879 / job 128248713926)与 v0.2.3
(run 42429017002 / job 128573547829)两份 check-run 注解逐字一致 —— 失败步骤是第 6 步
`Windows ARM64 工具链体检`,第 7 步 `cargo build` 状态 skipped。也就是说 v0.2.1~v0.2.3 五轮
全红都红在一个**编译前的启发式探针**上,真正的编译器/链接器一次都没说话。该探针的结论还与它
自己打印的快照矛盾:`SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,]` 明明在,却
同时报 `kernel32=缺失`(同一个 `find -path` 模式在本地合成目录树上命中)。runner 实况是
Windows 11 Arm64 + Visual Studio 2026 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100`
都装好了,M142 的 `setup.exe modify` 反倒返回非 0。

改:
- release-build.yml:探针降级为「只取证」——vswhere 组件查询 + 各目录 `.lib` 计数 +
  arm64 `link.exe` 候选,一条 `::error::` 都不写、永不 exit 1;不再自动补装 VS。红绿只由
  cargo/链接/静态 CRT 导入表门禁决定。cargo build 步给 arm64 显式备齐工具链:
  `rustup target add aarch64-pc-windows-msvc`、`vcvarsall.bat arm64` 取 `LIB`/`INCLUDE`、
  vswhere 定位 arm64 `link.exe` 后导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
  (顺序不能反:只给链接器不给 LIB ⇒ LNK1181 kernel32.lib)。
- 新增 packaging/check_publish_slots.py(344 行,零依赖):名单 ⇄ 产物**两个方向**。
  声明了缺 GUI/CLI ⇒ `::error::` + exit 1;有产物没声明 ⇒ `::warning::` 明说未发布(M142 之后
  这个方向是静默的,裁剪步只 echo 一行 drop:)。另有源码级断言(名单每项必须存在且 mode=ci、
  运行期名单必须等于文件里那行)与 8 条 `--self-test` 注入回归。
- release-publish.yml:裁剪前插一步跑那个守卫,原来内联的齐全性循环搬进脚本(一套判据一处实现);
  重算 SHA256SUMS 的步骤只负责重算。
- release-guard.yml:PR 阶段跑守卫自测 + 源码级断言。
- 文档同步:release.yml / release-publish.yml / release-build.yml 头注释、packaging/README §12、
  §1 矩阵、§3 构建与发布、§9 支持矩阵、§7.5 arm64 取证、release-notes.md、README/README.zh-CN
  的产物矩阵与说明。口径统一为「arm64 构建取证中、未发布」,不写成已支持。

没做的(按 tower 裁决「方案乙」):`PUBLISH_SLOTS` 本轮**不加回** arm64。arm64 从未产出过 PE、
本机也证不了,写进名单就等于拿 x64 的发布去赌它(M142 移出名单正是为避免这个事故)。下一 tag 的
CI 真出包后翻一行即可,守卫与 SHA256SUMS 自动跟着扩。

验证:workflow YAML 全部可被 PyYAML 解析;所有 `run:` 块过 `bash -n`;探针步在本机(无 VS/SDK)
实跑 exit 0 并打出实况注解;守卫 `--self-test` 8 条全绿、正反两个方向都在合成 dist 上实测过;
check_release_semantics.py(含 --self-mutation)/ release_marker / make_manifest 自检全绿;
cargo metadata 正常。**arm64 产物从未被构建过、也从未在真机跑过**——本机 `rustc --target
aarch64-pc-windows-msvc` 直接撞上没有 MSVC 链接器(git-bash 的 GNU `link` 被当成 link.exe),
`cargo check --workspace --target aarch64-pc-windows-msvc` 也死在 aws-lc-sys 的 cc-rs 阶段
(GNU host 上没有该 target 的 C 编译器)。真实结论只能等下一 tag 的 CI。
This commit is contained in:
AskaEth committed 2026-11-08 08:33:44 +08:00
1 parent 15a19bb375
commit e858664fca
13 files changed
+577 -120

No files matched your search

+1
View File
@@ -64,6 +64,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
| `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 |
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""发布名单守卫(M164):**名单声明与实际产物必须对得上**,两个方向都管。
起因:`PUBLISH_SLOTS` 是唯一决定「哪几个槽位进 Release」的开关,而它此前只有一半是守住的 ——
「名单里有、产物没有」会被 `release-publish.yml` 的齐全性检查拦下,但反方向**静默**:某个槽位(例如
`windows-arm64`)真编出了产物、名单却还没声明它时,那些文件会在裁剪步被 `rm` 掉,日志里只有一行
`drop:`,没有任何注解、也没有任何非零退出码。修复的那个 tag 里「arm64 其实已经能出包了」这件事就
这么被吞掉,没人会注意到(M164 的任务清单点名要这条)。
四条断言:
1. **名单非空且可解析**:`release-publish.yml` 里必须有 `PUBLISH_SLOTS: "..."`。开关消失时不要静默
变成「一个都不发」。
2. **名单里每一项都是矩阵槽位**:`release.yml` 的槽位表(`dict(slot=... )`)才是槽位的唯一定义。
名单里写错一个字(`windows-aarch64`)此前只会表现为「Release 侧什么也不匹配、齐全性检查红在别处」,
根因要翻两层日志;这条在 PR 阶段就把它点出来。
3. **名单里每一项的 `mode` 都必须是 `ci`**:`mode: probe` 的槽位默认构建根本不跑,写进发布名单等于
每一轮都缺一档 —— 一个自己写死要红的组合。
4. **产物 ⇄ 名单两个方向**(`--dist`,只在发布侧有产物时跑):
- 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版,这条现在是脚本、本地可跑);
- 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**;
- 无论哪种,最后都打一张全槽位表:发布 / 未发布(无产物)/ 未发布(名单没声明)。
用法(零第三方依赖,本地与 CI 同一份代码):
python3 packaging/check_publish_slots.py # 源码级断言(PR CI)
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
python3 packaging/check_publish_slots.py --self-test # 注入回归,断言每条都变红
`--self-test` 是这条守门的自测证据:把两个 workflow 复制进临时目录、造合成 dist,逐项注入真实回归
(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物),断言守门
该红的红、该只警告的不红。守门抓不住回归时它自己先红。
"""
from __future__ import annotations
import re
import shutil
import sys
import tempfile
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent
ROOT = PACKAGING.parent
MATRIX_YML = Path(".github/workflows/release.yml")
PUBLISH_YML = Path(".github/workflows/release-publish.yml")
#: 槽位表条目(`plan` job 里那份):`dict(slot="windows-x64", ..., mode="ci")`,一条可能跨两行。
SLOT_RE = re.compile(r'dict\(\s*slot="(?P<slot>[^"]+)"(?P<rest>.*?)\)', re.S)
MODE_RE = re.compile(r'mode="(?P<mode>[^"]+)"')
#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64"`)。
PUBLISH_RE = re.compile(r'^\s*PUBLISH_SLOTS:\s*"(?P<value>[^"]*)"', re.M)
#: 每个槽位必须**两样都有**:GUI 与 CLI 各自一个资产(只按「槽位名出现过」判会被其中一个掩盖)。
ASSET_PREFIXES = (("unlucky-launcher", "GUI"), ("ull", "CLI"))
def stem(slot: str) -> str:
"""槽位键落到文件名里是下划线形态(`windows-arm64` → `windows_arm64`)。"""
return slot.replace("-", "_")
# ────────────────────────── 读两个 workflow ──────────────────────────
def matrix_slots(text: str) -> dict[str, str]:
"""`release.yml` 的槽位表 → {槽位: mode}。表在 `plan` job 里,是槽位的唯一定义。"""
slots: dict[str, str] = {}
for match in SLOT_RE.finditer(text):
mode = MODE_RE.search(match.group("rest"))
slots[match.group("slot")] = mode.group("mode") if mode else ""
return slots
def declared_slots(text: str) -> list[str] | None:
"""`release-publish.yml` 的 `PUBLISH_SLOTS` → 槽位列表;整行不见了返回 `None`(区别于「空名单」)。"""
match = PUBLISH_RE.search(text)
return None if match is None else match.group("value").split()
def parse_slots_arg(raw: str) -> list[str]:
"""`--slots "windows-x64 windows-arm64"` 的取值解析(CI 直接透传 `$PUBLISH_SLOTS`)。"""
return raw.replace(",", " ").split()
# ────────────────────────── 1~3. 源码级断言 ──────────────────────────
def check_source(root: Path, expected: list[str] | None = None) -> list[str]:
problems: list[str] = []
matrix_path = root / MATRIX_YML
publish_path = root / PUBLISH_YML
for path in (matrix_path, publish_path):
if not path.is_file():
return [f"找不到 {path.relative_to(root)}(流水线搬家了?这条守门要跟着搬)"]
matrix = matrix_slots(matrix_path.read_text(encoding="utf-8"))
if not matrix:
return [f"{MATRIX_YML} 里读不到任何槽位(`dict(slot=...)` 的写法变了?槽位表是槽位的唯一定义)"]
declared = declared_slots(publish_path.read_text(encoding="utf-8"))
if declared is None:
return [f"{PUBLISH_YML} 里没有 `PUBLISH_SLOTS:` 这一行:发布白名单消失了(别让它静默变成「一个都不发」)"]
if not declared:
problems.append("`PUBLISH_SLOTS` 是空名单:任何一个槽位都不会进 Release 与 SHA256SUMS")
if expected is not None and expected != declared:
# 运行期实际用的名单必须就是文件里那一行(有人在步内覆盖 PUBLISH_SLOTS 时就对不上)。
problems.append(f"运行期名单 {expected} 与 {PUBLISH_YML} 里声明的 {declared} 不一致")
for slot in declared:
if slot not in matrix:
problems.append(
f"名单里的 `{slot}` 不是矩阵槽位(矩阵只有 {' / '.join(matrix)}):"
"写错一个字,裁剪步就一个也匹配不到,而齐全性检查会红在别处、根因埋两层"
)
elif matrix[slot] != "ci":
problems.append(
f"名单里的 `{slot}` 在矩阵里是 mode={matrix[slot]!r}(默认不构建):"
"声明为发布却默认不编,等于每一轮都缺一档"
)
return problems
# ────────────────────────── 4. 产物 ⇄ 名单(发布侧) ──────────────────────────
def assets_for(dist: Path, slot: str) -> dict[str, list[str]]:
"""{资产前缀: 命中的文件名}——按 `make_artifacts.py::asset_stem()` 的命名规则找。"""
out: dict[str, list[str]] = {}
for prefix, _role in ASSET_PREFIXES:
out[prefix] = sorted(p.name for p in dist.glob(f"{prefix}_*_{stem(slot)}*") if p.is_file())
return out
def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple[list[str], list[str], list[str]]:
"""返回 (errors, warnings, 表格行)。errors 非空 ⇒ 调用方 exit 1。"""
errors: list[str] = []
warnings: list[str] = []
rows: list[str] = ["槽位 名单 本次产物 结论"]
rows.append("-" * 78)
wanted = set(declared)
# 顺序:先按矩阵顺序(对外口径稳定),再有名单里多出来的(check_source 已负责报它是错字)。
order = list(matrix) + [s for s in declared if s not in matrix]
for slot in order:
found = assets_for(dist, slot)
present = [f"{prefix}({len(found[prefix])})" for prefix, _ in ASSET_PREFIXES if found[prefix]]
is_declared = slot in wanted
if is_declared:
missing = [role for prefix, role in ASSET_PREFIXES if not found[prefix]]
for role in missing:
errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`)")
verdict = "发布" if not missing else "缺产物 ⇒ 不发版"
wanted.discard(slot)
elif present:
# **反方向**:真编出来了却没声明。裁剪步会把它 rm 掉,此前这里一句注解都没有。
warnings.append(
f"{slot} 有产物({'、'.join(present)})但不在 PUBLISH_SLOTS 里:本次不发布,产物会被裁掉。"
"若这是修复后的首次成功构建,把槽位加进名单即可(齐全性检查与 SHA256SUMS 自动跟着扩)"
)
verdict = "未发布(名单没声明)"
else:
verdict = "未发布(本次没有产物)"
rows.append(f"{slot:<20} {'声明' if is_declared else '未声明':<8} {'、'.join(present) or '无':<15} {verdict}")
for slot in sorted(wanted):
errors.append(f"发布白名单里的 `{slot}` 既不是矩阵槽位、也没有产物(详见上面的源码级断言)")
return errors, warnings, rows
def prefix_for(role: str) -> str:
return next(prefix for prefix, name in ASSET_PREFIXES if name == role)
# ────────────────────────── 汇总 ──────────────────────────
def run_checks(root: Path, dist: Path | None = None, expected: list[str] | None = None) -> tuple[list[str], list[str], list[str]]:
problems = check_source(root, expected)
if dist is None or problems:
return problems, [], []
matrix = matrix_slots((root / MATRIX_YML).read_text(encoding="utf-8"))
declared = declared_slots((root / PUBLISH_YML).read_text(encoding="utf-8")) or []
errors, warnings, rows = check_dist(dist, declared, matrix)
return problems + errors, warnings, rows
def report(root: Path, dist: Path | None, expected: list[str] | None = None) -> int:
errors, warnings, rows = run_checks(root, dist, expected)
for row in rows:
print(row)
for item in warnings:
print(f"::warning::{item}")
# 表格之后再看结论:注解必须排在它解释的那张表后面,人读日志的顺序才对得上。
for item in errors:
print(f"::error::{item}", file=sys.stderr)
if errors:
print(f"发布名单守卫:{len(errors)} 条不通过", file=sys.stderr)
return 1
print(f"OK: 发布名单守卫全绿({len(warnings)} 条「未发布」提示)")
return 0
# ────────────────────────── 自测:注入回归,断言每条都红 ──────────────────────────
def snapshot(dst: Path) -> None:
for rel in (MATRIX_YML, PUBLISH_YML):
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / rel, target)
def fake_dist(parent: Path, slots: tuple[str, ...]) -> Path:
"""合成 dist:每槽位一个 GUI + 一个 CLI 资产(名字形状与 `make_artifacts.py` 一致)。"""
dist = parent / "dist"
dist.mkdir(parents=True, exist_ok=True)
version = "0.0.0"
for slot in slots:
platform, arch = slot.split("-", 1)
suffix = ".exe" if platform == "windows" else ".tar.gz"
(dist / f"unlucky-launcher_{version}_{platform}_{arch}{suffix}").write_bytes(b"gui")
(dist / f"ull_{version}_{platform}_{arch}{suffix}").write_bytes(b"cli")
return dist
def set_declared(root: Path, value: str) -> None:
path = root / PUBLISH_YML
text = path.read_text(encoding="utf-8")
replaced, count = PUBLISH_RE.subn(f'PUBLISH_SLOTS: "{value}"', text)
if count != 1:
raise SystemExit(f"::error::自测自身失效:{PUBLISH_YML} 里找到 {count} 处 PUBLISH_SLOTS")
path.write_text(replaced, encoding="utf-8")
def self_test() -> int:
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-publish-slots-") as tmp:
base = Path(tmp) / "base"
snapshot(base)
declared = declared_slots((base / PUBLISH_YML).read_text(encoding="utf-8")) or []
if not declared:
print("::error::自测的基线读不出 PUBLISH_SLOTS", file=sys.stderr)
return 1
cases: list[tuple[str, Path, Path | None, list[str] | None, bool]] = []
# 0. 基线:名单齐全、产物齐全 ⇒ 绿。
cases.append(("基线(名单与产物都齐)", base, fake_dist(Path(tmp) / "c0", tuple(declared)), None, False))
# 1. 名单声明了,但缺 CLI 资产 ⇒ 必须红(这条就是「少一档不发版」)。
dist = fake_dist(Path(tmp) / "c1", tuple(declared))
for stale in dist.glob("ull_*"):
stale.unlink()
cases.append(("名单声明了但缺 CLI 产物", base, dist, None, True))
# 2. 名单声明了,但整个槽位没有产物 ⇒ 必须红。
cases.append(("名单声明了但整个槽位没产物", base, fake_dist(Path(tmp) / "c2", ()), None, True))
# 3. 有产物、名单没声明 ⇒ 只警告不红(反方向的静默必须被打破,但发布本身没错)。
# 名单内的槽位照旧齐全,只是多出一档没人声明的产物。
cases.append(("产物在但名单没声明(arm64 修复后的典型场景)", base, fake_dist(Path(tmp) / "c3", (*declared, "windows-arm64")), None, False))
# 4. 名单拼错一个字 ⇒ 必须红(此前要翻两层日志才能看出根因)。
typo = Path(tmp) / "c4"
snapshot(typo)
set_declared(typo, "windows-aarch64")
cases.append(("名单拼错成 windows-aarch64", typo, fake_dist(Path(tmp) / "c4d", ("windows-aarch64",)), None, True))
# 5. 名单写了 probe 档(默认不构建)⇒ 必须红。
probe = Path(tmp) / "c5"
snapshot(probe)
set_declared(probe, "windows-x64 linux-x64")
cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "linux-x64")), None, True))
# 6. 运行期名单与文件里那一行不一致 ⇒ 必须红(防步内覆盖)。
cases.append(("运行期名单与文件不一致", base, fake_dist(Path(tmp) / "c6", tuple(declared)), ["windows-arm64"], True))
# 7. 「把 arm64 加回名单」这一行改动本身是干净的:名单+产物都对得上 ⇒ 绿。
flip = Path(tmp) / "c7"
snapshot(flip)
set_declared(flip, " ".join([*declared, "windows-arm64"]))
cases.append(("名单加上 windows-arm64 且产物齐全(下一 tag 的翻牌预演)", flip, fake_dist(Path(tmp) / "c7d", (*declared, "windows-arm64")), None, False))
for label, root, dist, expected, want_red in cases:
errors, warnings, _rows = run_checks(root, dist, expected)
got_red = bool(errors)
if got_red != want_red:
want = "红" if want_red else "绿"
detail = ";".join(errors) or "(无 error)"
print(f"::error::自测失败:{label} 应该{want},实际 {'红' if got_red else '绿'} —— {detail}", file=sys.stderr)
failures += 1
else:
print(f"OK: {label} → {'红' if got_red else '绿'}({len(warnings)} 条未发布提示)")
# 第 3 条还要断言**确实打了警告**:不静默才是这条规则的重点。
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c8", (*declared, "windows-arm64")), None)
if not any("windows-arm64" in w for w in warnings):
print("::error::自测失败:产物在但名单没声明时没有针对 windows-arm64 的 ::warning::", file=sys.stderr)
failures += 1
else:
print("OK: 产物在但名单没声明 → 有 windows-arm64 的 ::warning::")
if failures:
return 1
print("OK: 发布名单守卫自测全部通过(该红的红、该绿的不红)")
return 0
def main(argv: list[str]) -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
root = ROOT
dist: Path | None = None
expected: list[str] | None = None
args = list(argv)
while args:
arg = args.pop(0)
if arg == "--root" and args:
root = Path(args.pop(0)).resolve()
elif arg == "--dist" and args:
dist = Path(args.pop(0)).resolve()
elif arg == "--slots" and args:
expected = parse_slots_arg(args.pop(0))
elif arg == "--self-test":
return self_test()
elif arg in ("-h", "--help"):
print(__doc__)
return 0
else:
print(f"::error::不认识的参数:{arg}", file=sys.stderr)
return 2
if dist is not None and not dist.is_dir():
print(f"::error::--dist {dist} 不是目录(下载 artifact 那一步没跑成?)", file=sys.stderr)
return 1
return report(root, dist, expected)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3 -1
View File
@@ -9,7 +9,7 @@
| 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 |
|------|---------|-----------|------|---------|
| `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `windows-arm64` | ❌ 不发布(构建照跑取证) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 仍在**原生** `windows-11-arm` runner 上构建(M140 起不交叉),**红了不挡 x64 发布**(M142,见 §3) |
| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡 x64 发布**(M142,见 §3) |
| `linux-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
| `linux-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_arm64.tar.gz` | 单二进制归档 | 同上 |
@@ -22,6 +22,8 @@
- **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。
(M164)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒
`::warning::` 明说「未发布、会被裁掉」——arm64 真出包之后把键加回名单是一行改动,守卫与 `SHA256SUMS.txt` 自动跟着扩。
- **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
+26 -12
View File
@@ -50,17 +50,29 @@ python packaging/make_artifacts.py --slot windows-arm64 \
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
`ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` /
`macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位与 `rustup
target add` 一并没了)。arm64 槽位多一步编译前体检:查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static`
靠它)与 SDK 的 `um/arm64/kernel32.lib`,缺了就 fail;**注解里固定打 4 行实况**(探测根 / SDK Lib 版本→um
架构 / MSVC lib 架构 / 两个关键库)。M142 修的正是「诊断自己先死」:`set -uo pipefail` 关不掉 runner 注入的
`-e`,`find` 扫不存在的根被 pipefail 判非零就当场退出 → 现在 `set +e` + 处处 `|| true`,清单先打完再定红绿。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位没了)。arm64 槽位在编译前
**把工具链显式备齐**(M164):`rustup target add aarch64-pc-windows-msvc`、用微软自己的 `vcvarsall.bat arm64` 取
`LIB`/`INCLUDE`(写进本步环境与 `$GITHUB_ENV`)、用 vswhere 定位 arm64 `link.exe` 后显式导出
`CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 原生优先)。顺序不能反:只覆盖链接器而不给 `LIB`,
撞上的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
- **编译前体检已降级为「只取证」**(M164):M141/M142 的版本用 `find` 猜 `um/arm64/kernel32.lib` 在不在、猜缺就
`exit 1` —— v0.2.1~v0.2.3 五轮全红在**那一步**,`cargo build` 一次都没跑过(永远 skipped),而它跟自己打的快照
自相矛盾(`um/arm64` 明明在)。现在那一步只打印 vswhere 组件查询、各目录 `.lib` **计数**与 `link.exe` 候选,
永不 `exit 1`;**红绿只由编译器 / 链接器 / 静态 CRT 导入表门禁决定**。runner 实况:`windows-11-arm` 自 2026-09
是 Windows 11 Arm64 + **Visual Studio 2026** 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都已装好。
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有
`windows-x64`**:arm64 连试四轮未走通,而齐全性检查是「名单内少一档就 fail」,挂着它就等于 x64 编成功也发不出去;
它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create`(**push tag 默认建稳定版**:不带任何标记)。
`windows-x64`**:齐全性检查是「名单内少一档就 fail」,挂着没验证过的 arm64 就等于 x64 编成功也发不出去。
(M164)arm64 那五轮红其实全红在误判的体检步(上一条),门禁拆掉后「arm64 能不能出 PE」只能由**下一 tag 的 CI**
证明,所以名单这轮仍只列 x64 —— 不拿 x64 的发布去赌(tower M164 裁决)。**可逆且只是一行**:那次 CI 真出包后把
键加回 `release-publish.yml` 那一行,再同步 §1/§9 与 `release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- **名单 ⇄ 产物两个方向都有守卫(M164)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段
(`.github/workflows/release-guard.yml`)断言「名单非空 / 每项都是槽位表里 `mode: ci` 的档」;发布侧
(`release-publish.yml` 的 `--dist` 模式,跑在**裁剪前**的全量集上)断言「声明了就得有 GUI 与 CLI 两个产物,
缺一个就红」,并把「有产物但名单没声明」打成 `::warning::` 明说未发布 —— M142 之后那个方向是静的(裁剪步只
`echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。`--self-test` 注入 8 条回归,本地一条命令可跑。
- `release` job:下载全部 artifact → **发布名单守卫**(断言 + 未发布提示)→ 按白名单裁剪 → 重算 `SHA256SUMS.txt` →
用 `packaging/release-notes.md` 模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create`
(**push tag 默认建稳定版**:不带任何标记)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **预发布标记的语义表(M163,唯一真相源 `packaging/release_marker.py`)**:
@@ -87,8 +99,10 @@ python packaging/make_artifacts.py --slot windows-arm64 \
`.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步
逐字搬进 `.github/workflows/release-publish.yml`(同 `workflow_call`,`secrets.GITHUB_TOKEN` 改为调用方显式透传
的 secret)。触发(`on`)、`env`、顶层 `permissions`、`concurrency` 与 `plan` 的槽位表在 M145 搬家时**一字未动**;
后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表)。改槽位仍只改
`release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,两个 arm64 修复时要改的仍是那两行)。
后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表),M164 改过
`release-build.yml` 的 arm64 体检步与 `cargo build` 步、并在 `release-publish.yml` 裁剪前插了「发布名单守卫」
(于是它现在是 7 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,
翻牌时要改的仍是那两处)。
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
git 文件**:真值放仓库 Secret,由 `release.yml` 的 `build` job 按名显式透传(可复用工作流拿不到未显式传入的
@@ -21,7 +21,7 @@
| 平台 | 本版发布 | 级别 | 运行时要求 |
|------|---------|------|-----------|
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
| Windows 11 ARM64 | ❌ 不发布(构建取证中,待修) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| Windows 11 ARM64 | ❌ 不发布(构建取证中) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
@@ -34,7 +34,12 @@
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release-publish.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
(M164)`windows-arm64` 为什么还没进名单:它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的
`um/arm64/kernel32.lib`),`cargo build` 一次都没跑过;M164 把那个门禁降级为只取证、判定权交回编译器,但「能不能
出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向现在都有守卫(`packaging/check_publish_slots.py`):
声明了缺产物 ⇒ 红,有产物没声明 ⇒ `::warning::` 明说未发布 —— 所以那次 CI 真出包之后,翻一行名单即可。
## 10. 已知边界 / 后续
+6 -2
View File
@@ -120,5 +120,9 @@ libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
M141/M142 保留了编译前体检(查 VC 的 `lib/arm64/libcmt.lib` 与 SDK 的 `um/arm64/kernel32.lib`,无论齐不齐都先打 4 行
实况注解)。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),而是 arm64 注解转绿 + 把键加回 `PUBLISH_SLOTS`。
**M164 的更正**:M141/M142 那个编译前体检不但没帮上忙,还是 arm64 五轮全红的唯一原因(`find` 猜 SDK 的
`um/arm64/kernel32.lib`,猜缺就 `exit 1`,而 `cargo build` 永远 skipped);M164 把它降级为只取证(打印 vswhere 组件、
目录 `.lib` 计数、`link.exe` 候选),红绿交回编译器。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),
而是 arm64 的 `cargo build` 真跑绿 + 把键加回 `PUBLISH_SLOTS`(守卫会自动跟着扩,并在产物有了却没声明时报 `::warning::`)。
本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(8 条注入回归)与
`cargo check --target aarch64-pc-windows-msvc`(依赖图对该 target 的类型检查,**不链接、不产出 PE**)。
+16 -10
View File
@@ -33,11 +33,16 @@ Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — t
| --- | --- | --- |
| Windows 10/11 x64 | `unlucky-launcher_{{VERSION}}_windows_x64.exe` | single exe, double-click to run; this is also what you run on ARM64 Windows (x64 emulation) |
**Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner for
evidence, but that slot's toolchain check has not gone green yet, so
**Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner (Windows 11
Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet, so
`unlucky-launcher_{{VERSION}}_windows_arm64.exe` is deliberately kept out of the download list and out of
`SHA256SUMS.txt` instead of handing out a build nobody has verified. `windows-x64` is the only published
slot; ARM64 rejoins this table as soon as its build is green (one line in the workflow's `PUBLISH_SLOTS`).
`SHA256SUMS.txt` instead of handing out a build nobody has verified. Worth stating plainly: every ARM64 failure
so far happened in a **pre-build toolchain probe** that guessed whether the SDK's ARM64 import libraries were
present and aborted before `cargo build` ever ran — the compiler and linker have not been heard from yet. That
probe is now evidence-only (it prints what it sees and never decides the job), so the next CI run is the first
one that can actually answer the question. `windows-x64` is the only published slot; ARM64 rejoins this table as
soon as a run produces its artifact (one line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by
`packaging/check_publish_slots.py`).
**This release ships Windows only, and CI builds Windows only.** The Linux (x64 / arm64) and macOS
(Apple Silicon / Intel) slots are not built by the release workflow at all — they have not been verified
@@ -79,7 +84,7 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt
| Platform | Status in this release | Level | Runtime requirements |
| --- | --- | --- | --- |
| Windows 10/11 x64 | **published** | first class | nothing beyond the OS (UCRT is part of Win10+) |
| Windows 11 ARM64 | not published (build chasing, unverified) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
| Windows 11 ARM64 | not published (build evidence only) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
| macOS 12+ Apple Silicon | not built (unverified) | first class (unverified) | nothing; unsigned build, needs the Gatekeeper workaround |
| macOS 12+ Intel | not built (unverified) | first class (unverified) | same as above |
| Linux x64, X11 | not built (unverified) | first class (unverified) | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
@@ -89,11 +94,12 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) |
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green
(see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and
Linux arm64 is not built by default — someone has to run it on real hardware (via a manual all-slots run)
before it joins this list. The rows above and the matrix table in `packaging/README.md` §9 are the same
list — change one, change the other.
`windows-x64` is the only published slot this release; Windows ARM64 stays out until a CI run actually produces
its artifact (see Downloads above — so far every ARM64 failure was the pre-build probe, never the compiler).
Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not built by
default — someone has to run it on real hardware (via a manual all-slots run) before it joins this list. The
rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same list —
change one, change the other.
## Install