From 47386ef2ffa9968e4d0ba6573b467db4e765c18f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 22:34:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(release):=20PE=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=AF=BB=E5=9B=9E=E5=9C=A8=20Windows=20runner=20=E4=B8=8A?= =?UTF-8?q?=E5=B4=A9=E5=9C=A8=20print=20=E7=BC=96=E7=A0=81=E4=B8=8A?= =?UTF-8?q?=EF=BC=88=E5=88=A4=E6=8D=AE=E5=86=85=E9=83=A8=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=9C=AA=E9=99=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.2.4 Release run 43031591576 的 windows-x64 / windows-arm64 都红在「PE 版本资源读回」, 而 cargo build 是绿的、注解 API 上只有一句 Process completed with exit code 1. 根因:check_pe_version.py 把中文打在 stdout/stderr,本机 cp936 能编,Windows runner 是 en-US 镜像(代码页 1252),print() 直接 UnicodeEncodeError —— 判据全过也红,而且崩在 print 上, 日志里只剩 traceback。同仓 5 个 packaging 脚本已有 reconfigure(errors=backslashreplace) 守卫, Windows 槽位跑的脚本里只有这一个漏了。 - check_pe_version.py: safe_streams()(管道钉 UTF-8、终端只放宽 errors)、 每产物摘要行 flush(stderr 的判据不再跑到摘要前面)、模块 docstring 补判据清单; --self-test 增加「版本资源齐全的合成 PE 必须绿」正控 + 真子进程 cp1252/cp437 回归(红/绿两路) - release-build.yml: 读回步骤先 ls -l 落产物大小,输出 tee 后逐行转 ::error:: 注解 (下次红能看到原因,不再只有退出码) - 判据未降:仍然是读产物字节、仍然 exit 1,未改任何断言 实测:RUSTFLAGS='-C target-feature=+crt-static' 的 release 产物本机读回 exit 0; 同产物同命令在 PYTHONIOENCODING=cp1252 下,修前 exit 1 + traceback、修后 exit 0。 --- .github/workflows/release-build.yml | 23 ++++- packaging/check_pe_version.py | 153 ++++++++++++++++++++++++---- 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index c7c0c67..29b5796 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -226,10 +226,31 @@ jobs: set -euo pipefail PY=$(command -v python3 || command -v python) if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi + # 先把「产物到底是哪个文件、多大」落成日志:这一步红了,第一件要排除的就是「读的不是那个文件」。 + # `|| echo` 是刻意的:让「文件不存在」的判据由检查器自己报(它的措辞比 ls 更适合当注解)。 + ls -l "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ + || echo "::warning::ls 没列出产物(文件可能不存在),判据交给下面的检查器" # 版本号与 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" + # 判据本身不降级(仍然只看产物字节、仍然 exit 1),但**失败原因必须自己浮出来**:v0.2.4 的 + # run 43031591576 两个 Windows 槽位红在这里,注解 API 上只剩一句 `Process completed with exit + # code 1.`(检查器把原因打在 stderr,而 step 没转成注解),只能靠猜。故整段输出 tee 到文件, + # failure 时逐行转 `::error::` 注解。 + log="$RUNNER_TEMP/pe-version.log" + status=0 + "$PY" packaging/check_pe_version.py \ + "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ + --expect-version "$version" 2>&1 | tee "$log" || status=$? + if [ "$status" -ne 0 ]; then + # 上限 30 行:正常失败是「每个产物几条判据」,异常失败(traceback)也不会被截掉开头。 + line_no=0 + while IFS= read -r line; do + line_no=$((line_no + 1)) + if [ "$line_no" -le 30 ]; then echo "::error::PE 版本资源读回:$line"; fi + done < "$log" + exit "$status" + fi - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) if: runner.os == 'Windows' diff --git a/packaging/check_pe_version.py b/packaging/check_pe_version.py index 09c43d7..af309e0 100644 --- a/packaging/check_pe_version.py +++ b/packaging/check_pe_version.py @@ -17,12 +17,29 @@ version, so "version drifted from Cargo.toml" is caught. python3 packaging/check_pe_version.py ... --expect-version 0.2.3 python3 packaging/check_pe_version.py --self-test + +# 什么时候退出非 0(判据清单;排查红步骤时照这张表读日志) + +逐产物(退出码 1,每条一条 `error:`): + +1. 文件不存在; +2. 小于 0x100 字节,或不是可解析的 PE(MZ / PE 头不对); +3. 找不到 `FIXEDFILEINFO` 签名 0xFEEF04BD —— **版本资源根本没进产物**; +4. 缺结构键 `VS_VERSION_INFO` / `StringFileInfo` / `VarFileInfo` / `Translation`; +5. 缺承诺字段名,或字段值不等于承诺值(CompanyName / ProductName / FileDescription / LegalCopyright); +6. `OriginalFilename` 不是 `.exe`; +7. 给了 `--expect-version` 时:四段式版本字符串不在资源里,或 `dwFileVersion` 与它不一致。 + +其它:`--self-test` 自检失败 → 1;没给产物也没给 `--self-test` → 2(打印用法)。 +输出本身**不再是**失败原因:`safe_streams()` 保证「汉字打不出来」不会把一条通过的判据变成红。 """ from __future__ import annotations import argparse +import os import struct +import subprocess import sys from pathlib import Path @@ -109,14 +126,89 @@ def check(path: Path, expect_version: str | None) -> list[str]: print( f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)" - f" / 版本资源={'有' if has_resource else '无'}" + f" / 版本资源={'有' if has_resource else '无'}", + # 进度行走在 stderr 的判据之前:CI 的 stdout 是块缓冲,不 flush 就会「错误先、摘要后」,读起来像两份日志。 + flush=True, ) return failures +def safe_streams() -> None: + """把 stdout/stderr 钉成「汉字打不出来也不许抛异常」。 + + CI 的 stdout 是**管道**:GitHub Actions 按 UTF-8 解码,而 Windows runner 是 en-US 镜像、默认代码页 + 1252,于是 `print("ok: 2 个产物的版本资源齐全")` 会 `UnicodeEncodeError` —— 判据全过也红,而且崩在 + `print` 上,日志里只剩一段 traceback(`::error::` 注解一条都没有)。本机 cp936 能编汉字,复现不出来。 + 管道里钉 UTF-8(Actions 那边正好按 UTF-8 解),终端里只放宽 `errors`(不改编码,免得本机控制台变乱码)。 + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) # Python < 3.7 没有,那就只能用默认行为 + if reconfigure is None: + continue + try: + if stream.isatty(): + reconfigure(errors="backslashreplace") + else: + reconfigure(encoding="utf-8", errors="backslashreplace") + except (ValueError, OSError): + pass + + +def synthetic_pe(stem: str, version: tuple[int, int, int, int], *, with_resource: bool = True) -> bytes: + """合成一个 MZ/PE 头合法的最小文件:`with_resource` 决定版本资源齐不齐。 + + 两种用途:`False` 是**负控**(判据必须报错),`True` 是**正控**(判据全过 —— 否则「绿的时候能不能 + 正常打印/正常退出 0」这条根本没法验)。 + """ + pe_offset = 0x40 + body = bytearray(0x100) + body[0:2] = b"MZ" + struct.pack_into(" subprocess.CompletedProcess[str]: + """按指定 I/O 编码起一个真子进程跑本脚本(复刻 CI 上的 Windows 管道环境)。""" + env = {key: value for key, value in os.environ.items() if key not in ("PYTHONUTF8", "PYTHONIOENCODING")} + env["PYTHONIOENCODING"] = io_encoding + return subprocess.run( + [sys.executable, str(Path(__file__).resolve()), str(artifact), "--expect-version", expect], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + check=False, + ) + + def self_test() -> int: - """口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。""" - import os + """口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」+ 「管道编码不许崩」。""" import tempfile failures = 0 @@ -137,19 +229,45 @@ def self_test() -> int: # 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错 # (否则这个脚本对任何输入都会「绿」,门禁等于没有)。 - fake = bytearray(0x200) - fake[0:2] = b"MZ" - struct.pack_into(" int: parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)") parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe") parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)") - parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)") + parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红/齐全必绿 + 管道编码)") args = parser.parse_args(argv) if args.self_test: @@ -185,4 +303,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + safe_streams() raise SystemExit(main()) From bb572ac2dd20e32a8464c795ee9691440fc00766 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 22:46:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(release):=20PE=20=E8=AF=BB?= =?UTF-8?q?=E5=9B=9E=E6=AD=A5=E9=AA=A4=E4=BD=93=E6=8B=86=E8=BF=9B=20packag?= =?UTF-8?q?ing/ci-pe-version-gate.sh=EF=BC=88release-build.yml=20400=20?= =?UTF-8?q?=E8=A1=8C=E5=AE=88=E9=97=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P1-1:main 上 release-build.yml 是 400 行(贴线),上一版 +23/−10 顶到 421,越过 guards.yml 的 file-size 硬门禁(LIMIT=400 是用户硬约束,不许抬)。按复审给定的方向「拆」: - 新增 packaging/ci-pe-version-gate.sh(59 行):读回两个产物 + 从 Cargo.toml 同源取版本号 + 失败逐条转 ::error:: 注解(上限 30 行)+ 非 0 退出;找不到 python 仍 fail; 临时日志用 mktemp + trap 清理,仓库根按脚本位置反推(不依赖 cwd) - release-build.yml 那一步只留 bash packaging/ci-pe-version-gate.sh "$BUILD_DIR" → 文件 421 → 396 行(比 main 的 400 还少 4) - 判据一行没改:仍然读产物字节、仍然由 check_pe_version.py 决定红绿、仍然非 0 退出 复审 P2-2:check_pe_version.py 的「什么时候退出非 0」清单补上第 7 条第 ③ 项 (签名后不足 16 字节、读不到 dwFileVersion,check():124-125)。 --- .github/workflows/release-build.yml | 31 ++------------ packaging/check_pe_version.py | 3 +- packaging/ci-pe-version-gate.sh | 66 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 29 deletions(-) create mode 100644 packaging/ci-pe-version-gate.sh diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 29b5796..20445bc 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -217,40 +217,15 @@ jobs: # 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。 # 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见 # `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。 + # 步骤体本身在 `packaging/ci-pe-version-gate.sh`:读回 + 同源取版本号 + 失败逐条转 `::error::` + # 注解(M264 从本文件拆出,给 400 行硬门禁腾地方;判据没动,仍由检查器 exit 1)。 - 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 - # 先把「产物到底是哪个文件、多大」落成日志:这一步红了,第一件要排除的就是「读的不是那个文件」。 - # `|| echo` 是刻意的:让「文件不存在」的判据由检查器自己报(它的措辞比 ls 更适合当注解)。 - ls -l "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ - || echo "::warning::ls 没列出产物(文件可能不存在),判据交给下面的检查器" - # 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。 - version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) - echo "expect-version=$version" - # 判据本身不降级(仍然只看产物字节、仍然 exit 1),但**失败原因必须自己浮出来**:v0.2.4 的 - # run 43031591576 两个 Windows 槽位红在这里,注解 API 上只剩一句 `Process completed with exit - # code 1.`(检查器把原因打在 stderr,而 step 没转成注解),只能靠猜。故整段输出 tee 到文件, - # failure 时逐行转 `::error::` 注解。 - log="$RUNNER_TEMP/pe-version.log" - status=0 - "$PY" packaging/check_pe_version.py \ - "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ - --expect-version "$version" 2>&1 | tee "$log" || status=$? - if [ "$status" -ne 0 ]; then - # 上限 30 行:正常失败是「每个产物几条判据」,异常失败(traceback)也不会被截掉开头。 - line_no=0 - while IFS= read -r line; do - line_no=$((line_no + 1)) - if [ "$line_no" -le 30 ]; then echo "::error::PE 版本资源读回:$line"; fi - done < "$log" - exit "$status" - fi + bash packaging/ci-pe-version-gate.sh "$BUILD_DIR" - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) if: runner.os == 'Windows' diff --git a/packaging/check_pe_version.py b/packaging/check_pe_version.py index af309e0..ecc0684 100644 --- a/packaging/check_pe_version.py +++ b/packaging/check_pe_version.py @@ -28,7 +28,8 @@ version, so "version drifted from Cargo.toml" is caught. 4. 缺结构键 `VS_VERSION_INFO` / `StringFileInfo` / `VarFileInfo` / `Translation`; 5. 缺承诺字段名,或字段值不等于承诺值(CompanyName / ProductName / FileDescription / LegalCopyright); 6. `OriginalFilename` 不是 `.exe`; -7. 给了 `--expect-version` 时:四段式版本字符串不在资源里,或 `dwFileVersion` 与它不一致。 +7. 给了 `--expect-version` 时:①四段式版本字符串不在资源里;②`dwFileVersion` 与它不一致; + ③签名之后不足 16 字节、读不到 `dwFileVersion`(`FIXEDFILEINFO` 被截断)。 其它:`--self-test` 自检失败 → 1;没给产物也没给 `--self-test` → 2(打印用法)。 输出本身**不再是**失败原因:`safe_streams()` 保证「汉字打不出来」不会把一条通过的判据变成红。 diff --git a/packaging/ci-pe-version-gate.sh b/packaging/ci-pe-version-gate.sh new file mode 100644 index 0000000..831f8b1 --- /dev/null +++ b/packaging/ci-pe-version-gate.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# PE 版本资源读回门禁(M190 判据在 CI 上的入口;M264 从 release-build.yml 拆出来)。 +# +# 为什么是脚本而不是内联 step:`.github/workflows/release-build.yml` 撞着 400 行硬门禁 +# (`guards.yml` 的 file-size,用户硬约束,不许抬 LIMIT),而「读回两个产物 + 同源取版本号 + +# 失败逐条转注解」内联进去必然顶上线。拆法与 `packaging/linux-smoke-docker.sh` / +# `packaging/linux-check-docker.sh` 同一思路:workflow 只留一行调用。 +# +# 判据一行没改,仍在 `packaging/check_pe_version.py`: +# ①读的是**产物字节**(构建日志里打印过不算证据);②版本号与 `Cargo.toml` 同源; +# ③任何一条判据没过就非 0 退出(含「产物不存在」「找不到 python」——工具缺失等于门禁失效,不降级)。 +# 本脚本只做「调用 + 把失败原因变成 `::error::` 注解」:v0.2.4 的 run 43031591576 两个 Windows +# 槽位红在这里时,注解 API 上只剩一句 `Process completed with exit code 1.`,只能靠猜。 +# +# 用法: +# bash packaging/ci-pe-version-gate.sh [build_dir] # 默认 target/release +set -euo pipefail + +BUILD_DIR="${1:-target/release}" + +# 仓库根按脚本位置反推:workflow 在仓库根跑,但本地从别处调用时也不该依赖 cwd。 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PY=$(command -v python3 || command -v python || true) +if [ -z "$PY" ]; then + echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)" + exit 1 +fi + +# 先把「产物到底是哪个文件、多大」落成日志:这一步红了,第一件要排除的就是「读的不是那个文件」。 +# `|| echo` 是刻意的:让「文件不存在」的判据由检查器自己报(它的措辞比 ls 更适合当注解)。 +ls -l "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ + || echo "::warning::ls 没列出产物(文件可能不存在),判据交给下面的检查器" + +# 版本号与 `Cargo.toml` **同源**:`[workspace.package] version = "x.y.z"`。不在这里写死任何版本。 +version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) +echo "expect-version=$version" + +log=$(mktemp "${TMPDIR:-/tmp}/pe-version.XXXXXX.log" 2>/dev/null || true) +if [ -n "$log" ]; then + trap 'rm -f "$log"' EXIT INT TERM + echo "log: $log" +else + # 连临时文件都建不出来时**不判红**:判据是产物的字节,不是本脚本的管道。原因看原始输出。 + echo "::warning::mktemp 失败,失败原因只能看下面的原始输出(判据不变)" +fi + +status=0 +"$PY" packaging/check_pe_version.py \ + "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \ + --expect-version "$version" 2>&1 | tee "${log:-/dev/null}" || status=$? + +if [ "$status" -ne 0 ]; then + if [ -n "$log" ]; then + # 上限 30 行:正常失败是「每个产物几条判据」,异常失败(traceback)也不会被截掉开头。 + line_no=0 + while IFS= read -r line; do + line_no=$((line_no + 1)) + if [ "$line_no" -le 30 ]; then echo "::error::PE 版本资源读回:$line"; fi + done < "$log" + fi + exit "$status" +fi + +exit 0