Files
Unlucky-Launcher/packaging/check_publish_slots.py
T
AskaEth e858664fca 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。
2026-11-08 08:33:44 +08:00

345 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:]))