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
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:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user