fix(release): PE 版本读回在 Windows runner 上崩在 print 编码上(判据内部一条未降)
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。
This commit is contained in:
1 parent
03b644fa0c
commit
47386ef2ff
2 files changed
+158
-18
No files matched your search
@@ -226,10 +226,31 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PY=$(command -v python3 || command -v python)
|
PY=$(command -v python3 || command -v python)
|
||||||
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
|
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"`。
|
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
|
||||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
||||||
echo "expect-version=$version"
|
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)
|
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
|
|||||||
+136
-17
@@ -17,12 +17,29 @@ version, so "version drifted from Cargo.toml" is caught.
|
|||||||
|
|
||||||
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
|
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
|
||||||
python3 packaging/check_pe_version.py --self-test
|
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` 不是 `<bin>.exe`;
|
||||||
|
7. 给了 `--expect-version` 时:四段式版本字符串不在资源里,或 `dwFileVersion` 与它不一致。
|
||||||
|
|
||||||
|
其它:`--self-test` 自检失败 → 1;没给产物也没给 `--self-test` → 2(打印用法)。
|
||||||
|
输出本身**不再是**失败原因:`safe_streams()` 保证「汉字打不出来」不会把一条通过的判据变成红。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -109,14 +126,89 @@ def check(path: Path, expect_version: str | None) -> list[str]:
|
|||||||
|
|
||||||
print(
|
print(
|
||||||
f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)"
|
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
|
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("<I", body, 0x3C, pe_offset)
|
||||||
|
body[pe_offset : pe_offset + 4] = PE_MAGIC
|
||||||
|
struct.pack_into("<H", body, pe_offset + 24 + 0x44, 2) # Subsystem = WINDOWS_GUI
|
||||||
|
if not with_resource:
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
ms = (version[0] << 16) | version[1]
|
||||||
|
ls = (version[2] << 16) | version[3]
|
||||||
|
fixed = FIXED_SIGNATURE + struct.pack("<I", 0x00010000) + struct.pack("<II", ms, ls) + struct.pack("<II", ms, ls)
|
||||||
|
dotted = ".".join(str(part) for part in version)
|
||||||
|
texts = [
|
||||||
|
"VS_VERSION_INFO",
|
||||||
|
"StringFileInfo",
|
||||||
|
"VarFileInfo",
|
||||||
|
"Translation",
|
||||||
|
"CompanyName",
|
||||||
|
EXPECTED_STRINGS["CompanyName"],
|
||||||
|
"ProductName",
|
||||||
|
EXPECTED_STRINGS["ProductName"],
|
||||||
|
"FileDescription",
|
||||||
|
EXPECTED_STRINGS["FileDescription"],
|
||||||
|
"LegalCopyright",
|
||||||
|
EXPECTED_STRINGS["LegalCopyright"],
|
||||||
|
dotted,
|
||||||
|
f"{stem}.exe",
|
||||||
|
]
|
||||||
|
return bytes(body) + fixed + b"".join(utf16(text) for text in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_with_io_encoding(artifact: Path, expect: str, io_encoding: str) -> 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:
|
def self_test() -> int:
|
||||||
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。"""
|
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」+ 「管道编码不许崩」。"""
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
failures = 0
|
failures = 0
|
||||||
@@ -137,19 +229,45 @@ def self_test() -> int:
|
|||||||
|
|
||||||
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
||||||
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
||||||
fake = bytearray(0x200)
|
with tempfile.TemporaryDirectory() as work:
|
||||||
fake[0:2] = b"MZ"
|
red = Path(work) / "red.exe"
|
||||||
struct.pack_into("<I", fake, 0x3C, 0x40)
|
red.write_bytes(synthetic_pe("red", (0, 2, 3, 0), with_resource=False))
|
||||||
fake[0x40:0x44] = PE_MAGIC
|
problems = check(red, "0.2.3")
|
||||||
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
|
if not problems:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
|
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
|
||||||
handle.write(bytes(fake))
|
failures += 1
|
||||||
name = handle.name
|
|
||||||
problems = check(Path(name), "0.2.3")
|
# 正控:同一套判据对「齐全」的合成 PE 必须绿,否则下面的「绿的时候能不能正常退出」无从验起。
|
||||||
os.unlink(name)
|
green = Path(work) / "green.exe"
|
||||||
if not problems:
|
green.write_bytes(synthetic_pe("green", (0, 2, 3, 0)))
|
||||||
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
|
problems = check(green, "0.2.3")
|
||||||
failures += 1
|
if problems:
|
||||||
|
print(f"FAIL: 版本资源齐全的合成 PE 竟然报错 {problems}(判据过度)", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
# 回归(v0.2.4 Release run 43031591576 两个 Windows 槽位红在这里):CI 的 stdout 是管道,
|
||||||
|
# Windows runner 默认代码页 1252 打不出汉字。下面两条用**真子进程 + 真管道**把那个环境复刻出来:
|
||||||
|
# 判据全过时必须退 0(不是崩在 print 上),判据没过时必须退 1 且报错文本还在(不许被吞成 traceback)。
|
||||||
|
for case, artifact, expect, want in (
|
||||||
|
("cp1252 绿", green, "0.2.3", 0),
|
||||||
|
("cp437 绿", green, "0.2.3", 0),
|
||||||
|
("cp1252 红", red, "0.2.3", 1),
|
||||||
|
):
|
||||||
|
result = spawn_with_io_encoding(artifact, expect, "cp1252" if "cp1252" in case else "cp437")
|
||||||
|
crashed = "Traceback" in result.stderr or "UnicodeEncodeError" in result.stderr
|
||||||
|
if crashed or result.returncode != want:
|
||||||
|
print(
|
||||||
|
f"FAIL: {case} 时退出码 {result.returncode}(期望 {want})"
|
||||||
|
f"{',且崩在编码上' if crashed else ''}:{result.stderr.strip()[-300:]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
failures += 1
|
||||||
|
if want == 0 and "ok:" not in result.stdout:
|
||||||
|
print(f"FAIL: {case} 时 stdout 没有 ok 行:{result.stdout.strip()[-200:]}", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
if want == 1 and "error:" not in result.stderr:
|
||||||
|
print(f"FAIL: {case} 时报错文本丢了:{result.stderr.strip()[-200:]}", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
|
||||||
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
|
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
|
||||||
return 0 if failures == 0 else 1
|
return 0 if failures == 0 else 1
|
||||||
@@ -159,7 +277,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)")
|
parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)")
|
||||||
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
|
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
|
||||||
parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)")
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.self_test:
|
if args.self_test:
|
||||||
@@ -185,4 +303,5 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
safe_streams()
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
Reference in new issue
Block a user