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」的旧口径
全部改掉。
This commit is contained in:
AskaEth committed 2026-11-07 18:56:01 +08:00
1 parent 5d5fe8aa73
commit 1d2e84ed61
18 files changed
+805 -74

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
#!/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:]))