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:
1 parent
5d5fe8aa73
commit
1d2e84ed61
18 files changed
+805
-74
No files matched your search
+14
-4
@@ -92,7 +92,17 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
|
||||
|
||||
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新
|
||||
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍
|
||||
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致;
|
||||
v0.2.2 起首个适用。
|
||||
**D51 定案(2026-11-06,方案甲)+ M163 语义修正(2026-11-07)**:GitHub 的 `releases/latest/download/*`
|
||||
只指向**非 prerelease** 的最新 Release。而自更新清单固定取 `releases/latest/download/manifest.json`,所以
|
||||
「新版本必须被标成稳定版,latest 才会指过去」——M163 之前 push tag 默认打 `--prerelease`,v0.2.2 因此至今
|
||||
`releases/latest` 404、更新通道取不到清单。现在的语义(唯一真相源 `packaging/release_marker.py`):
|
||||
|
||||
| 触发 | create(新建 Release) | edit(Release 已存在) |
|
||||
| --- | --- | --- |
|
||||
| `push` tag `v*`(含其 Re-run) | **稳定版**(不带标记) | 不碰标记 |
|
||||
| `workflow_dispatch` + `prerelease=true` | 预发布 | 显式改成预发布 |
|
||||
| `workflow_dispatch` + `prerelease=false`(默认) | 稳定版 | 显式改成稳定版 |
|
||||
|
||||
`v0.2.2` 那个 Release **保留为历史预发布不动**,稳定通道以 `v0.2.3` 为起点。发版 checklist 第六件 =
|
||||
确认 prerelease 标记与通道一致(`gh release view <tag> --json isPrerelease`),守门是
|
||||
`packaging/check_release_semantics.py`(CI:`.github/workflows/release-guard.yml`)。
|
||||
@@ -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:]))
|
||||
@@ -41,9 +41,9 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
|
||||
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
|
||||
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
|
||||
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.2`。
|
||||
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.3`。
|
||||
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
|
||||
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
|
||||
「分支 ref 的 dispatch 只构建不发布」与「tag ref 上 dispatch 能选标记」是两条互不冲突的规则。
|
||||
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
|
||||
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
|
||||
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
|
||||
@@ -60,20 +60,34 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
|
||||
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
|
||||
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
|
||||
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
|
||||
模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create`(**push tag 默认建稳定版**:不带任何标记)。
|
||||
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
|
||||
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
|
||||
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
|
||||
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
|
||||
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
|
||||
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.2 -f prerelease=false`
|
||||
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
|
||||
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
|
||||
- **预发布标记的语义表(M163,唯一真相源 `packaging/release_marker.py`)**:
|
||||
|
||||
| 触发 | create(新建 Release) | edit(Release 已存在) |
|
||||
| --- | --- | --- |
|
||||
| `push` tag `v*`(含其 Re-run) | **稳定版**(不带标记) | 不碰标记 |
|
||||
| `workflow_dispatch` + `prerelease=true` | 预发布 | 显式改成预发布 |
|
||||
| `workflow_dispatch` + `prerelease=false`(默认) | 稳定版 | 显式改成稳定版 |
|
||||
|
||||
空串必须是稳定版:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而自更新清单固定取
|
||||
`releases/latest/download/manifest.json`(D50)——push tag 打成预发布,稳定通道就一直是 404(v0.2.2 至今
|
||||
如此,M163 修的就是这条;**v0.2.2 那个 Release 保留为历史预发布不动**,稳定通道以 v0.2.3 为起点)。
|
||||
edit 的空串是「不碰」而不是「改成稳定版」:同 tag 重跑**不会**把用户手工提成稳定版的 Release 翻回预发布。
|
||||
要改已发版本的标记仍是两条等价路:① `gh workflow run release.yml --ref <tag> -f prerelease=false`
|
||||
(落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线,后续默认重跑不碰它)。
|
||||
- **这条语义有守门(M163)**:`packaging/check_release_semantics.py`(CI 在 `.github/workflows/release-guard.yml`)
|
||||
断言四件事:标记表逐条对(push tag 的 create 不含 `--prerelease`)、把 `release_marker.py --emit-bash` 的产物
|
||||
**真灌进 bash** 读回两个数组、`release-publish.yml` 的可执行内容里没有 `--prerelease` 字面量、dispatch 默认值
|
||||
`== false`(读 YAML 结构,不是 grep)。`--self-mutation` 再把「默认值改回 true」「塞回字面量」「表里 push 分支
|
||||
改成预发布」注入临时副本,断言守门**每一项都红**、基线绿——守门自己抓不住回归时它先红。
|
||||
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
|
||||
触发、`concurrency`、槽位表(`plan`)与两个 `uses:` 编排;`build` job 的 11 步逐字搬进
|
||||
`.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步
|
||||
逐字搬进 `.github/workflows/release-publish.yml`(同 `workflow_call`,`secrets.GITHUB_TOKEN` 改为调用方显式透传
|
||||
的 secret)。触发(`on`)、`env`、顶层 `permissions`、`concurrency` 与 `plan` 的槽位表**一字未动**;改槽位仍只改
|
||||
的 secret)。触发(`on`)、`env`、顶层 `permissions`、`concurrency` 与 `plan` 的槽位表在 M145 搬家时**一字未动**;
|
||||
后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表)。改槽位仍只改
|
||||
`release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,两个 arm64 修复时要改的仍是那两行)。
|
||||
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
|
||||
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
|
||||
|
||||
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.2_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
|
||||
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.3_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
|
||||
|
||||
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
|
||||
|
||||
|
||||
@@ -4,6 +4,26 @@ Rust-native **single-file** Minecraft launcher: iced 0.14 + Material 3 Expressiv
|
||||
(chat / setting edits / crash diagnosis) and an MCP server other agent tools can attach to.
|
||||
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
|
||||
|
||||
## What's new in {{VERSION}}
|
||||
|
||||
- **{{VERSION}} is the first release published as a stable release.** GitHub only points
|
||||
`releases/latest` at the newest **non-prerelease** release, so `0.2.2` — kept as a historical
|
||||
pre-release, untouched — never appeared there. A pushed tag now creates a stable release by default;
|
||||
a pre-release has to be asked for explicitly (`workflow_dispatch` with `prerelease=true`).
|
||||
- **Self-update works end to end.** `ull update check` / `ull update apply` and the settings panel's
|
||||
Update group share one core (`crates/core/src/update/`): manifest, version and channel admission,
|
||||
length + SHA256 verification, and an atomic rename-based swap that rolls back on failure. Only the
|
||||
stable channel is published today.
|
||||
- **Online content library (Modrinth, anonymous).** Search, project details and version lists come from
|
||||
Modrinth's v2 API with facets derived from the target instance, and a known hash completes a remote
|
||||
entry. Being offline is reported as "not connected", never as "no matching content".
|
||||
- **Per-file decisions when importing a modpack.** The pre-import scan lists every conflicting file in
|
||||
pages of 40, and you choose keep-all, overwrite-all, or per item; files that do not conflict are
|
||||
never rewritten, and the report names every conflict afterwards.
|
||||
- **Per-instance launch arguments.** JVM arguments, game arguments, memory, the Java executable and the
|
||||
graphics backend (`default` / `opengl` / `vulkan`) are set per instance, each field falling back on
|
||||
its own to the global setting and then the factory default. The CLI reads the same resolved values.
|
||||
|
||||
## Downloads
|
||||
|
||||
Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — the rule
|
||||
@@ -44,6 +64,14 @@ Each published slot also ships the command line as its own small bare asset,
|
||||
`config`, `java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set
|
||||
before it ever opens a window, so a desktop machine does not need `ull`.
|
||||
|
||||
## Updating
|
||||
|
||||
The launcher reads `releases/latest/download/manifest.json` on startup (switchable: `update.auto_check`)
|
||||
and shows four distinct outcomes: up to date, an update is available, a newer version exists but ships no
|
||||
artifact for this platform, or the check itself failed. `ull update check` prints the same verdict, and
|
||||
`ull update apply` downloads, verifies SHA256 and swaps the binary atomically, rolling back if the swap
|
||||
fails. Nothing installs without you asking for it.
|
||||
|
||||
## Support matrix
|
||||
|
||||
Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt`.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""发布标记(`gh release ... --prerelease`)的**唯一定义处**(M163)。
|
||||
|
||||
# 三种触发方式各创建什么
|
||||
|
||||
| 触发 | `github.event.inputs.prerelease` | 新建 Release(create) | 已存在 Release(edit) |
|
||||
| --- | --- | --- | --- |
|
||||
| push tag `v*`(以及它的 Re-run) | 空串(该字段不存在) | **稳定版**:不带任何标记 | 不传标记(**不碰**) |
|
||||
| `workflow_dispatch` + `prerelease=true` | `"true"` | 预发布 | 显式改成预发布 |
|
||||
| `workflow_dispatch` + `prerelease=false` | `"false"` | 稳定版 | 显式改成稳定版 |
|
||||
|
||||
空串必须是稳定版:GitHub 的 `releases/latest` **只指向非 prerelease 的最新 Release**,而自更新
|
||||
客户端把清单地址钉在 `releases/latest/download/manifest.json`(D50)。v0.2.2 之前 push tag 默认打
|
||||
`--prerelease`,于是 latest 一直 404、更新通道取不到清单——M163 修的就是这条语义。
|
||||
|
||||
edit 那一列的空串是「不碰」而不是「改成稳定版」:同 tag 重跑会落到 edit 分支,而用户手工把某个
|
||||
Release 提成稳定版之后,重跑**不应该**把它翻回预发布。
|
||||
|
||||
# 流水线里唯一的走法
|
||||
|
||||
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
|
||||
source "$marks" # 得到 create_mark=(...) / edit_mark=(...)
|
||||
|
||||
先落文件再 `source`,而不是 `source <(...)`:非法的 `prerelease` 取值必须让这一步**当场红**
|
||||
(`set -e` 只认前一条命令的退出码,进程替换里的失败会被吞掉)。
|
||||
|
||||
守门在 `packaging/check_release_semantics.py`:它调本文件的 `marks()` 逐条对表,再真的把
|
||||
`--emit-bash` 灌进 bash 看展开出来的两个数组。本文件的 `--self-test` 只查自己这一层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
#: 打预发布标记。
|
||||
PRERELEASE = "--prerelease"
|
||||
#: 显式摘掉预发布标记(`gh release edit` 用;`create` 不带标记就已经是稳定版)。
|
||||
STABLE = "--prerelease=false"
|
||||
|
||||
#: (触发方式,输入原始值,create 标记,edit 标记)——本仓对外承诺的那张表,逐行可对。
|
||||
CASES: tuple[tuple[str, str, tuple[str, ...], tuple[str, ...]], ...] = (
|
||||
("push tag v*(含其 Re-run)", "", (), ()),
|
||||
("workflow_dispatch + prerelease=true", "true", (PRERELEASE,), (PRERELEASE,)),
|
||||
("workflow_dispatch + prerelease=false", "false", (), (STABLE,)),
|
||||
)
|
||||
|
||||
|
||||
def marks(raw: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""`github.event.inputs.prerelease` 的原始字符串 → (create 标记, edit 标记)。
|
||||
|
||||
只认三种取值(空串 / `"true"` / `"false"`),其它一律抛 :class:`ValueError`——发布流水线
|
||||
宁可红在一次错误的 dispatch 输入上,也不要把一个自己没预期的值当成「稳定版」发出去。
|
||||
"""
|
||||
value = (raw or "").strip()
|
||||
if value == "":
|
||||
return (), ()
|
||||
if value == "true":
|
||||
return (PRERELEASE,), (PRERELEASE,)
|
||||
if value == "false":
|
||||
return (), (STABLE,)
|
||||
raise ValueError(
|
||||
f"非预期的 github.event.inputs.prerelease 取值:{raw!r}(只认 空串 / true / false)"
|
||||
)
|
||||
|
||||
|
||||
def bash_snippet(raw: str) -> str:
|
||||
"""把两个数组渲染成可直接 `source` 的 bash 片段。"""
|
||||
create, edit = marks(raw)
|
||||
return (
|
||||
"# 由 packaging/release_marker.py 生成,别手改:标记的全部判据都在那个脚本里。\n"
|
||||
f"create_mark=({' '.join(create)})\n"
|
||||
f"edit_mark=({' '.join(edit)})\n"
|
||||
)
|
||||
|
||||
|
||||
def table() -> str:
|
||||
"""把 :data:`CASES` 渲染成 markdown 表(`--table`;报告与文档直接抄它)。"""
|
||||
|
||||
def cell(flags: tuple[str, ...], empty: str) -> str:
|
||||
return "、".join(f"`{flag}`" for flag in flags) if flags else empty
|
||||
|
||||
rows = ["| 触发 | `prerelease` 输入 | create | edit |", "| --- | --- | --- | --- |"]
|
||||
for trigger, raw, create, edit in CASES:
|
||||
shown = "空串" if raw == "" else f'`"{raw}"`'
|
||||
rows.append(f"| {trigger} | {shown} | {cell(create, '(不带标记)')} | {cell(edit, '不碰标记')} |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""本文件自己这一层的断言(完整守门见 check_release_semantics.py)。"""
|
||||
failures: list[str] = []
|
||||
|
||||
for trigger, raw, create, edit in CASES:
|
||||
got = marks(raw)
|
||||
if got != (create, edit):
|
||||
failures.append(f"{trigger}:表里写 {(create, edit)},marks({raw!r}) 给的是 {got}")
|
||||
|
||||
# 最关键的一条:push tag(空串)的新建路径**不许**带预发布标记。它是 v0.2.2 至今
|
||||
# `releases/latest` 404 的根因,任何一次重构都不许把它带回来。
|
||||
push_create, push_edit = marks("")
|
||||
if PRERELEASE in push_create:
|
||||
failures.append(f"push tag 的 create 标记里出现了 {PRERELEASE}:稳定通道会一直是 404")
|
||||
if push_edit:
|
||||
failures.append(f"push tag 的 edit 标记应为「不碰」,实际是 {push_edit}")
|
||||
|
||||
# dispatch 显式 false 时,新建同样是稳定版(create 不需要 `--prerelease=false`:不带就已经是了)。
|
||||
false_create, false_edit = marks("false")
|
||||
if PRERELEASE in false_create:
|
||||
failures.append(f"dispatch prerelease=false 的 create 标记里出现了 {PRERELEASE}")
|
||||
|
||||
# 非法值必须炸,不许静默当稳定版(`True` 是 Python 的写法,Actions 的 boolean 输入给的是小写)。
|
||||
for bad in ("yes", "1", "TRUE", "False", "0", "prerelease"):
|
||||
try:
|
||||
marks(bad)
|
||||
except ValueError:
|
||||
continue
|
||||
failures.append(f"非法取值 {bad!r} 被当成了合法输入:{marks(bad)}")
|
||||
|
||||
if failures:
|
||||
for item in failures:
|
||||
print(f"::error::release_marker 自检失败:{item}", file=sys.stderr)
|
||||
return 1
|
||||
print("OK: release_marker 标记表自检通过(push tag → 稳定版)")
|
||||
print(table())
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
# Windows 控制台是 GBK:本文件的输出里有中文与箭头,缺了这行会在打印时抛
|
||||
# UnicodeEncodeError(与判据无关的崩溃)。CI 是 UTF-8,不受影响。
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(errors="backslashreplace")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="发布标记(--prerelease)的唯一定义处:打印 gh release 要带的参数。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prerelease",
|
||||
default="",
|
||||
metavar="VALUE",
|
||||
help='github.event.inputs.prerelease 的原值:空串 / "true" / "false"',
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--emit-bash",
|
||||
action="store_true",
|
||||
help="打印可直接 source 的 bash 片段(create_mark / edit_mark 两个数组)",
|
||||
)
|
||||
group.add_argument("--table", action="store_true", help="打印触发方式 ↔ 标记的对照表")
|
||||
group.add_argument("--self-test", action="store_true", help="只查本文件这一层的断言")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
if args.table:
|
||||
print(table())
|
||||
return 0
|
||||
|
||||
try:
|
||||
sys.stdout.write(bash_snippet(args.prerelease))
|
||||
except ValueError as error:
|
||||
# 走 stderr:stdout 在这种情况下会被重定向进 marks 文件,注解会丢。
|
||||
print(f"::error::{error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in new issue
Block a user