P2-1(核心:上一轮的 ② 在 git-bash 下是死代码):`cmd //c "\"${vcvars//\//\}\" arm64 …"`
不行——MSYS 把参数里的 `"` 改写成 `\"` 交给 cmd.exe,cmd 的 `/c` 引号剥离接不住,LIB/INCLUDE
全空,只剩 `set` 的空输出;于是 ③「显式 link.exe」在**没有 LIB** 的状态下生效,正是本步注释里
写的那种会造出 `LNK1181: cannot open input file 'kernel32.lib'` 的组合。改法(复审验过的路线):
往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper `.bat`(`call "<vcvarsall>" arm64 >/dev/null 2>&1` + `set`),
`cmd //c` 只收**不含引号**的相对文件名(先 cd 进去)——引号留在 .bat 内部由 cmd 解析,路径带空格
也不受影响。同源的 ③ 也补齐:`vswhere -find` 的 `vcvarsall.bat` 与两个 `link.exe` 候选都改成
「反斜杠形态 + 正斜杠形态各试一次」(和同文件 dumpbin 步同规矩),不再赌一种形态。
P2-2:cargo build 步末尾那条 `ls -l` 移到 `if [ "$SLOT" = "windows-arm64" ]` 门内(并加 `|| true`):
x64 是当前唯一在发的槽位,不给它加一个失败面。
P2-3:① `release-publish.yml` 步数改对——主线 7 步、本分支 8 步(`release.yml` 与
`packaging/docs/build-and-ci.md` 两处都改,并列出 8 步是哪几步);② `check_publish_slots.py`
的 report() 全部改走 **stdout**:注解(`::error::`/`::warning::`)在 stdout 上同样被 Actions 识别,
而分两个流写会让「表格 → 注解」的顺序不可控(原注释承诺的顺序当时并不成立)。顺带在
`build-and-ci.md` 的 arm64 工具链段补一句「`cmd //c` 别收带引号的路径、要 wrapper .bat」,
免得下一个 agent 再把 ② 写回死代码。
本地证据(同一 worktree 复跑):
- P2-1 新证据:把本步 `run:` 块原样抽出,用 `bash --noprofile --norc -eo pipefail`(与 runner 同
语义)、假 vswhere + **路径带空格**的假 `vcvarsall.bat`(真 cmd)跑:新写法 LIB/INCLUDE/linker
三行全部写进 `$GITHUB_ENV`、rc=0;同一套假件跑 e858664 的老写法 ⇒ LIB/INCLUDE 全空、只有 linker
(与复审结果一致)。降级路径也复测:vswhere 不存在 ⇒ 只 1 条 warning、rc=0;vswhere 不给
vcvarsall ⇒ ② warning、③ 仍拿到 Hostx64 兜底、rc=0。
- 体检步 5 场景(无 VS / vswhere 全成功 / 报错退出 / 垃圾+非零 / 路径不存在)全部 rc=0、0 条
`::error::`。
- `check_publish_slots.py --self-test` 8 条 + 警告断言全绿;`--dist` 六场景复测(x64-only 绿 /
多一份 arm64 绿+warning / 缺 CLI rc=1 / 空 dist rc=1 / `--slots` 漂移 rc=1 / `--dist` 非目录
rc=1),并且缺产物时输出顺序是「表格 → warning → error → 汇总」,stderr 为空。
- 7 个 workflow `yaml.safe_load` OK;全部 `run:` 块 `bash -n` OK;全仓 400 行扫描 0 个超限
(release-build.yml 316、check_publish_slots.py 345)。
- `check_release_semantics.py`(+`--self-mutation`)、`release_marker.py --self-test`、
`make_manifest.py --self-test`、`cargo metadata --no-deps` 全绿。
边界不变:arm64 产物仍**从未被构建过、也从未在真机跑过**;`PUBLISH_SLOTS` 仍是 `"windows-x64"`,
发布语义与 x64 静态 CRT 门禁未动。下一次 tag 的 arm64 仍可能红,红点大概率换到 C 依赖编译
(aws-lc-sys 的 CMake + aarch64 cl.exe)或链接器(LNK1181)——**不是「arm64 修好了」**。
346 lines
17 KiB
Python
346 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)
|
||
# 全部走 **stdout**:Actions 在 stdout 上同样识别 `::error::` / `::warning::`,而分两个流写会让
|
||
# 「表格 → 注解」的顺序在 CI 日志里不可控(stderr 往往整段先刷出来,注解就跑到它解释的那张表前面)。
|
||
for row in rows:
|
||
print(row)
|
||
for item in warnings:
|
||
print(f"::warning::{item}")
|
||
for item in errors:
|
||
print(f"::error::{item}")
|
||
if errors:
|
||
print(f"发布名单守卫:{len(errors)} 条不通过")
|
||
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:]))
|