发布标记:`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」的旧口径
全部改掉。
392 lines
16 KiB
Python
392 lines
16 KiB
Python
#!/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:]))
|