diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml new file mode 100644 index 0000000..e62553d --- /dev/null +++ b/.github/workflows/release-guard.yml @@ -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 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 7cc057b..ae77d73 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -8,8 +8,11 @@ name: Release / publish(可复用) # ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。 # 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。 # ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个 -# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与 -# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是 +# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由 +# `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」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是 # 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。 # 取 event 上的字符串形态既保住三态,又不需要任何类型转换。 @@ -140,33 +143,22 @@ jobs: run: | set -euo pipefail base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md) - # pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push - # 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。 - # 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。 - create_mark=() - edit_mark=() - case "${PRERELEASE:-}" in - true) - create_mark=(--prerelease) - edit_mark=(--prerelease) - ;; - false) - edit_mark=(--prerelease=false) - ;; - "") - create_mark=(--prerelease) - ;; - *) - echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE" - exit 1 - ;; - esac + # ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ── + # 三种触发各自创建什么(那张表是唯一真相源): + # push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记 + # dispatch prerelease=true → create / edit 都带预发布标记 + # dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记 + # 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而 + # 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布, + # 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。 + # 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红, + # 进程替换里的失败会被 `set -e` 吞掉。 + marks=/tmp/release-marks.sh + python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks" + source "$marks" + echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})" # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # 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) if gh release view "$TAG" >/dev/null 2>&1; then # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ea7b91..861f22c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Release # 发布流水线: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 连试 # 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都 # 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 @@ -28,9 +29,13 @@ on: type: boolean default: false 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 - default: true + default: false env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index 7f65aba..9c87a6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] name = "unlucky-agent" -version = "0.2.2" +version = "0.2.3" dependencies = [ "axum", "futures-util", @@ -4997,7 +4997,7 @@ dependencies = [ [[package]] name = "unlucky-app-iced" -version = "0.2.2" +version = "0.2.3" dependencies = [ "iced", "image", @@ -5011,7 +5011,7 @@ dependencies = [ [[package]] name = "unlucky-cli" -version = "0.2.2" +version = "0.2.3" dependencies = [ "serde", "serde_json", @@ -5022,7 +5022,7 @@ dependencies = [ [[package]] name = "unlucky-core" -version = "0.2.2" +version = "0.2.3" dependencies = [ "flate2", "futures-util", @@ -5041,15 +5041,15 @@ dependencies = [ [[package]] name = "unlucky-design" -version = "0.2.2" +version = "0.2.3" [[package]] name = "unlucky-i18n" -version = "0.2.2" +version = "0.2.3" [[package]] name = "unlucky-palette-extract" -version = "0.2.2" +version = "0.2.3" dependencies = [ "image", "unlucky-design", @@ -5057,7 +5057,7 @@ dependencies = [ [[package]] name = "unlucky-presenter" -version = "0.2.2" +version = "0.2.3" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 709910c..c85314a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ members = [ exclude = ["crates/app"] [workspace.package] -version = "0.2.2" +version = "0.2.3" edition = "2021" license = "Apache-2.0" authors = ["AskaEth "] diff --git a/README.md b/README.md index b1d1f3b..0adf18f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ > **Official project home**: | downloads, issues and > release notes all live there: +> +> **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 (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 game arguments, a native-library pass, and a JSONL session record for every run. Process trees are 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 (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 @@ -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 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 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, 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/`. 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 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 @@ -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 (`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`, -`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 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 - newer version for you, and there is no update source decided yet. +- **No installer.** You download a file and run it — there is no setup step, and updates replace that + 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*, *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 @@ -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 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. -- **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. - **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 @@ -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/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-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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 62ca718..29db965 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,6 +4,8 @@ > **官方项目主页**: | 下载、议题、发布说明都在这里: > +> +> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 @@ -33,6 +35,11 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, 每次运行留一份 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`、 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 @@ -44,8 +51,15 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 +- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40 + 条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重 + 写,导入报告事后仍逐条列出全部冲突。 - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, 以及一键**装入这个实例**。 +- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、 + 加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/` 补全。不需要 API key, + 也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」, + 绝不说成「没有匹配的内容」。 - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 禁用态,并把不匹配写清楚。没有一条是静默失败的。 @@ -129,14 +143,35 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 -`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、 -`version`、`help`。 +`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、 +`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] 占位动作 …`,什么都不改 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 @@ -154,8 +189,6 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 `静默失败` 就是它。 - **启动器内的助手背后没有模型**(见上)。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`)。 @@ -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/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-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 | ## 架构与模块地图 diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index d34e89b..9172a72 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -13,7 +13,7 @@ publish.workspace = true # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 [dependencies] -unlucky-core = { path = "../core", version = "0.2.2" } +unlucky-core = { path = "../core", version = "0.2.3" } # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 工具结果 / 审计记录 / 事件流都用 serde derive。 diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index ecba70b..c01ccea 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -22,15 +22,15 @@ path = "src/lib.rs" [dependencies] # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 -unlucky-design = { path = "../design", version = "0.2.2" } +unlucky-design = { path = "../design", version = "0.2.3" } # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 -unlucky-core = { path = "../core", version = "0.2.2" } +unlucky-core = { path = "../core", version = "0.2.3" } # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 与 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**。 # 关掉默认 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 → # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 -unlucky-cli = { path = "../cli", version = "0.2.2" } +unlucky-cli = { path = "../cli", version = "0.2.3" } # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # 所以系统字体扫描留在这里(`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:#?}")`, # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 7c7e041..fa50efc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -22,10 +22,10 @@ path = "src/lib.rs" # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 [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 双投影, # 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 运行时)。 tokio.workspace = true serde.workspace = true diff --git a/crates/palette-extract/Cargo.toml b/crates/palette-extract/Cargo.toml index 9dc2956..42ee0e9 100644 --- a/crates/palette-extract/Cargo.toml +++ b/crates/palette-extract/Cargo.toml @@ -12,7 +12,7 @@ publish.workspace = true # 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 # 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 [dependencies] -unlucky-design = { path = "../design", version = "0.2.2" } +unlucky-design = { path = "../design", version = "0.2.3" } # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # - png:启动器背景图最常见的无损格式,也是截图通道用的格式; diff --git a/crates/presenter/Cargo.toml b/crates/presenter/Cargo.toml index 1b6ff3d..921446d 100644 --- a/crates/presenter/Cargo.toml +++ b/crates/presenter/Cargo.toml @@ -22,7 +22,7 @@ serde_json.workspace = true # 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 -unlucky-core = { path = "../core", version = "0.2.2" } +unlucky-core = { path = "../core", version = "0.2.3" } [lints] workspace = true diff --git a/packaging/README.md b/packaging/README.md index f14cc08..2e05dd6 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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 --json isPrerelease`),守门是 +`packaging/check_release_semantics.py`(CI:`.github/workflows/release-guard.yml`)。 diff --git a/packaging/check_release_semantics.py b/packaging/check_release_semantics.py new file mode 100644 index 0000000..dff6b3c --- /dev/null +++ b/packaging/check_release_semantics.py @@ -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:])) diff --git a/packaging/docs/build-and-ci.md b/packaging/docs/build-and-ci.md index de1c985..81f76e3 100644 --- a/packaging/docs/build-and-ci.md +++ b/packaging/docs/build-and-ci.md @@ -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 --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 -f prerelease=false` + (落到 edit 分支显式断言)② 直接 `gh release edit --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 **不进 diff --git a/packaging/docs/profiles-and-measurements.md b/packaging/docs/profiles-and-measurements.md index 4202244..efc24fc 100644 --- a/packaging/docs/profiles-and-measurements.md +++ b/packaging/docs/profiles-and-measurements.md @@ -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 体积与冷构建耗时(实测字节数,不是估算) diff --git a/packaging/release-notes.md b/packaging/release-notes.md index 26263af..4e74394 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -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 `___`, 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`. diff --git a/packaging/release_marker.py b/packaging/release_marker.py new file mode 100644 index 0000000..2bc52cf --- /dev/null +++ b/packaging/release_marker.py @@ -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:]))