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())