Files
Unlucky-Launcher/packaging/check_release_semantics.py
T
AskaEth 1d2e84ed61 fix(m163): push tag 默认建稳定版(发布标记语义 + 0.2.3 bump)
发布标记:`release.yml` 的 dispatch `prerelease` 默认值 true → false;`release-publish.yml`
的 create 路径不再塞 `--prerelease`(空串 = push tag ⇒ 稳定版),edit 路径行为不变(空串不碰
标记)。三种触发各创建什么改由 `packaging/release_marker.py` 一张表定义,流水线
`--emit-bash` 出来两个数组直接用,自己不再写 case——v0.2.2 至今 `releases/latest` 404 的根因
就是「push 默认 prerelease」这层叠上「dispatch 默认 true」。

守门:新增 `packaging/check_release_semantics.py`(CI 在 `.github/workflows/release-guard.yml`,
guards.yml 已 398 行不能再挂 job):①标记表逐条对(push tag 的 create 不含 --prerelease);
②把 `--emit-bash` 真灌进 bash 读回两个数组;③publish 的可执行内容里没有 --prerelease 字面量、
必须调用 emit-bash、两个数组各喂 create/edit;④release.yml 的 dispatch 默认值 == false(读
YAML 结构)。`--self-mutation` 注入三项真实回归(默认值改回 true / 塞回字面量 / 表里 push
改成预发布),断言每项都红、基线绿。

版本:0.2.2 → 0.2.3(workspace `[workspace.package]` + 五处 path 依赖 pin + Cargo.lock)。

文档:README 中英两版版本行改 0.2.3,并把本阶段能力补进现有章节(Modrinth 匿名在线内容源、
自更新与稳定通道、整合包导入逐文件决策、实例级启动参数),删掉「没有自动更新通道」「导入只有
两份全局策略」两条过期说法;`release-notes.md` 写 0.2.3 的真实更新摘要(保留 {{VERSION}} /
{{SUMS}});`packaging/README.md` 与 `docs/build-and-ci.md` 里「push 默认仍 prerelease」的旧口径
全部改掉。
2026-11-07 18:56:01 +08:00

