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:
1 parent
15a19bb375
commit
e858664fca
13 files changed
+577
-120
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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 的并列资产分发,条款同样随下载走。
|
||||
|
||||
@@ -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. 已知边界 / 后续
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user