diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f472f4c..e03b797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,3 +90,82 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo build --workspace --release + + # 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。 + # 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里 + # 启动 GUI 进程,让 Slint 软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身** + # 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。 + # 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。 + 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 会把这一步拖到十几分钟却不带来额外证据。 + - run: cargo build --bin unlucky-launcher --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 --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 会话,Slint 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push, + # 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。 + # 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced + # 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。 + 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 --locked + - name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论) + run: | + set -o pipefail + mkdir -p "$RUNNER_TEMP/smoke/data" + target/debug/unlucky-launcher --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 diff --git a/packaging/README.md b/packaging/README.md index a53d8c3..a5184ef 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -342,11 +342,57 @@ experimental / not part of this release matrix)——改一边必须同步另 可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64 当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。 -## 11. 文件清单 +## 11. CI 运行时冒烟(Linux 真渲染一帧) + +「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境** +运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的 +进程内截图通道(`--software-renderer --screenshot --data-dir `)渲出一帧 PNG,再对**像素本身**断言。 + +三道断言(`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`): +① 进程退出码 0(截图通道失败会以非 0 退出,不会留下半张图)② PNG 存在、可解析、宽高 > 0 +③ **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。 + +为什么不能只看退出码:窗口起来了却什么都没画(字体库缺失、软渲染降级失败)同样是「退出码 0 + 一张合法 PNG」。 +阈值不凭空定,两组对照实测(`python packaging/check_screenshot.py --selftest` 可复现后三行): + +| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 | +|------|---------|-------|-----------|------| +| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass | +| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass | +| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail | +| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) | +| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — | + +阈值刻意落在「最弱内容也过不去、真实图有 5~7 倍余量」的位置:换主题/换字体不会误报,空白帧一定红。 +第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片 +底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。 + +**覆盖哪个平台**:只有 Linux x64(X11 + Xvfb + 软渲染)。 +- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS + runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS + 且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与 + `continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。 +- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。 + +**本地复跑**:`bash packaging/linux-smoke-docker.sh . /tmp/ull-smoke`——用 Docker(`rust:1-bookworm` + +TUNA 源装依赖)复刻同一套步骤,截图落在 `/tmp/ull-smoke/runtime-smoke.png`。诚实边界:本机容器是 +Debian 12(glibc 2.36)、官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同,容器绿只证明 +「这条通道能跑」,官方 runner 首跑仍要单独看。 + +**已知跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、 +`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。 + +**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**—— +iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」 +而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。 + +## 12. 文件清单 | 文件 | 作用 | 行数上限 | |------|------|---------| | `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 | | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | +| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | +| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 | diff --git a/packaging/check_screenshot.py b/packaging/check_screenshot.py new file mode 100644 index 0000000..d44250e --- /dev/null +++ b/packaging/check_screenshot.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。 + +为什么需要它:`unlucky-launcher --screenshot` 只要进程退出码 0 就会写出 PNG—— +一张全黑/全白(窗口起来了但什么都没画、字体库缺失、软渲染器降级失败)也是「成功退出」。 +所以 CI 除了断言退出码与文件存在,还要看**像素本身有没有信息**。 + +三个判据(阈值依据见 `--selftest` 与 packaging/README.md「CI 运行时冒烟」): + +- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。 +- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。 +- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与 + 边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色, + 平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。 + +只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。 +不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。 + +用法:: + + python packaging/check_screenshot.py shot.png # 判定,退出码 0/1 + python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用) + python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值 +""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +import sys +import tempfile +import zlib +from collections import Counter +from pathlib import Path + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图): +# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面); +# - 人为纯色帧:1 / 0.0 / 0.0。 +# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。 +DEFAULT_MIN_UNIQUE = 64 +DEFAULT_MIN_STDDEV = 3.0 +DEFAULT_MIN_EDGE_RATIO = 0.001 + +# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。 +EDGE_LUMA_DELTA = 8 + +# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解) +CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4} + + +class PngError(Exception): + """PNG 结构不受支持或损坏。""" + + +def _iter_chunks(data: bytes): + """按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。""" + offset = len(PNG_SIGNATURE) + while offset + 12 <= len(data): + (length,) = struct.unpack(">I", data[offset : offset + 4]) + yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length] + offset += 12 + length + + +def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None: + """就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。""" + if kind == 0: + return + if kind == 1: # Sub:加上左边 + for index in range(bpp, len(line)): + line[index] = (line[index] + line[index - bpp]) & 0xFF + elif kind == 2: # Up:加上正上方 + for index in range(len(line)): + line[index] = (line[index] + prev[index]) & 0xFF + elif kind == 3: # Average:加上 (左 + 上) // 2 + for index in range(len(line)): + left = line[index - bpp] if index >= bpp else 0 + line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF + elif kind == 4: # Paeth:加上 Paeth 预测子 + for index in range(len(line)): + left = line[index - bpp] if index >= bpp else 0 + up = prev[index] + upper_left = prev[index - bpp] if index >= bpp else 0 + estimate = left + up - upper_left + dist_left = abs(estimate - left) + dist_up = abs(estimate - up) + dist_corner = abs(estimate - upper_left) + if dist_left <= dist_up and dist_left <= dist_corner: + predictor = left + elif dist_up <= dist_corner: + predictor = up + else: + predictor = upper_left + line[index] = (line[index] + predictor) & 0xFF + else: + raise PngError(f"未知的行过滤类型 {kind}") + + +def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]: + """读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。""" + data = path.read_bytes() + if not data.startswith(PNG_SIGNATURE): + raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)") + + width = height = depth = color_type = interlace = None + compressed = bytearray() + for kind, body in _iter_chunks(data): + if kind == b"IHDR": + width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + compressed.extend(body) + elif kind == b"IEND": + break + + if width is None or height is None: + raise PngError("缺 IHDR 块") + if interlace: + raise PngError("不支持隔行(Adam7)PNG") + if depth != 8: + raise PngError(f"只支持 8bit PNG,实际 {depth}bit") + channels = CHANNELS_BY_COLOR_TYPE.get(color_type) + if channels is None: + raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)") + + raw = zlib.decompress(bytes(compressed)) + stride = width * channels + if len(raw) < (stride + 1) * height: + raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}") + + rows: list[bytearray] = [] + previous = bytearray(stride) + offset = 0 + for _ in range(height): + kind = raw[offset] + offset += 1 + line = bytearray(raw[offset : offset + stride]) + offset += stride + _unfilter(kind, line, previous, channels) + rows.append(line) + previous = line + return width, height, rows, channels + + +def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict: + """统计「这帧图里有没有信息」的三项指标。""" + colors: Counter[int] = Counter() + luma_sum = 0 + luma_square_sum = 0 + strong_edges = 0 + pair_count = 0 + + for row in rows: + previous_luma = None + for x in range(width): + base = x * channels + if channels >= 3: + red, green, blue = row[base], row[base + 1], row[base + 2] + else: + red = green = blue = row[base] + colors[(red << 16) | (green << 8) | blue] += 1 + # 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较 + luma = (red * 299 + green * 587 + blue * 114) // 1000 + luma_sum += luma + luma_square_sum += luma * luma + if previous_luma is not None: + pair_count += 1 + if abs(luma - previous_luma) >= EDGE_LUMA_DELTA: + strong_edges += 1 + previous_luma = luma + + total = width * height + mean = luma_sum / total if total else 0.0 + variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0 + top_color, top_count = colors.most_common(1)[0] if colors else (0, 0) + return { + "width": width, + "height": height, + "pixels": total, + "unique_colors": len(colors), + "top_color": f"#{top_color:06x}", + "top_color_share": round(top_count / total, 4) if total else 0.0, + "luma_mean": round(mean, 2), + "luma_stddev": round(math.sqrt(variance), 2), + "edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0, + } + + +def verdict( + stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float +) -> list[str]: + """把指标折成「失败原因清单」;空清单 = 通过。""" + problems = [] + if stats["pixels"] <= 0: + problems.append("图像尺寸为 0(宽或高是 0)") + return problems + if stats["unique_colors"] < min_unique: + problems.append( + f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)" + ) + if stats["luma_stddev"] < min_stddev: + problems.append( + f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)" + ) + if stats["edge_ratio"] < min_edge_ratio: + problems.append( + f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)" + ) + return problems + + +def encode_png(width: int, height: int, rgb_row) -> bytes: + """最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。""" + raw = bytearray() + for y in range(height): + raw.append(0) + raw.extend(rgb_row(y)) + out = bytearray(PNG_SIGNATURE) + for kind, body in ( + (b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)), + (b"IDAT", zlib.compress(bytes(raw), 6)), + (b"IEND", b""), + ): + out.extend(struct.pack(">I", len(body))) + out.extend(kind) + out.extend(body) + out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)) + return bytes(out) + + +def selftest() -> int: + """造两张对照样图跑一遍,打印阈值依据的两组数值。 + + - 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧; + - 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。 + """ + width, height = 1180, 760 + cases = { + "flat-white": lambda y: b"\xff" * (width * 3), + "flat-black": lambda y: b"\x00" * (width * 3), + "border-line": lambda y: b"".join( + b"\x00\x00\x00" + if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2) + else b"\xff\xff\xff" + for x in range(width) + ), + } + with tempfile.TemporaryDirectory() as tmp: + for name, describe in cases.items(): + path = Path(tmp) / f"{name}.png" + path.write_bytes(encode_png(width, height, describe)) + stats = measure(*decode_png(path)) + failed = verdict( + stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO + ) + print( + json.dumps( + { + "case": name, + "unique_colors": stats["unique_colors"], + "luma_stddev": stats["luma_stddev"], + "edge_ratio": stats["edge_ratio"], + "verdict": "fail" if failed else "pass", + }, + ensure_ascii=False, + ) + ) + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)") + parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径") + parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果") + parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限") + parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限") + parser.add_argument( + "--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限" + ) + parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出") + args = parser.parse_args(argv) + + if args.selftest: + return selftest() + if args.png is None: + parser.error("需要给出 PNG 路径(或 --selftest)") + + try: + stats = measure(*decode_png(args.png)) + except (PngError, OSError, zlib.error) as error: + print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr) + return 1 + + problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio) + if args.json: + print(json.dumps({**stats, "problems": problems}, ensure_ascii=False)) + else: + print( + f"{args.png}: {stats['width']}x{stats['height']} " + f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} " + f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}" + f"({stats['top_color_share'] * 100:.1f}%)" + ) + if problems: + for problem in problems: + print(f"[fail] {problem}", file=sys.stderr) + return 1 + print("[ok] 截图非空白") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/packaging/linux-smoke-docker.sh b/packaging/linux-smoke-docker.sh new file mode 100644 index 0000000..12794d3 --- /dev/null +++ b/packaging/linux-smoke-docker.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# 本地复跑 `.github/workflows/ci.yml` 的 `runtime-smoke` job:Linux + Xvfb + 软渲染截图。 +# +# 为什么要有这条脚本:CI 的 runtime-smoke 在 GitHub 的 ubuntu-latest 上跑, +# 但「改动这个 job 的人」需要在没有 Linux 桌面的开发机上先自证一遍——否则改的就是没人验过的 YAML。 +# 本脚本在 Docker 里复刻 job 的步骤(同样的 apt 依赖、同样的构建、同样的 xvfb-run 命令), +# 并把截图与判定结果留在宿主目录,供人眼与 `check_screenshot.py` 复核。 +# +# 用法: +# bash packaging/linux-smoke-docker.sh [仓库根目录] [输出目录] +# ULL_SMOKE_SKIP_BUILD=1 bash packaging/linux-smoke-docker.sh # 复用缓存的二进制 +# +# 说明(诚实边界): +# - 基础镜像 `rust:1-bookworm` 是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**。 +# 两者图形栈(X11/xvfb/Mesa 软渲染)来源不同,因此本脚本证明的是「Linux 上这条通道能真跑出图」, +# 不能代替官方 runner 的首跑验证。 +# - 国内网络慢时可换 TUNA 镜像:脚本默认就用 TUNA 的 Debian 源(`ULL_APT_MIRROR` 可覆盖)。 + +set -euo pipefail + +repo_root=$(cd "${1:-.}" && pwd) +out_dir=${2:-/tmp/ull-smoke} +image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm} +cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg} +target_volume=${ULL_CARGO_TARGET_VOLUME:-ull-linux-target} +apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn} +rustup_mirror=${ULL_RUSTUP_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/rustup} + +mkdir -p "$out_dir" +out_dir=$(cd "$out_dir" && pwd) +echo "== 仓库: $repo_root" +echo "== 输出: $out_dir" + +# 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形) +if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then + context=$(mktemp -d) + trap 'rm -rf "$context"' EXIT + cat >"$context/Dockerfile" <