Merge branch 'feat/releasefix-windows-pe-version-readback-r'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-12 09:25:37 +08:00
commit cb051ca933
3 files changed
+203 -21

No files matched your search

+3 -7
View File
@@ -217,19 +217,15 @@ jobs:
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。 # 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见 # 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。 # `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
# 步骤体本身在 `packaging/ci-pe-version-gate.sh`:读回 + 同源取版本号 + 失败逐条转 `::error::`
# 注解(M264 从本文件拆出,给 400 行硬门禁腾地方;判据没动,仍由检查器 exit 1)。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里) - name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
env: env:
BUILD_DIR: ${{ inputs.build_dir }} BUILD_DIR: ${{ inputs.build_dir }}
run: | run: |
set -euo pipefail bash packaging/ci-pe-version-gate.sh "$BUILD_DIR"
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) - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows' if: runner.os == 'Windows'
+134 -14
View File
@@ -17,12 +17,30 @@ 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` 与它不一致;
③签名之后不足 16 字节、读不到 `dwFileVersion`(`FIXEDFILEINFO` 被截断)。
其它:`--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 +127,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,20 +230,46 @@ 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)
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
handle.write(bytes(fake))
name = handle.name
problems = check(Path(name), "0.2.3")
os.unlink(name)
if not problems: if not problems:
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr) print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
failures += 1 failures += 1
# 正控:同一套判据对「齐全」的合成 PE 必须绿,否则下面的「绿的时候能不能正常退出」无从验起。
green = Path(work) / "green.exe"
green.write_bytes(synthetic_pe("green", (0, 2, 3, 0)))
problems = check(green, "0.2.3")
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 +278,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 +304,5 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
safe_streams()
raise SystemExit(main()) raise SystemExit(main())
+66
View File
@@ -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