release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,336 @@
|
||||
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
|
||||
# ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ----
|
||||
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些
|
||||
# 变量零命中 ⇒ **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处
|
||||
# 常驻调用点。(另有一道保险:变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过。
|
||||
# 于是将来谁把下面出图那步重构掉,构建会红。)
|
||||
#
|
||||
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸
|
||||
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上
|
||||
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在
|
||||
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里
|
||||
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
|
||||
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shots="$RUNNER_TEMP/dl-shots"
|
||||
mkdir -p "$shots"
|
||||
render() { # $1=宽 $2=文件名
|
||||
cargo run -q -p unlucky-app-iced --example pages_demo -- --screen=download \
|
||||
--width="$1" --height=760 --screenshot="$shots/$2" \
|
||||
--screenshot-theme=light --software-renderer
|
||||
}
|
||||
# ready700:**不设** ULL_DL_DEMO(缺省就是 ready,还没选版本)
|
||||
render 700 ready700.png
|
||||
export ULL_DL_DEMO=options-fabric # 其余三张都是「选中版本 + 选项区展开」
|
||||
render 700 chips700.png
|
||||
render 736 compact736.png
|
||||
render 1180 wide1180.png
|
||||
for shot in ready700 chips700 compact736 wide1180; do
|
||||
test -s "$shots/$shot.png" || { echo "::error::$shot.png 没出图"; exit 1; }
|
||||
done
|
||||
ls -l "$shots"
|
||||
{
|
||||
echo "ULL_DL_READY_SHOT=$shots/ready700.png"
|
||||
echo "ULL_DL_CHIPS_SHOT=$shots/chips700.png"
|
||||
echo "ULL_DL_COMPACT_SHOT=$shots/compact736.png"
|
||||
echo "ULL_DL_OPTIONS_SHOT=$shots/wide1180.png"
|
||||
} >> "$GITHUB_ENV"
|
||||
# 单独跑一遍只为**取证**:日志里那行 `test result: ok. 4 passed` 是「守门真跑了」的
|
||||
# 直接证据(紧接着的 `--workspace` 那步会再跑一遍,热的,代价可忽略)。
|
||||
- name: 四条像素守门(已喂图,仅 Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: cargo test -p unlucky-app-iced --lib options_shot
|
||||
# 断言红的时候要先看到那张图(否则「像素不对」只有一句话,没有现场)。
|
||||
- name: 上传喂进守门的四张图(失败也传)
|
||||
if: always() && runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dl-shots
|
||||
path: ${{ runner.temp }}/dl-shots/
|
||||
if-no-files-found: warn
|
||||
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
|
||||
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
|
||||
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
||||
#
|
||||
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
|
||||
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
|
||||
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
|
||||
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
|
||||
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
|
||||
# 事各自对准一种「看不见」:
|
||||
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
|
||||
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
|
||||
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
|
||||
# 注解是匿名侧唯一能取到的失败正文;
|
||||
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
|
||||
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
|
||||
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
log="$RUNNER_TEMP/cargo-test.log"
|
||||
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
|
||||
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
|
||||
# 不是 tee 的码)。
|
||||
set +e
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "cargo exit=$code"
|
||||
if [ "$code" -ne 0 ]; then
|
||||
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
|
||||
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
|
||||
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
|
||||
fi
|
||||
exit "$code"
|
||||
- name: cargo test --workspace --all-targets(windows,失败升注解)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
|
||||
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
|
||||
ULL_XDEV_TEST_WAIVE: "1"
|
||||
run: |
|
||||
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
|
||||
# 不会把它改掉,所以它是 cargo 自己的码。
|
||||
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
|
||||
$code = $LASTEXITCODE
|
||||
Write-Host "cargo exit=$code"
|
||||
if ($code -ne 0) {
|
||||
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
|
||||
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
|
||||
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
|
||||
Select-Object -First 40 |
|
||||
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
|
||||
}
|
||||
exit $code
|
||||
|
||||
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
|
||||
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
|
||||
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
|
||||
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
|
||||
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PY=$(command -v python3 || command -v python)
|
||||
"$PY" packaging/check_pe_subsystem.py \
|
||||
--gui target/release/unlucky-launcher-iced.exe \
|
||||
--cli target/release/ull.exe
|
||||
|
||||
# 运行时冒烟(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
|
||||
|
||||
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
|
||||
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
|
||||
#
|
||||
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
|
||||
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
|
||||
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
|
||||
# (r1 复审 P2-4)。
|
||||
#
|
||||
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
|
||||
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
|
||||
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
|
||||
#
|
||||
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
|
||||
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
|
||||
update-e2e:
|
||||
name: update-e2e (windows x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
|
||||
ULL_XDEV_TEST_WAIVE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: update-e2e
|
||||
run: >
|
||||
cargo test --release -p unlucky-cli --features update-test-source
|
||||
--test update_e2e_test --test update_e2e_tamper_test
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Dev Guards
|
||||
|
||||
# 日常线门禁(M133)。
|
||||
#
|
||||
# 为什么需要这个文件:本仓日常开发线是 `dev`,日常合并**不走 PR**(见 DevDocs/AGENTS.md §8 双仓纪律:
|
||||
# Gitea `dev` = 日常线)。而 ci.yml 与 guards.yml 只挂在 `push: branches: [main]` + `pull_request` 上
|
||||
# ⇒ fmt / clippy / 文件行数≤400 / 三条零依赖守门在**日常线上一次都没跑过**。第一个可见后果:
|
||||
# `packaging/README.md` 涨到 401 行、超全仓红线无人拦,直到文档普查才被发现(M131 才修回 398)。
|
||||
# 「门禁存在但日常线上永不运行」= 空跑的门禁,长期等于没有。
|
||||
#
|
||||
# 口径:**只补「快、且坏了会静默腐化」的那几道**,且不复制守门脚本:
|
||||
# - `guards` 作业直接 `uses: ./.github/workflows/guards.yml` —— 六道守门(core-no-ui /
|
||||
# presenter-no-ui / design-zero-dep / unsafe-scope / file-size / licenses)跑的是与 main 同一份脚本,
|
||||
# dev 与 main 的判据永远同源。main / PR / dispatch 的触发与作业一个字没动。
|
||||
# - `fmt` / `clippy` 在本文件里各写一份:ci.yml 的作业背后挂着 build / test / runtime-smoke 整条矩阵,
|
||||
# 整份复用等于把 dev 的成本翻倍;这两条命令与 ci.yml 逐字一致,方便对照与同步。
|
||||
#
|
||||
# 刻意**不上** dev 的(判断依据见 M133 报告,这里只记结论):
|
||||
# - 全量 `cargo test`:`clippy --all-targets` 已经把测试**代码**类型检查过一遍(「测试编译不过了」
|
||||
# 这类静默腐化会被它抓到),而「断言暂时红了」在施工中间态是常态、不是腐化;test 是 2 个 OS ×
|
||||
# 全量编译+运行,留给 main 与发布前的最终门更划算。
|
||||
# - build release / runtime-smoke:它们是「能不能发」的门,dev 每次 push 跑一遍换不到新信息。
|
||||
#
|
||||
# 成本控制(dev push 很频繁,这是硬要求):
|
||||
# 1. `concurrency` 取消**同一个 ref** 上的旧运行:连推三次只留最后一次,被取消的旧运行基本不计费。
|
||||
# 2. clippy 只在「这次推送碰过 Rust 侧文件」时跑:只改文档(*.md / LICENSE / NOTICE / .gitignore)
|
||||
# 的推送跳过编译。判据**保守**——非 push、base 不可解析(force-push / 首次推送)、diff 取不到、
|
||||
# 出现任何不在白名单里的路径,一律照跑:宁可多跑一次,不许假绿。
|
||||
# 刻意**不**用 `on.push.paths` 过滤整个工作流:file-size 正是要在「只改文档」的推送里跑的那道门
|
||||
# (README 401 行就是这么漏的),整条过滤掉等于自废武功。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
group: dev-guards-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 六道守门原样来自 guards.yml(那边加了 `workflow_call:` 触发,纯新增)。本文件不重写它们的脚本:
|
||||
# unsafe-scope 的词法器一旦有第二份副本,就会出现「dev 绿、main 红」的双真相。
|
||||
guards:
|
||||
name: 架构与合规守门(同 main 口径)
|
||||
uses: ./.github/workflows/guards.yml
|
||||
|
||||
# GPU 证据门禁(M253 r1 P2-1):与 main **同一份** workflow,理由同上 —— 判据只能有一份。
|
||||
# dev 上 `evidence` 照样显式跳过(无 GPU、无产物,见该文件的 probe job),所以实际拿到的是
|
||||
# `selftest` 那一半:19 条向量 + 三个退出码 0/1/2 + 接线自检,成本就是一次 python 自测。
|
||||
# 不另加「只跑自测」的开关:那会给这道门禁多开一个「可以合法关掉真判据」的旋钮。
|
||||
gpu-evidence:
|
||||
name: GPU 证据门禁(同 main 口径;无 GPU 只跑自测)
|
||||
uses: ./.github/workflows/gpu-evidence.yml
|
||||
|
||||
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
|
||||
|
||||
# 只跑 ubuntu:clippy 的判据是类型层 lint,跨平台差异极少(Windows 专属行为由 main/PR 上的
|
||||
# windows-latest 槽位与 test 里的像素守门兜);两个 OS 都上会把 dev 的编译成本直接翻倍。
|
||||
clippy:
|
||||
name: clippy (ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 要 diff 出「这次推了哪些文件」就得有 base commit;浅 clone 会让下面的判据退化成
|
||||
# 「一律照跑」(保守,但不再省成本),所以这里取全历史。
|
||||
fetch-depth: 0
|
||||
- name: 本次推送是否碰过 Rust 侧文件(只碰文档则跳过编译)
|
||||
id: scope
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
run=yes
|
||||
note="照跑:默认(未进入可判定的 push 分支)"
|
||||
if [ "$EVENT" != "push" ]; then
|
||||
note="照跑:非 push 事件(手动 dispatch)没有可信 diff"
|
||||
elif [ -z "${BEFORE:-}" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||
note="照跑:base 为首次推送(before 全 0)"
|
||||
elif ! git rev-parse --verify -q "${BEFORE}^{commit}" >/dev/null 2>&1; then
|
||||
note="照跑:base commit 不可解析(force-push / 历史被改写)"
|
||||
else
|
||||
changed=$(git diff --name-only "$BEFORE" "$SHA" 2>/dev/null || true)
|
||||
if [ -z "$changed" ]; then
|
||||
note="照跑:diff 为空(可能是增量不可得)"
|
||||
else
|
||||
# 白名单判定:只有「全部改动都落在这些非 Rust 路径上」才跳过编译。
|
||||
# 反向写法(黑名单 .rs)更省事,但漏一个路径就等于漏一次编译检查。
|
||||
others=$(printf '%s\n' "$changed" \
|
||||
| grep -vE '\.md$' \
|
||||
| grep -vxE 'LICENSE|NOTICE|\.gitignore' || true)
|
||||
if [ -z "$others" ]; then
|
||||
run=no
|
||||
note="跳过:本次只碰文档类路径 —— $(printf '%s ' $changed)"
|
||||
else
|
||||
note="照跑:本次改动了非文档路径 —— $(printf '%s ' $others)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "$note"
|
||||
echo "run=$run" >> "$GITHUB_OUTPUT"
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
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
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
# 与 ci.yml 的 clippy 作业逐字一致(`--workspace` 不含已退役的 `unlucky-app`,见 M118)。
|
||||
- name: cargo clippy --workspace --all-targets -D warnings
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
@@ -0,0 +1,233 @@
|
||||
name: GPU evidence
|
||||
|
||||
# GPU 截图证据门禁(M253:把 M249 的 `packaging/check_gpu_shot.py` 接进 CI)。
|
||||
#
|
||||
# **为什么新开一个文件,不并进 guards.yml**:guards.yml 已是 399 行、红线 400(`file-size` 那道门
|
||||
# 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。这与
|
||||
# `release-guard.yml` 当初单独成文的理由逐字相同(那份文件头:「那份文件已经 398 行(400 行红线),
|
||||
# 再挂一个 job 必越线」)。
|
||||
#
|
||||
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
|
||||
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
|
||||
# (33 条「改绿」变异逐条重放、10 类封闭清单),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、
|
||||
# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」):
|
||||
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
|
||||
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
|
||||
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
|
||||
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
|
||||
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
|
||||
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。
|
||||
#
|
||||
# 载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
|
||||
# 所以改这道门禁时请连自检一起想:自检只认「上面那张封闭清单」,不宣称「不可能被改成总是绿」。
|
||||
#
|
||||
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
|
||||
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
shot_log:
|
||||
description: 'stderr 日志路径(需带 ULL_GPU_LOG=info,例 DevDocs/artifacts/gpu-probe/a1-gpu.stderr.log);留空则按下面的约定路径判'
|
||||
required: false
|
||||
default: ''
|
||||
shot_png:
|
||||
description: '同一运行的 PNG 路径;负例(expect_refuse=true)可留空'
|
||||
required: false
|
||||
default: ''
|
||||
expect_refuse:
|
||||
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
|
||||
type: boolean
|
||||
default: false
|
||||
# 日常线 `dev` 复用**同一份**判据与接线(r1 P2-1):`dev-guards.yml` 里
|
||||
# `uses: ./.github/workflows/gpu-evidence.yml`。理由与 guards.yml 的 `workflow_call:` 逐字相同:
|
||||
# 判据只能有一份,两份各自演进迟早出现「dev 绿、main 红」的双真相;而 `dev-guards.yml` 文件头
|
||||
# 自己写着「空跑的门禁等于没有」。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的
|
||||
# 是 `selftest` 那一半:19 条向量 + 三个退出码 + 接线自检,成本就是一次 python 自测。
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
selftest:
|
||||
name: GPU 判据自测(不需 GPU;跳过 ≠ 已验证)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
|
||||
id: vectors
|
||||
run: python3 packaging/check_gpu_shot.py --self-test
|
||||
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
|
||||
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
|
||||
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
|
||||
id: exitcodes
|
||||
run: |
|
||||
set -uo pipefail
|
||||
work="$RUNNER_TEMP/gpu-shot-exitcodes"
|
||||
mkdir -p "$work"
|
||||
# 用自测脚手架现造一份能过的一对(同一份 synth_log/synth_png,避免第二套手工拼装)
|
||||
python3 - "$work" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path[:0] = ["packaging", "packaging/tests"]
|
||||
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
(root / "pass.stderr.log").write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
|
||||
(root / "pass.png").write_bytes(synth_png("content", SELFTEST_SIZE))
|
||||
(root / "red.stderr.log").write_text(synth_log(renderer="tiny-skia"), encoding="utf-8")
|
||||
PY
|
||||
expect() { # expect <期望退出码> <说明> <命令...>
|
||||
want="$1"; note="$2"; shift 2
|
||||
set +e
|
||||
"$@" > "$work/out.txt" 2>&1
|
||||
got=$?
|
||||
set -e
|
||||
cat "$work/out.txt"
|
||||
if [ "$got" -ne "$want" ]; then
|
||||
echo "::error::退出码语义不对:$note 期望 $want,实际 $got。判据脚本的 0/1/2 是整条门禁的接口,变了就是门禁变了。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $note → exit=$got"
|
||||
}
|
||||
expect 0 "正例(合成日志 + 合成 PNG,六条判据全过)" \
|
||||
python3 packaging/check_gpu_shot.py "$work/pass.stderr.log" "$work/pass.png"
|
||||
expect 1 "CPU 档日志(renderer=tiny-skia,判据点名判红)" \
|
||||
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
|
||||
expect 2 "用法错(一个位置参数都没给)" \
|
||||
python3 packaging/check_gpu_shot.py
|
||||
- name: 接线自检(33 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring)
|
||||
id: wiring
|
||||
run: python3 packaging/tests/gpu_shot_ci_selftest.py
|
||||
|
||||
probe:
|
||||
name: GPU 证据 — 探测可用产物与硬件适配器
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
available: ${{ steps.detect.outputs.available }}
|
||||
log: ${{ steps.detect.outputs.log }}
|
||||
png: ${{ steps.detect.outputs.png }}
|
||||
reason: ${{ steps.detect.outputs.reason }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 探测样例产物与硬件适配器(只探测、不判定)
|
||||
id: detect
|
||||
env:
|
||||
SHOT_LOG: ${{ github.event.inputs.shot_log }}
|
||||
SHOT_PNG: ${{ github.event.inputs.shot_png }}
|
||||
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
default_dir=DevDocs/artifacts/gpu-probe
|
||||
default_stem=gpu-shot
|
||||
log_path=""
|
||||
png_path=""
|
||||
available=false
|
||||
reason=""
|
||||
|
||||
if [ -n "${SHOT_LOG:-}" ]; then
|
||||
# ① dispatch 显式点了名:**给了却找不到 = 红**。若「明确说要判、东西不在」能退化成跳过,
|
||||
# 那又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip。
|
||||
if [ ! -f "${SHOT_LOG}" ]; then
|
||||
echo "::error::dispatch 指定了 shot_log=${SHOT_LOG},但工作区里没这个文件 —— 显式要求判定的输入缺失按红处理(不跳过)"
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${SHOT_PNG:-}" ] && [ ! -f "${SHOT_PNG}" ]; then
|
||||
echo "::error::dispatch 指定了 shot_png=${SHOT_PNG},但工作区里没这个文件 —— 同上,按红处理"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${EXPECT_REFUSE:-false}" != "true" ] && [ -z "${SHOT_PNG:-}" ]; then
|
||||
echo "::error::正例(expect_refuse=false)必须给 shot_png;只有负例可以省略 PNG"
|
||||
exit 1
|
||||
fi
|
||||
log_path="${SHOT_LOG}"
|
||||
png_path="${SHOT_PNG:-}"
|
||||
available=true
|
||||
reason="dispatch 显式指定产物(${log_path})"
|
||||
elif [ -f "${default_dir}/${default_stem}.stderr.log" ] && [ -f "${default_dir}/${default_stem}.png" ]; then
|
||||
# ② 约定路径:一次取证 = 一份干净的 stderr + 同一次运行的那张图
|
||||
# (`--diff`/拼接会骗过判据,见 gpu-evidence.md §5 第 1/7 条)。
|
||||
log_path="${default_dir}/${default_stem}.stderr.log"
|
||||
png_path="${default_dir}/${default_stem}.png"
|
||||
available=true
|
||||
reason="工作区里有约定产物(${default_dir}/${default_stem}.*)"
|
||||
else
|
||||
# ③ 什么都没有 —— 这是 GitHub 托管 runner 的常态:托管 runner 没有 GPU,
|
||||
# 而样例产物也不在仓库里(`DevDocs/` 被 .gitignore 忽略,图不进仓库)。
|
||||
# 必须显式跳过 + 写明原因,不许静默 pass。
|
||||
if compgen -G '/dev/dri/renderD*' >/dev/null; then
|
||||
adapter='本机有硬件适配器(/dev/dri/renderD*)'
|
||||
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
||||
adapter='本机有硬件适配器(nvidia-smi -L 有输出)'
|
||||
else
|
||||
adapter='无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi)'
|
||||
fi
|
||||
reason="${adapter},且工作区里没有可用的样例产物(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=${EVENT})"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "available=${available}"
|
||||
echo "log=${log_path}"
|
||||
echo "png=${png_path}"
|
||||
echo "reason=${reason}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${available}" = "true" ]; then
|
||||
echo "GPU 证据:${reason} —— 交给 evidence job 跑真实产物判据(0 过 / 1 判据不过)"
|
||||
else
|
||||
echo "GPU 证据:${reason},跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)"
|
||||
# warning 而不是 notice:这一趟运行**看起来是绿的**,必须在 UI 上留一条黄条说清它没判过。
|
||||
echo "::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## GPU 证据(M253)"
|
||||
echo
|
||||
if [ "${available}" = "true" ]; then
|
||||
echo "- 结论:**跑真实产物判据**(${reason})"
|
||||
echo "- 产物:${log_path}"
|
||||
if [ -n "${png_path}" ]; then echo "- PNG:${png_path}"; else echo "- PNG:无(负例口径 ${EXPECT_REFUSE:-false})"; fi
|
||||
echo "- 判定看 \`evidence\` job 的退出码(0 过 / 1 判据不过 / 2 用法错);这里是「有东西可判」,不是「判过了」。"
|
||||
else
|
||||
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
|
||||
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
|
||||
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
|
||||
echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:"
|
||||
echo " ① \`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,且两个文件必须**已被 checkout 下来**"
|
||||
echo " —— checkout 只拿已跟踪文件,所以产物得先提交进被 dispatch 的那个 ref;"
|
||||
echo " ② 把一对产物提交到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`"
|
||||
echo " 再对那个 ref 跑一次(\`${default_dir}\` 在 .gitignore 里,要用 \`git add -f\`;"
|
||||
echo " 或改放到任一已跟踪目录,用 ① 的输入指过去)。"
|
||||
echo " 就地往 workspace 里拷文件(不提交)在 CI 上做不到:那次运行 checkout 的树里没有 DevDocs/。"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
evidence:
|
||||
name: GPU 证据 — 真实产物判据
|
||||
needs: [selftest, probe]
|
||||
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped,
|
||||
# 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。
|
||||
if: needs.probe.outputs.available == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
|
||||
id: judge
|
||||
env:
|
||||
LOG: ${{ needs.probe.outputs.log }}
|
||||
PNG: ${{ needs.probe.outputs.png }}
|
||||
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
echo "GPU 证据:判 ${LOG}"
|
||||
if [ "${EXPECT_REFUSE:-false}" = "true" ]; then
|
||||
if [ -n "${PNG}" ]; then
|
||||
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse "$PNG"
|
||||
else
|
||||
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse
|
||||
fi
|
||||
else
|
||||
python3 packaging/check_gpu_shot.py "$LOG" "$PNG"
|
||||
fi
|
||||
@@ -0,0 +1,399 @@
|
||||
name: Guards
|
||||
|
||||
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
|
||||
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
|
||||
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
|
||||
|
||||
# 架构与合规守门(与 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;M196 起两处)
|
||||
#
|
||||
# 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:
|
||||
# M133:日常线 `dev` 复用**同一份**守门脚本(`.github/workflows/dev-guards.yml` 里
|
||||
# `uses: ./.github/workflows/guards.yml`)。这是**纯新增的触发方式**:上面三条与下面全部作业
|
||||
# 一字未动,main / PR / dispatch 的触发与判据与加这行之前逐字相同。
|
||||
# 为什么复用而不是复制:判据只能有一份。两份 unsafe 词法器(110 行状态机)各自演进,迟早出现
|
||||
# 「dev 绿、main 红」的双真相,那时 dev 门禁就变成了装饰品。
|
||||
workflow_call:
|
||||
|
||||
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'
|
||||
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
|
||||
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
|
||||
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
|
||||
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
|
||||
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`(`SetLayeredWindowAttributes` 没有安全替代)。
|
||||
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
|
||||
# 判据是**集合相等**而不是「黑名单命中」:
|
||||
# - 任何第三个显式放行 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/console.rs\ncrates/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 秒。
|
||||
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
|
||||
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 || true)
|
||||
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 落点(不在期望集合里):"
|
||||
printf '%s\n' "$extra" | sed 's/^/ - /'
|
||||
fi
|
||||
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
|
||||
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
|
||||
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
|
||||
fi
|
||||
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
|
||||
|
||||
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,400 @@
|
||||
name: Release / build(可复用)
|
||||
|
||||
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
|
||||
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
|
||||
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
|
||||
# `matrix.rustflags` → `inputs.rustflags`。
|
||||
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
|
||||
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
|
||||
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
|
||||
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
|
||||
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
|
||||
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
|
||||
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
|
||||
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
|
||||
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slot:
|
||||
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
|
||||
type: string
|
||||
required: true
|
||||
os:
|
||||
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
|
||||
type: string
|
||||
required: true
|
||||
build_dir:
|
||||
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
|
||||
type: string
|
||||
required: true
|
||||
rustflags:
|
||||
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
|
||||
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
|
||||
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
|
||||
# 没传时这里是空,不会报错。
|
||||
secrets:
|
||||
ULL_MSA_CLIENT_ID:
|
||||
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
|
||||
required: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ inputs.slot }})
|
||||
runs-on: ${{ inputs.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
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
|
||||
|
||||
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
|
||||
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
|
||||
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
|
||||
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
|
||||
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
|
||||
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
|
||||
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
|
||||
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
|
||||
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
|
||||
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
|
||||
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
|
||||
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
|
||||
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
|
||||
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
|
||||
if: inputs.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
|
||||
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
|
||||
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
|
||||
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
|
||||
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
|
||||
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
|
||||
done
|
||||
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
|
||||
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
|
||||
done
|
||||
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
|
||||
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
|
||||
for ver in "$root"/*/; do
|
||||
libs=( "$ver"um/arm64/*.lib )
|
||||
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
|
||||
done
|
||||
done
|
||||
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
|
||||
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
say notice "link.exe 候选 $p → ${hit:-未命中}"
|
||||
done
|
||||
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
|
||||
|
||||
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
|
||||
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
|
||||
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
|
||||
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
|
||||
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
|
||||
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
|
||||
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
|
||||
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
|
||||
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
|
||||
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
|
||||
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
|
||||
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
|
||||
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
|
||||
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ inputs.rustflags }}
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
rustup target add aarch64-pc-windows-msvc
|
||||
rustup target list --installed
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
if [ -x "$vswhere" ]; then
|
||||
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
|
||||
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
|
||||
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
|
||||
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
|
||||
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
|
||||
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
|
||||
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
|
||||
mkdir -p "$tmp"
|
||||
vcvars=""
|
||||
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
|
||||
# 只赌一种就等于再写一个「静默退化成 warning」。
|
||||
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
|
||||
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$vcvars" ] && break
|
||||
done
|
||||
dump=""
|
||||
if [ -n "$vcvars" ]; then
|
||||
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
|
||||
{ printf '@echo off\r\n'
|
||||
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
|
||||
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
|
||||
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
|
||||
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
|
||||
fi
|
||||
for key in LIB INCLUDE; do
|
||||
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
|
||||
if [ -n "$value" ]; then
|
||||
export "$key=$value"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
|
||||
echo "env $key=${value:0:90}…(${#value} 字符)"
|
||||
else
|
||||
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
|
||||
fi
|
||||
done
|
||||
found=""
|
||||
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
|
||||
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
[ -n "$found" ] && break
|
||||
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$hit" ] && found=${hit//\\//}
|
||||
done
|
||||
if [ -n "$found" ]; then
|
||||
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
|
||||
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
|
||||
echo "linker: $found"
|
||||
else
|
||||
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
|
||||
fi
|
||||
else
|
||||
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
|
||||
fi
|
||||
fi
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
|
||||
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
|
||||
fi
|
||||
|
||||
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
|
||||
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
|
||||
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
|
||||
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
|
||||
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
|
||||
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PY=$(command -v python3 || command -v python)
|
||||
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
|
||||
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
||||
echo "expect-version=$version"
|
||||
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
|
||||
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
|
||||
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
|
||||
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
|
||||
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
|
||||
fi
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
|
||||
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
|
||||
for p in "${pats[@]}"; do
|
||||
for pat in "${p//\//\\}" "$p"; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
|
||||
find_pat=()
|
||||
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
|
||||
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
|
||||
fi
|
||||
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
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: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
PY=$(command -v python3 || command -v python)
|
||||
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
|
||||
|
||||
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
|
||||
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
|
||||
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
|
||||
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
|
||||
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
|
||||
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
|
||||
if: inputs.slot == 'linux-x64'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb xauth \
|
||||
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
|
||||
out="$RUNNER_TEMP/linux-smoke"
|
||||
mkdir -p "$out/data"
|
||||
log="$out/launcher.log"
|
||||
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
|
||||
set +e
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
|
||||
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "launcher exit=$code"
|
||||
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
|
||||
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
|
||||
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
|
||||
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
|
||||
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
|
||||
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
|
||||
ls -l "$out"
|
||||
exit "$code"
|
||||
|
||||
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
|
||||
if: always() && inputs.slot == 'linux-x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x64-first-frame
|
||||
path: ${{ runner.temp }}/linux-smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
|
||||
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
|
||||
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-${{ inputs.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
|
||||
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
|
||||
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
|
||||
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
|
||||
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
|
||||
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
|
||||
- name: arm64 失败收尾注解(红不挡发布)
|
||||
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
|
||||
shell: bash
|
||||
env:
|
||||
SLOT: ${{ inputs.slot }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$SLOT" = "linux-arm64" ]; then
|
||||
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
|
||||
else
|
||||
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
|
||||
fi
|
||||
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
|
||||
@@ -0,0 +1,94 @@
|
||||
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)。
|
||||
#
|
||||
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
|
||||
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
|
||||
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
|
||||
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
|
||||
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
|
||||
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
|
||||
#
|
||||
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
|
||||
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
|
||||
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
|
||||
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
|
||||
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
|
||||
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
|
||||
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
|
||||
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
|
||||
# 该红的红、该绿的绿。
|
||||
|
||||
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
|
||||
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
|
||||
run: python3 packaging/check_publish_slots.py --self-test
|
||||
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
|
||||
run: python3 packaging/check_publish_slots.py
|
||||
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
|
||||
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
|
||||
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
|
||||
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
|
||||
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
|
||||
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
|
||||
run: python3 packaging/check_test_source_off.py --self-test
|
||||
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
|
||||
run: python3 packaging/check_test_source_off.py
|
||||
# M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出
|
||||
# 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本
|
||||
# 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。
|
||||
- name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析)
|
||||
run: python3 packaging/sign_manifest.py --self-test
|
||||
- name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红)
|
||||
run: python3 packaging/check_pe_version.py --self-test
|
||||
# M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01
|
||||
# (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫
|
||||
# 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow,
|
||||
# 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。
|
||||
# PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。
|
||||
- name: workflow YAML 自检(注入 0x01 / 坏结构必须红)
|
||||
run: python3 packaging/check_workflows_yaml.py --self-test
|
||||
- name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符)
|
||||
run: python3 packaging/check_workflows_yaml.py
|
||||
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
|
||||
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
|
||||
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
|
||||
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
|
||||
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
|
||||
run: python3 packaging/check_test_key_off.py --self-test
|
||||
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
|
||||
run: python3 packaging/check_test_key_off.py
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Release / publish(可复用)
|
||||
|
||||
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
|
||||
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
|
||||
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
|
||||
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
|
||||
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
|
||||
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
|
||||
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
|
||||
# ① `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"`)。**三种取值各自创建什么**(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 上的字符串形态既保住三态,又不需要任何类型转换。
|
||||
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
|
||||
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PUBLISH_TOKEN:
|
||||
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
|
||||
required: true
|
||||
# (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方
|
||||
# `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串——
|
||||
# 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。
|
||||
# `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed
|
||||
# (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。
|
||||
ULL_MANIFEST_SIGNING_KEY:
|
||||
description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release"
|
||||
required: false
|
||||
|
||||
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
|
||||
# **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
|
||||
# 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
|
||||
# `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
|
||||
# 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
|
||||
# **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
|
||||
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
|
||||
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
|
||||
# 不是静默丢弃(这条是任务里点名要验的反方向)。
|
||||
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
|
||||
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
|
||||
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
|
||||
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
|
||||
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
|
||||
# macOS 两档仍未验证,默认连构建都不跑(M139)。
|
||||
PUBLISH_SLOTS: "windows-x64 linux-x64"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
|
||||
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
|
||||
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
|
||||
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
|
||||
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
|
||||
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
|
||||
|
||||
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
|
||||
keep=" "
|
||||
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
|
||||
for f in dist/*; do
|
||||
base=${f##*/}
|
||||
[ "$base" = "SHA256SUMS.txt" ] && continue
|
||||
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
|
||||
case "$keep" in
|
||||
*" $platform_arch "*)
|
||||
echo "publish: $base"
|
||||
;;
|
||||
*)
|
||||
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
|
||||
rm -f "$f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
- name: 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
|
||||
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
|
||||
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
|
||||
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
|
||||
python3 packaging/make_manifest.py --self-test
|
||||
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
|
||||
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
|
||||
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||
# 后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
python3 packaging/make_manifest.py \
|
||||
--dist dist \
|
||||
--version "$TAG" \
|
||||
--channel stable \
|
||||
--asset-base-url "$RELEASE_BASE/$TAG" \
|
||||
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量,
|
||||
# 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release:
|
||||
# ① `--self-test`:RFC 8032 向量(签名脚本自己没坏)
|
||||
# ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住
|
||||
# (签出一份客户端不认的清单,比不发版更糟)
|
||||
# ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig`
|
||||
# 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。
|
||||
- name: 签更新清单(Ed25519;签不上就阻断发布)
|
||||
shell: bash
|
||||
env:
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set +x
|
||||
PY=$(command -v python3 || command -v python)
|
||||
if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi
|
||||
"$PY" packaging/sign_manifest.py --self-test
|
||||
if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then
|
||||
echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。"
|
||||
echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs"
|
||||
exit 1
|
||||
fi
|
||||
"$PY" packaging/sign_manifest.py --check-pinned-keyring
|
||||
"$PY" packaging/sign_manifest.py --sign dist/manifest.json
|
||||
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
|
||||
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
|
||||
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
|
||||
ls -l dist/manifest.json dist/manifest.json.sig
|
||||
|
||||
- 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.PUBLISH_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
PRERELEASE: ${{ github.event.inputs.prerelease }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||
# ── 预发布标记:判据只在 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`。
|
||||
# `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource`
|
||||
# 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig 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
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Release
|
||||
|
||||
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
|
||||
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
|
||||
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
|
||||
# 不碰标记)。
|
||||
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。
|
||||
# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地
|
||||
# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁
|
||||
# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。
|
||||
# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。
|
||||
# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后
|
||||
# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。
|
||||
# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆,
|
||||
# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走
|
||||
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
|
||||
# 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在
|
||||
# release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。
|
||||
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
|
||||
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
|
||||
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
|
||||
#
|
||||
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
|
||||
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
|
||||
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
|
||||
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
|
||||
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
|
||||
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
|
||||
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
|
||||
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
all_slots:
|
||||
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease:
|
||||
# 默认 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: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
|
||||
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
|
||||
plan:
|
||||
name: 选槽位(默认四个:Windows 两档 + Linux 两档)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
steps:
|
||||
- name: 生成 build 矩阵(`all_slots=true` 才整表六槽位)
|
||||
id: pick
|
||||
shell: bash
|
||||
env:
|
||||
ALL_SLOTS: ${{ inputs.all_slots }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - "$ALL_SLOTS" >> "$GITHUB_OUTPUT" <<'PY'
|
||||
import json, sys
|
||||
# 槽位表(唯一真相源)。mode:"ci" = 默认在 CI 里构建;"probe" = 未验证,仅 all_slots=true 时构建。
|
||||
# **没有交叉槽位**(M140):每槽在自己的架构上原生构建,故无 `target` 键、产物一律在 target/release。
|
||||
# 要加回交叉槽位:`target` 键 + `build_dir=target/<triple>/release` + `rustup target add` 与 cargo 的 `--target`。
|
||||
SLOTS = [
|
||||
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
|
||||
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
|
||||
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
|
||||
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
|
||||
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
|
||||
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
|
||||
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
|
||||
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
|
||||
rustflags="-C target-feature=+crt-static", mode="ci"),
|
||||
# ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
|
||||
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
|
||||
# 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
|
||||
# `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
|
||||
# `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机
|
||||
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
|
||||
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
|
||||
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
|
||||
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
|
||||
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
|
||||
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
|
||||
# 同步 README §1/§9 与 release-notes.md。
|
||||
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
|
||||
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
|
||||
]
|
||||
keep = [s for s in SLOTS if s["mode"] == "ci" or sys.argv[1] == "true"]
|
||||
print("matrix=" + json.dumps({"include": keep}, separators=(",", ":")))
|
||||
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
|
||||
PY
|
||||
|
||||
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
|
||||
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
|
||||
build:
|
||||
name: build (${{ matrix.slot }})
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
slot: ${{ matrix.slot }}
|
||||
os: ${{ matrix.os }}
|
||||
build_dir: ${{ matrix.build_dir }}
|
||||
rustflags: ${{ matrix.rustflags }}
|
||||
secrets:
|
||||
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
|
||||
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
|
||||
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: build
|
||||
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
|
||||
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
|
||||
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
|
||||
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
|
||||
if: always() && !cancelled() && github.ref_type == 'tag'
|
||||
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/release-publish.yml
|
||||
secrets:
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
|
||||
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
|
||||
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Signing status(播报,不是门禁)
|
||||
|
||||
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
|
||||
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
|
||||
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
|
||||
#
|
||||
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
|
||||
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
|
||||
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
|
||||
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
|
||||
#
|
||||
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
|
||||
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
|
||||
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
|
||||
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
|
||||
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
|
||||
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
|
||||
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
|
||||
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
|
||||
# ④ **生产签名种子只在 `refs/heads/main` 上注入**,且**没有 `pull_request` 触发**(M255 r1 P2-1)。
|
||||
# 这条是本文件唯一的**安全**口径,别为了「PR 上也能看读数」把它放开:
|
||||
# - 仓库内分支的 PR / 任意分支的 dispatch **拿得到**仓库 Secret(只有 fork / Dependabot 拿不到);
|
||||
# - 而这把种子能给**全量已安装客户端**签出被信任的清单(自更新链 = 用户端 RCE),
|
||||
# 既有设计把它锁在 tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`)。
|
||||
# - 播报**不需要**在 PR 上跑:PR 改了 `check_signing_ready.py` 时,CI 会在那个 PR 自己的代码上执行它,
|
||||
# 若同时注入种子,等于把种子交给任何能推分支的人。
|
||||
# 门由两行 env 表达(同一个条件、同一个地方可审):钥给 main,非 main 则给 `ULL_SIGNING_KEY_WITHHELD`
|
||||
# 一个原因串 —— 于是 ② 读成「未判定」而不是「没配」(把策略说成缺配是一次假读数)。
|
||||
#
|
||||
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
|
||||
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
|
||||
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
|
||||
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
|
||||
selftest:
|
||||
name: 判据自测(三行/退出码/变异重放/接线)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 判据本体(三行 / 0/1/2 / 两向退出码变异 / --key 用法错 / 指针章节)
|
||||
run: python3 packaging/tests/signing_ready_selftest.py
|
||||
# 接线单独一份(400 行红线):播报不是门禁、**生产种子只在 main 注入**、文档指针真的存在,
|
||||
# 以及「去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红」三处变异重放。
|
||||
- name: 接线(ref 门 / 不被别的 workflow 复用 / 无 error 级注解 / 文档路径存在性 + 三处变异)
|
||||
run: python3 packaging/tests/signing_ready_wiring_selftest.py
|
||||
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
work="$RUNNER_TEMP/signing-ready"
|
||||
mkdir -p "$work"
|
||||
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
|
||||
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
|
||||
run_and_show() { # run_and_show <说明> <命令...>
|
||||
note="$1"; shift
|
||||
echo "── $note"
|
||||
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
|
||||
echo
|
||||
}
|
||||
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
|
||||
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
|
||||
python3 - "$work" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
|
||||
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
|
||||
end = trust.index("<<< PINNED-KEYS <<<")
|
||||
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
|
||||
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
|
||||
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
|
||||
)
|
||||
PY
|
||||
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
|
||||
--trust "$work/trust-with-fake-key.rs"
|
||||
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
|
||||
|
||||
# 播报:只把三行搬进 job summary,不看颜色。
|
||||
status:
|
||||
name: 签名就绪度(播报)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
|
||||
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
|
||||
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
|
||||
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
|
||||
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
|
||||
env:
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ github.ref == 'refs/heads/main' && secrets.ULL_MANIFEST_SIGNING_KEY || '' }}
|
||||
ULL_SIGNING_KEY_WITHHELD: ${{ github.ref == 'refs/heads/main' && '' || '非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)' }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
set +e
|
||||
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
cat "$RUNNER_TEMP/signing-ready.txt"
|
||||
case "$rc" in
|
||||
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
|
||||
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
|
||||
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
|
||||
esac
|
||||
{
|
||||
echo
|
||||
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
|
||||
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
|
||||
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
|
||||
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
Reference in new issue
Block a user