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

+40
View File
@@ -0,0 +1,40 @@
name: Release guard
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
#
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
#
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
release-semantics:
name: 发布标记语义守门(push tag → 稳定版)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 标记表自检(push tag ⇒ 稳定版)
run: python3 packaging/release_marker.py --self-test
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
run: python3 packaging/check_release_semantics.py
- name: 变异自测(守门抓不抓得住回归)
run: python3 packaging/check_release_semantics.py --self-mutation
+19 -27
View File
@@ -8,8 +8,11 @@ name: Release / publish(可复用)
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。 # ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。 # 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个 # ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与 # 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是 # `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是 # **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。 # 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。 # 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
@@ -140,33 +143,22 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md) base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push # ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。 # 三种触发各自创建什么(那张表是唯一真相源):
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。 # push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
create_mark=() # dispatch prerelease=true → create / edit 都带预发布标记
edit_mark=() # dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
case "${PRERELEASE:-}" in # 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
true) # 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
create_mark=(--prerelease) # 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
edit_mark=(--prerelease) # 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
;; # 进程替换里的失败会被 `set -e` 吞掉。
false) marks=/tmp/release-marks.sh
edit_mark=(--prerelease=false) python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
;; source "$marks"
"") echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。 # manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# D51 定案(2026-11-06,方案甲):GitHub 的 `releases/latest/download/*` 只指向**非
# prerelease** 的最新 Release。稳定版发布时显式 dispatch `prerelease=false`(latest 随之生效,
# 客户端地址不变);push tag 默认仍 prerelease(视为预发布观察,不进稳定通道)。
# 发版 checklist 第六件 = 确认 prerelease 标记与通道一致;v0.2.2 起首个适用。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md) assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
+8 -3
View File
@@ -1,7 +1,8 @@
name: Release name: Release
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 + # 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 +
# SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。 # SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 `--prerelease`,否则
# `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试 # **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都 # 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 # 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
@@ -28,9 +29,13 @@ on:
type: boolean type: boolean
default: false default: false
prerelease: prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)" # 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上——
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
type: boolean type: boolean
default: true default: false
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
Generated
+8 -8
View File
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,7 +4997,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5011,7 +5011,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5022,7 +5022,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
@@ -5041,15 +5041,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.2" version = "0.2.3"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.2" version = "0.2.3"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5057,7 +5057,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.2" version = "0.2.3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.2" version = "0.2.3"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
+51 -5
View File
@@ -4,6 +4,8 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and > **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases> > release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no (Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -43,6 +45,14 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and - **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID. killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals - **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the (exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -56,8 +66,17 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`). import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version, - **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*. loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the - **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -160,14 +179,42 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
that its typo took effect. The subcommand table lives in exactly one place that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help` (`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`, lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `agent`, `version`, `help`. `diag`, `config`, `java`, `update`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Not done yet ### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy. This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a - **No installer.** You download a file and run it — there is no setup step, and updates replace that
newer version for you, and there is no update source decided yet. file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)).
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
The first observation on the live stable channel happens after `0.2.3` ships.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset *Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
@@ -190,8 +237,6 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above. translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does. - **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Pack import has two global policies**, keep-user or overwrite, not a per-file choice; the import
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path. - **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path. - **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is - **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
@@ -297,6 +342,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) | | `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map ## Architecture and module map
+39 -5
View File
@@ -4,6 +4,8 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里: > **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases> > <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -33,6 +35,11 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。 每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、 - **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -44,8 +51,15 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。 以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。 禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -129,14 +143,35 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、 `install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
`version`、`help`。 `agent`、`version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 还没做 ### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。 - **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
(见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
@@ -154,8 +189,6 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。 `静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。 - **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
@@ -243,6 +276,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) | | `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) |
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
## 架构与模块地图 ## 架构与模块地图
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.2" } unlucky-core = { path = "../core", version = "0.2.3" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+5 -5
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.2" } unlucky-design = { path = "../design", version = "0.2.3" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.2" } unlucky-core = { path = "../core", version = "0.2.3" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.2" } unlucky-presenter = { path = "../presenter", version = "0.2.3" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -60,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.2" } unlucky-cli = { path = "../cli", version = "0.2.3" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.2" } unlucky-i18n = { path = "../i18n", version = "0.2.3" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.2" } unlucky-core = { path = "../core", version = "0.2.3" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, # agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。 # core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.2" } unlucky-agent = { path = "../agent", version = "0.2.3" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 # `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
+1 -1
View File
@@ -12,7 +12,7 @@ publish.workspace = true
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 # 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 # 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
[dependencies] [dependencies]
unlucky-design = { path = "../design", version = "0.2.2" } unlucky-design = { path = "../design", version = "0.2.3" }
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; # - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
+1 -1
View File
@@ -22,7 +22,7 @@ serde_json.workspace = true
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 # 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.2" } unlucky-core = { path = "../core", version = "0.2.3" }
[lints] [lints]
workspace = true workspace = true
+14 -4
View File
@@ -92,7 +92,17 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
后者在 Release 正文里已有,两处各写一份迟早漂。 后者在 Release 正文里已有,两处各写一份迟早漂。
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。 - 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新 **D51 定案(2026-11-06,方案甲)+ M163 语义修正(2026-11-07)**:GitHub 的 `releases/latest/download/*`
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍 只指向**非 prerelease** 的最新 Release。而自更新清单固定取 `releases/latest/download/manifest.json`,所以
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致; 「新版本必须被标成稳定版,latest 才会指过去」——M163 之前 push tag 默认打 `--prerelease`,v0.2.2 因此至今
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`)。
+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:]))
+24 -10
View File
@@ -41,9 +41,9 @@ python packaging/make_artifacts.py --slot windows-arm64 \
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false = - 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch 只跑两个 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'` 挡在发布之外,所以 分支 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) }}`:矩阵本身 - 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与 不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC + **原生** `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 与 它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。 `release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/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` - **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。 + `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run, - **预发布标记的语义表(M163,唯一真相源 `packaging/release_marker.py`)**:
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。 | 触发 | create(新建 Release) | edit(Release 已存在) |
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.2 -f prerelease=false` | --- | --- | --- |
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑 | `push` tag `v*`(含其 Re-run) | **稳定版**(不带标记) | 不碰标记 |
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。 | `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` 只留 - **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
触发、`concurrency`、槽位表(`plan`)与两个 `uses:` 编排;`build` job 的 11 步逐字搬进 触发、`concurrency`、槽位表(`plan`)与两个 `uses:` 编排;`build` job 的 11 步逐字搬进
`.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步 `.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步
逐字搬进 `.github/workflows/release-publish.yml`(同 `workflow_call`,`secrets.GITHUB_TOKEN` 改为调用方显式透传 逐字搬进 `.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 修复时要改的仍是那两行)。 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,两个 arm64 修复时要改的仍是那两行)。
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值 - **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进 `${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
+1 -1
View File
@@ -32,7 +32,7 @@
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。 构建环境: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 体积与冷构建耗时(实测字节数,不是估算) ### 7.1 体积与冷构建耗时(实测字节数,不是估算)
+28
View File
@@ -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. (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. 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 ## Downloads
Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — the rule 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 `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`. 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 ## Support matrix
Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt`. Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt`.
+171
View File
@@ -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:]))