Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
name: CI
# 主干门禁:fmt / clippy / test / build 双平台矩阵。
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。
# 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48
# 那棵依赖树(本机单路冷编 ≈14 分钟;并行超配时表现为 `rustc` 退 1 且零诊断,
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
fmt:
name: fmt (rustfmt --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
clippy:
name: clippy (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings
test:
name: test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
- run: cargo test --workspace --all-targets
build:
name: build release (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
# 启动 GUI 进程,让软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
# M118(D26):这里原本绑死 Slint 的 `Window::take_snapshot`,打的是 Slint 版 bin
# `unlucky-launcher`;`crates/app` 退出 workspace 后那个 bin 不再存在,改用 iced 版
# `unlucky-launcher-iced` 自带的 `--screenshot` 通道(`crates/app-iced/src/shot/`)。
# 命令行参数刻意不动:iced 的 `cli.rs` 就是照 Slint 版的口径写的,`--data-dir` 也仍被接受(忽略)。
runtime-smoke:
name: runtime smoke (ubuntu x64, xvfb)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb xauth \
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
libx11-6 libxcursor1 libxrandr2 libxi6 \
fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
target/debug/unlucky-launcher-iced --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
# WindowServer 会话,GUI 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
# M118:bin 与 Linux 那条一样,从 Slint 版换成 iced 版(`unlucky-launcher-iced`)。
runtime-smoke-macos:
name: runtime smoke (macos arm64, probe)
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
timeout-minutes: 30
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
target/debug/unlucky-launcher-iced --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
+381
View File
@@ -0,0 +1,381 @@
name: Guards
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
# ① core-no-ui / presenter-no-ui / design-zero-dep —— 「manifest 文本 + `cargo tree -p <crate>`」
# 口径,被守的 crate 仍是 workspace 成员,依赖树与排除前逐行一致;
# ② file-size / unsafe-scope —— 走 `git ls-files` / `git grep`,与构建图无关,**仍然覆盖
# `crates/app/**`**(源码还在仓库里,就还得守它的行数与 unsafe 落点);
# ③ licenses —— 唯一走 workspace 依赖图的守卫。排除后 Slint 那棵树不再进图(实测 `cargo deny
# list` 里 `slint*` / `i-slint-*` 为 0 个),这与「不再随任何产物发布」一致;代价是它的许可证
# 不再被本门禁审 —— 真要重新纳入发布,得先把它加回 workspace,门禁随之恢复。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
core-no-ui:
name: core 零 UI 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门
if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then
echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)"
exit 1
fi
echo "OK: crates/core/Cargo.toml 无 UI 依赖"
- name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
exit 1
fi
echo "OK: unlucky-core 依赖树零 UI"
# presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。
# 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。
# M118 之后 Slint 版已被 workspace `exclude`,但本条**照样守得住**:它查的是 presenter 自己的
# manifest 与 `cargo tree -p unlucky-presenter`,与「谁在消费 presenter」无关;依赖树里一旦出现
# slint/iced/winit 仍会当场红。名单里的 slint 继续留着,就是为了防「把 UI 依赖偷偷塞进宿主层」。
# 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本,
# 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。
presenter-no-ui:
name: presenter 零 UI 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着
# 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表
# (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。
# 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
# 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围——
# 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt
cat /tmp/presenter-deps.txt
if grep -iE "$forbidden" /tmp/presenter-deps.txt; then
echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。"
exit 1
fi
echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖"
- name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。
if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?"
exit 1
fi
echo "$tree"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /'
echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。"
exit 1
fi
echo "OK: unlucky-presenter 依赖树零 UI"
# unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点
# (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。
# 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。
design-zero-dep:
name: design 零第三方依赖守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法)
run: |
set -uo pipefail
# design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree
# --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含
# `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/design/Cargo.toml > /tmp/design-deps.txt
if [ -s /tmp/design-deps.txt ]; then
cat /tmp/design-deps.txt
echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:"
sed 's/^/ - /' /tmp/design-deps.txt
echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)"
- name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal)
run: |
set -uo pipefail
if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?"
exit 1
fi
echo "$tree"
# 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行
# `unlucky-design v0.1.0 (/path/to/crates/design)`。
lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true)
others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true)
if [ -n "$others" ] || [ "$lines" -ne 1 ]; then
echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:"
printf '%s\n' "$others" | sed 's/^/ - /'
echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
# 判据必须是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope:
name: unsafe 落点守门(集合必须恰好一个文件)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
run: |
set -uo pipefail
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。
#
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
#
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
#
# 仍然抓不住的(已知边界,别当全覆盖):
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
#
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
# 红 #[doc = r#"a "b"#] 后跟真 allow
# 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow
# 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow
# 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow
# 红 以上三者的 CRLF 版本
# 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报)
scan() {
awk -v path="$1" '
# ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ----
# 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进,
# r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
#
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
# 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。
function step(s, n, i, c, nx, j, h, ok) {
c = substr(s, i, 1)
g_plain = 0
if (g_state == 1) { if (c == "\n") g_state = 0; return i }
if (g_state == 2) {
if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 }
if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 }
return i
}
if (g_state == 3) {
if (c == "\\") return i + 1
if (c == "\"" || c == "\n") g_state = 0
return i
}
if (g_state == 4) {
if (c == "\"") {
ok = 1
for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
if (ok) { g_state = 0; return i + g_hashes }
}
return i
}
if (g_state == 5) {
if (c == "\\") return i + 1
if (c == g_q || c == "\n") g_state = 0
return i
}
if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 }
if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 }
if (c == "\"") { g_state = 3; return i }
if (c == g_q) {
nx = substr(s, i + 1, 1)
if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i }
g_plain = 1
return i
}
if (c == "r" || c == "b") {
j = i + 1
if (c == "b") {
if (substr(s, j, 1) == "\"") { g_state = 3; return j }
if (substr(s, j, 1) == g_q) { g_state = 5; return j }
if (substr(s, j, 1) != "r") { g_plain = 1; return i }
j++
}
h = 0
while (substr(s, j, 1) == "#") { h++; j++ }
if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j }
g_plain = 1
return i
}
g_plain = 1
return i
}
function lex( s, line, n, i, j, k, ch, adepth, attr) {
s = ""
while ((getline line < path) > 0) s = s line "\n"
close(path)
n = length(s)
g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0
for (i = 1; i <= n; i++) {
i = step(s, n, i)
if (!g_plain || substr(s, i, 1) != "#") continue
j = i + 1
if (substr(s, j, 1) == "!") j++
if (substr(s, j, 1) != "[") continue
# 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。
# 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。
attr = ""; adepth = 0; k = j
while (k <= n) {
k = step(s, n, k)
if (g_plain) {
ch = substr(s, k, 1)
if (ch == "[") adepth++
else if (ch == "]") adepth--
attr = attr ch
if (adepth <= 0) break
}
k++
}
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1
i = k
}
return g_found
}
BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) }
'
}
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort)
echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
printf '%s\n' "$extra" | sed 's/^/ - /'
fi
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
fi
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
exit 1
fi
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
file-size:
name: 文件行数 ≤400 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 检查单文件行数
run: |
set -uo pipefail
LIMIT=400
failed=0
while IFS= read -r f; do
case "$f" in
LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;;
CHANGELOG*|*/CHANGELOG*) continue ;;
# README 是「对外介绍」,不是可拆分的模块:拆成多份反而更难读,用户令
# (2026-11-05)明确「不用 400 行红线压它,但要保证介绍不臃肿、逻辑完备」。
# 只豁免仓库根的 README*.md;docs/ 下的长文仍按模块拆分。
README.md|README.*.md) continue ;;
esac
n=$(wc -l < "$f")
if [ "$n" -gt "$LIMIT" ]; then
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1))
fi
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
if [ "$failed" -ne 0 ]; then
echo "共 $failed 个文件超限"
exit 1
fi
echo "OK: 全部受检文件 ≤$LIMIT 行"
licenses:
name: 依赖许可与来源(cargo-deny)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
# 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管
# M118:cargo-deny 走 **workspace 依赖图**,`crates/app` 被 exclude 后 Slint 那棵树不再进图
# (实测 `cargo deny list` 里 `slint*` / `i-slint-*` 命中 0 个),所以本门禁不再审 Slint 的
# RF-2.0 许可 —— 与「它不再随任何产物发布」一致。这不是回退:`deny.toml` 里为 Slint 留的
# allow 条目**故意没删**(删它得先确认没有别的路径重新引入),当前等于一条死条目。
# 若将来把 `crates/app` 加回 workspace,本门禁的覆盖会自动恢复。
command: check licenses bans sources
+369
View File
@@ -0,0 +1,369 @@
name: Release
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: build (${{ matrix.slot }})
strategy:
fail-fast: false
matrix:
include:
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
- slot: windows-x64
os: windows-latest
build_dir: target/release
rustflags: -C target-feature=+crt-static
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
# 槽位放宽刚立的门禁不值得;
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
- slot: windows-arm64
os: windows-latest
target: aarch64-pc-windows-msvc
build_dir: target/aarch64-pc-windows-msvc/release
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
- slot: linux-x64
os: ubuntu-22.04
build_dir: target/release
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
- slot: linux-arm64
os: ubuntu-22.04-arm
build_dir: target/release
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
build_dir: target/release
- slot: macos-x64
os: macos-15-intel
build_dir: target/release
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
- name: 追加交叉编译目标 ${{ matrix.target }}
if: matrix.target != ''
shell: bash
env:
TARGET: ${{ matrix.target }}
run: rustup target add "$TARGET"
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
- name: Windows ARM64 交叉工具链体检
if: matrix.target == 'aarch64-pc-windows-msvc'
shell: bash
run: |
set -euo pipefail
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
sdk_lib=""
if [ -n "$sdk_version" ]; then
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
fi
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
exit 1
fi
# M118(D26):**Slint 版停发**。旧写法 `--bin unlucky-launcher` 打的是 `crates/app`
# (Slint 退役树)的 bin —— 也就是说这条流水线一直在发 Slint 版二进制,而 D26 早已把
# iced 定为产品 UI。现在 GUI 来源只有 `unlucky-launcher-iced`;`crates/app` 也已在根
# Cargo.toml 被 `exclude`,不参与任何 workspace 构建(它连 `--manifest-path` 都编不了)。
# 对外产物名由 `packaging/make_artifacts.py` 的 `asset_stem()` 统一生成,格式是
# `<名字>_<版本号>_<平台>_<架构>`(用户令 2026-11-05):下划线分段,且**不带实现细节
# 字样**(产物名里不出现 `iced`)。`LAUNCHER_BIN` 只用来在构建产物里找文件。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
target_args=()
if [ -n "$TARGET" ]; then
target_args+=(--target "$TARGET")
fi
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
# M118:校验的是**构建产物**(iced bin 的原始文件名)。对外名 `unlucky-launcher`
# 由 packaging/make_artifacts.py 在落盘时才换上,那一步之后才存在。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 矩阵完整性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
# 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
# GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— 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::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
# `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi