根因(拿到的真实证据,不是猜的):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。
345 lines
17 KiB
Python
345 lines
17 KiB
Python
#!/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:]))
|