392 lines
16 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
"""发布标记语义守门(M163):**push tag 默认创建稳定版**,这条不是靠注释保证的。
四条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`):
1. **标记表**:直接调 `packaging/release_marker.py::marks()`——
push tag(空串)的 create 标记里不许出现 `--prerelease`;`prerelease=true` 两个分支都带;
`prerelease=false` 的 create 稳定、edit 显式 `--prerelease=false`;非法取值必须抛。
2. **bash 往返**:把 `release_marker.py --emit-bash` 的输出真的 `source` 进 bash,读回
`create_mark` / `edit_mark` 两个数组。第 1 条只证明函数对,这一条证明**流水线跑的那段代码**对
(数组拼错、`source` 漏了、`set -u` 炸掉都在这条里现原形)。
3. **流水线必须用这份表**:`release-publish.yml` 的**可执行内容**里不许有 `--prerelease` 字面量
(注释里讲清楚为什么可以),必须调用 `--emit-bash`,且两个数组分别喂给 `gh release create/edit`。
否则表改得再对,流水线也可能自己写一套。
4. **dispatch 默认值 == false**:读 `release.yml` 的 YAML 结构(`on.workflow_dispatch.inputs.prerelease.default`),
不是 grep 字符串——键被搬走 / 改名 / 缺省会当场红。
`--self-mutation` 是这条守门的自测:把四个文件复制到临时目录,逐项注入真实回归(默认值改回
`true`、publish 里塞回 `--prerelease`、标记表的 push 分支改成预发布),断言守门**每一项都红**,
基线**绿**。变异自测本身失败也 exit 1——「守门抓不住」与「代码坏了」是两件事,不能混。
"""
from __future__ import annotations
import importlib.util
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent
ROOT = PACKAGING.parent
RELEASE_YML = Path(".github/workflows/release.yml")
PUBLISH_YML = Path(".github/workflows/release-publish.yml")
MARKER_REL = Path("packaging/release_marker.py")
#: release.yml 里「dispatch 的 prerelease 默认值」在 YAML 结构中的位置。
DEFAULT_PATH = ("on", "workflow_dispatch", "inputs", "prerelease", "default")
#: 三种触发方式各自该展开成什么(与 release_marker.py 的表同源,这里独立写一份是为了对拍)。
EXPECTED = {
"": ((), ()),
"true": (("--prerelease",), ("--prerelease",)),
"false": ((), ("--prerelease=false",)),
}
# ────────────────────────── 1. 标记表(语义断言) ──────────────────────────
def load_marker(root: Path):
"""从 `root` 里加载 release_marker.py(变异自测要能加载被改坏的那份)。"""
path = root / MARKER_REL
spec = importlib.util.spec_from_file_location(f"_release_marker_{hash(path)}", path)
if spec is None or spec.loader is None:
raise SystemExit(f"::error::加载不了 {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def check_table(root: Path) -> list[str]:
module = load_marker(root)
problems: list[str] = []
for raw, (create, edit) in EXPECTED.items():
got_create, got_edit = module.marks(raw)
trigger = "push tag(空串)" if raw == "" else f'dispatch prerelease="{raw}"'
if tuple(got_create) != create or tuple(got_edit) != edit:
problems.append(
f"{trigger}:期望 create={create} edit={edit},实际 create={tuple(got_create)} edit={tuple(got_edit)}"
)
# 单独立一条:这条是 v0.2.2 至今 releases/latest 404 的根因,报告里点名它。
push_create, _ = module.marks("")
if "--prerelease" in push_create:
problems.append("push tag 的 create 标记带上了 --prerelease:稳定通道会一直是 404")
for bad in ("yes", "1", "TRUE", "False", "0"):
try:
module.marks(bad)
except ValueError:
continue
problems.append(f"非法取值 {bad!r} 没有被拒绝(会静默变成某个发布标记)")
if not module.CASES:
problems.append("release_marker.CASES 是空的:对外承诺的那张表不见了")
return problems
# ────────────────────────── 2. bash 往返(真跑那段代码) ──────────────────────────
BASH_ROUNDTRIP = r"""
set -uo pipefail
marks=$(mktemp)
"$PYTHON" "$MARKER" --emit-bash --prerelease "$RAW" > "$marks" || { echo "emit failed"; exit 1; }
# shellcheck disable=SC1090
source "$marks"
printf 'create=%s\n' "${create_mark[*]:-}"
printf 'edit=%s\n' "${edit_mark[*]:-}"
rm -f "$marks"
"""
def find_bash() -> str | None:
"""找到「真的 bash」,而不是 Windows 上 System32 里那个 WSL 壳(它不认这套环境)。"""
candidates = [
os.environ.get("BASH"),
shutil.which("bash"),
"C:/Program Files/Git/bin/bash.exe",
"C:/Program Files/Git/usr/bin/bash.exe",
]
for candidate in candidates:
if not candidate:
continue
if "system32" in candidate.replace("/", "\\").lower():
continue
if Path(candidate).is_file() or shutil.which(candidate):
return candidate
return None
BASH = find_bash()
def bash_marks(marker: Path, raw: str) -> tuple[str, str] | None:
if BASH is None:
return None
# 解释器用 sys.executable 而不是写死 python3:CI(ubuntu)上两者等价,本机 Windows 只有
# `python`,写死 python3 会让这条断言在本地跑不起来——一条本地跑不了的守门等于没有。
env = dict(
os.environ,
MARKER=str(marker),
RAW=raw,
PYTHON=sys.executable.replace("\\", "/"),
PYTHONIOENCODING="utf-8",
)
proc = subprocess.run(
[BASH, "-c", BASH_ROUNDTRIP], env=env, capture_output=True, text=True, encoding="utf-8"
)
if proc.returncode != 0:
return None
values = {}
for line in proc.stdout.splitlines():
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
if "create" not in values or "edit" not in values:
return None
return values["create"], values["edit"]
def check_bash_roundtrip(root: Path) -> list[str]:
marker = root / MARKER_REL
problems: list[str] = []
if BASH is None:
return ["找不到 bash:这条断言要真跑流水线里那段 source,不能跳过(CI 是 ubuntu-latest,一定有)"]
for raw, (create, edit) in EXPECTED.items():
got = bash_marks(marker, raw)
want = (" ".join(create), " ".join(edit))
trigger = "push tag(空串)" if raw == "" else f'dispatch prerelease="{raw}"'
if got is None:
problems.append(f"{trigger}:--emit-bash 的产物 source 进 bash 失败")
elif got != want:
problems.append(f"{trigger}:bash 展开成 create={got[0]!r} edit={got[1]!r},期望 create={want[0]!r} edit={want[1]!r}")
return problems
# ────────────────────────── 3. 流水线必须用这份表 ──────────────────────────
def executable_text(text: str) -> str:
"""去掉整行注释后的内容:注释里写 `--prerelease` 是解释,可执行位置上出现才是回归。"""
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def check_pipeline(root: Path) -> list[str]:
path = root / PUBLISH_YML
if not path.is_file():
return [f"找不到 {PUBLISH_YML}(发布流水线搬家了?这条守门要跟着搬)"]
text = path.read_text(encoding="utf-8")
live = executable_text(text)
problems: list[str] = []
hits = [
line.strip()
for line in live.splitlines()
# 唯一允许出现它的位置:把值喂给表自己的那一行(`release_marker.py ... --prerelease "$PRERELEASE"`)。
# 其它任何可执行位置(`gh release` 的旗子、自己写的 case 分支)都是回归。
if "--prerelease" in line and "release_marker.py" not in line
]
if hits:
problems.append(
"release-publish.yml 的可执行内容里出现了 --prerelease 字面量(标记必须只由 "
"packaging/release_marker.py 产出):" + " | ".join(hits)
)
if "release_marker.py --emit-bash" not in live:
problems.append("release-publish.yml 没有调用 packaging/release_marker.py --emit-bash")
for array in ('"${create_mark[@]}"', '"${edit_mark[@]}"'):
if array not in live:
problems.append(f"release-publish.yml 没有把 {array} 喂给 gh release(create / edit 各要一个)")
if "github.event.inputs.prerelease" not in live:
problems.append("release-publish.yml 不再从 github.event.inputs.prerelease 取值(三态语义的来源没了)")
return problems
# ────────────────────────── 4. dispatch 默认值 == false ──────────────────────────
def yaml_scalars(text: str) -> dict[tuple[str, ...], tuple[str, int]]:
"""极简 YAML 结构读取:只认「缩进 → key: value」的标量行,返回 {路径: (值, 行号)}。
不引第三方依赖(CI 上只有裸 python3):本守门要读的是「某个键在哪个缩进层级上」,不是
完整 YAML 语义。块标量(`run: |`)的内容整段跳过,免得脚本里的 `a: b` 混进来。
"""
out: dict[tuple[str, ...], tuple[str, int]] = {}
stack: list[tuple[int, str]] = []
block_indent: int | None = None
for lineno, raw in enumerate(text.splitlines(), 1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
if block_indent is not None:
if indent > block_indent:
continue
block_indent = None
stripped = raw.strip()
if stripped == "-" or stripped.startswith("- "):
continue
if ":" not in stripped:
continue
key, _, value = stripped.partition(":")
key = key.strip().strip("'\"")
value = re.sub(r"\s+#.*$", "", value.strip())
while stack and stack[-1][0] >= indent:
stack.pop()
path = tuple(k for _, k in stack) + (key,)
if value in ("", "|", ">", "|-", ">-"):
stack.append((indent, key))
if value:
block_indent = indent
else:
out[path] = (value, lineno)
return out
def check_default(root: Path) -> list[str]:
path = root / RELEASE_YML
if not path.is_file():
return [f"找不到 {RELEASE_YML}"]
scalars = yaml_scalars(path.read_text(encoding="utf-8"))
if DEFAULT_PATH not in scalars:
return [
"release.yml 里读不到 "
+ ".".join(DEFAULT_PATH)
+ ":dispatch 的 prerelease 输入被搬走/改名/删了(默认值判据必须留在原处)"
]
value, lineno = scalars[DEFAULT_PATH]
if value.strip().strip("'\"").lower() != "false":
return [
f"release.yml:{lineno} dispatch 的 prerelease 默认值是 {value!r},必须是 false"
"(默认值一旦回 true,Actions 页面点一次的产物就是预发布)"
]
return []
# ────────────────────────── 汇总 ──────────────────────────
def run_checks(root: Path) -> list[str]:
problems: list[str] = []
for name, fn in (
("标记表(push tag ⇒ 稳定版)", check_table),
("bash 往返(流水线真跑的那段)", check_bash_roundtrip),
("流水线只从表里取标记", check_pipeline),
("dispatch 默认值 == false", check_default),
):
found = fn(root)
problems.extend(f"{name}:{item}" for item in found)
return problems
def report(root: Path) -> int:
problems = run_checks(root)
for item in problems:
print(f"::error::{item}", file=sys.stderr)
if problems:
print(f"发布标记语义守门:{len(problems)} 条不通过", file=sys.stderr)
return 1
marker = load_marker(root)
print("OK: 发布标记语义守门全绿")
print(marker.table())
return 0
# ────────────────────────── 变异自测 ──────────────────────────
def snapshot(dst: Path) -> None:
"""把守门依赖的四个文件复制进 `dst`(只复制这四个,保持临时树最小)。"""
for rel in (RELEASE_YML, PUBLISH_YML, MARKER_REL, Path("packaging") / Path(__file__).name):
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / rel, target)
def mutate_default_true(root: Path) -> str:
path = root / RELEASE_YML
text = path.read_text(encoding="utf-8")
scalars = yaml_scalars(text)
_, lineno = scalars[DEFAULT_PATH]
lines = text.splitlines()
lines[lineno - 1] = lines[lineno - 1].replace("false", "true")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return "release.yml 的 dispatch 默认值改回 true"
def mutate_inline_flag(root: Path) -> str:
path = root / PUBLISH_YML
text = path.read_text(encoding="utf-8")
path.write_text(text + " create_mark+=(--prerelease)\n", encoding="utf-8")
return "release-publish.yml 里塞回 --prerelease 字面量"
def mutate_marker_push(root: Path) -> str:
path = root / MARKER_REL
text = path.read_text(encoding="utf-8")
old = ' if value == "":\n return (), ()\n'
new = ' if value == "":\n return (PRERELEASE,), ()\n'
if old not in text:
raise SystemExit(f"::error::变异自测自身失效:{MARKER_REL} 里找不到 push 分支的原样代码")
path.write_text(text.replace(old, new, 1), encoding="utf-8")
return "标记表的 push tag 分支改成预发布"
MUTATIONS = (mutate_default_true, mutate_inline_flag, mutate_marker_push)
def self_mutation() -> int:
with tempfile.TemporaryDirectory(prefix="ull-release-guard-") as tmp:
baseline = Path(tmp) / "baseline"
snapshot(baseline)
problems = run_checks(baseline)
if problems:
for item in problems:
print(f"::error::变异自测的基线本该是绿的:{item}", file=sys.stderr)
return 1
print("OK: 基线绿")
failures = 0
for index, mutate in enumerate(MUTATIONS):
root = Path(tmp) / f"mutation-{index}"
snapshot(root)
label = mutate(root)
if not run_checks(root):
print(f"::error::变异自测失败:注入「{label}」后守门仍然绿(这条守门抓不住它)", file=sys.stderr)
failures += 1
else:
print(f"OK: 注入「{label}」→ 守门红")
if failures:
return 1
print(f"OK: {len(MUTATIONS)} 项变异全部被抓红,基线绿")
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
args = list(argv)
if "--root" in args:
index = args.index("--root")
root = Path(args[index + 1]).resolve()
del args[index : index + 2]
if "--self-mutation" in args:
return self_mutation()
unknown = [arg for arg in args if arg not in ("-h", "--help")]
if unknown:
print(f"::error::不认识的参数:{unknown}", file=sys.stderr)
return 2
return report(root)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))