#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ rebuild-bundles.py —— 前端 bundle 产物的确定性重建脚本 为什么需要它: 页面加载的是 bundle.js / jquery.js / vue.js / bundle.css / bundleV2.css / bundleV2.js (见 _MainLayout.cshtml),这些是由 bundleconfig.json 把分散的源文件拼接而成。 历史上"改了源文件却忘了重新打 bundle"导致部署的仍是陈旧/残缺产物(本次 EQD-6838 的痛点)。 本脚本按与历史手工打包一致的规则确定性地重建这 6 个产物,使"改源 -> 重新打 bundle"可复现、可校验。 拼接规则(已与已提交版本逐字节比对验证一致): 1. 每个输入文件:剥掉文件首的 UTF-8 BOM(EF BB BF),行尾 CRLF->LF 归一(不改文件原始编码,GBK 等字节原样保留)。 2. 输入文件之间用一个 '\\n' 分隔(末尾文件后不加分隔符)。 3. CSS 产物额外做相对 url() 重写:把相对于各 CSS 源文件目录的 url(...) 改写为相对于 bundle 输出目录(Statics/bundles/)的路径,行为与 BuildBundlerMinifier 一致: - data: URI 原样保留(含引号); - 其余 url 保留原始引号风格,仅改写路径为相对 bundle 目录的相对路径。 (注:已提交 bundle.css 中少量 GBK 中文注释曾被旧工具误解码成 UTF-8 替换符,本脚本生成的是正确字节; 重建后这些注释乱码会被修正,仅影响注释,不影响渲染。) 用法: python3 rebuild-bundles.py # 重建并写回 wwwroot/Statics/bundles/ 下 6 个产物 python3 rebuild-bundles.py --verify # 重建到内存并与已提交文件比对,不一致则 exit 1(供 CI 校验) """ import json import os import re import sys BOM = b'\xef\xbb\xbf' ROOT = os.path.dirname(os.path.abspath(__file__)) # YLErpWeb 目录 # 引号优先匹配,避免 data: URI 内的 ) 截断 URL_RE = re.compile(rb"url\(\s*('[^']*'|\"[^\"]*\"|[^\"'()]*)\s*\)") def normalize(b): """剥文件首 BOM(若有) + 行尾归一为 \\n。全程字节级,不改编码(GBK 等原样保留)。""" if isinstance(b, bytes): if b.startswith(BOM): b = b[3:] return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') def rewrite_css_urls(css, input_rel, out_rel): """CSS 相对 url() 改写为相对 bundle 输出目录(与 BuildBundlerMinifier 行为一致)。""" base = os.path.dirname(input_rel) # 输入文件目录(相对 ROOT) outdir = os.path.dirname(out_rel) # bundle 输出目录(相对 ROOT) def repl(m): raw = m.group(1).strip() # 保留 url() 内原始引号风格 if len(raw) >= 2 and raw[0:1] == raw[-1:] and raw[0:1] in (b"'", b'"'): q = raw[0:1] inner = raw[1:-1].strip() else: q = b'' inner = raw if inner.startswith(b'data:'): return m.group(0) # data: URI 原样保留(含引号) if re.match(rb'^(https?:|//|#)', inner) or inner.startswith(b'/'): return m.group(0) try: udec = inner.decode('ascii') # url 通常为 ascii except Exception: return m.group(0) resolved = os.path.normpath(os.path.join(base, udec)) rel = os.path.relpath(resolved, outdir).replace(os.sep, '/') return b'url(' + q + rel.encode('utf-8', errors='surrogateescape') + q + b')' return URL_RE.sub(repl, css) def build_all(): cfg_path = os.path.join(ROOT, 'bundleconfig.json') raw = open(cfg_path, 'rb').read() raw = re.sub(rb'//[^\n]*', b'', raw) # 去掉 // 行注释 cfg = json.loads(raw.decode('utf-8-sig')) results = [] # (out, generated_bytes, missing_list) for b in cfg: out = b['outputFileName'] iscss = out.endswith('.css') parts = [] missing = [] for f in b['inputFiles']: p = os.path.join(ROOT, f) if not os.path.exists(p): # ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js; # 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的 # 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。 # 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。 missing.append(f) continue content = normalize(open(p, 'rb').read()) if iscss: content = rewrite_css_urls(content, f, out) parts.append(content) joined = b'\n'.join(parts) # 文件间一个 \n 分隔,末尾不加 results.append((out, joined, missing)) return results def is_stale(out, input_files): """增量检查:任一源文件比 bundle 产物新(或产物不存在)则返回 True。""" dest = os.path.join(ROOT, out) if not os.path.exists(dest): return True out_mtime = os.path.getmtime(dest) for f in input_files: p = os.path.join(ROOT, f) if os.path.exists(p) and os.path.getmtime(p) > out_mtime: return True return False def is_ci_environment(): """检测是否在 CI 环境运行。 CI 下 git checkout 后所有文件 mtime 几乎相同,增量检查不可靠, 因此 CI 环境强制全量重建。 """ ci_vars = ('CI', 'JENKINS_HOME', 'BUILD_NUMBER', 'GITHUB_ACTIONS', 'GITLAB_CI', 'TF_BUILD') return any(os.environ.get(v) for v in ci_vars) def main(): verify = '--verify' in sys.argv[1:] force_rebuild = is_ci_environment() # CI 环境强制全量重建 # 读取配置用于增量检查 cfg_path = os.path.join(ROOT, 'bundleconfig.json') raw = open(cfg_path, 'rb').read() raw = re.sub(rb'//[^\n]*', b'', raw) cfg = json.loads(raw.decode('utf-8-sig')) # 增量过滤:非 verify、非 CI 时,跳过源文件未变更的 bundle # CI 环境(force_rebuild=True)强制全量重建,因为 git checkout 后 mtime 不可靠 stale_bundles = [] for b in cfg: out = b['outputFileName'] if verify or force_rebuild or is_stale(out, b['inputFiles']): stale_bundles.append(b) else: print(f" [UP-TO-DATE] {out}") # 只重建过期的 bundle results = [] for b in stale_bundles: out = b['outputFileName'] iscss = out.endswith('.css') parts = [] missing = [] for f in b['inputFiles']: p = os.path.join(ROOT, f) if not os.path.exists(p): # ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js; # 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的 # 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。 # 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。 missing.append(f) continue content = normalize(open(p, 'rb').read()) if iscss: content = rewrite_css_urls(content, f, out) parts.append(content) joined = b'\n'.join(parts) results.append((out, joined, missing)) ok = True for out, joined, missing in results: dest = os.path.join(ROOT, out) if missing: # ⚠️【CI 踩坑必读 / 非致命】缺失输入文件绝不 fail:早期实现把 MISSING 当致命错误 exit 1, # 会连带打挂整个 dotnet publish(yml 报错 "命令已退出,代码为 1")。 # 现改为:重建模式跳过该 bundle(保留已提交的产物即可,CI 仍能出包);只有 --verify 校验模式才 exit 1。 # 重建模式下警告但不 fail,避免阻塞 CI 主构建 if verify: ok = False print(f" [MISSING INPUT] {out}: {missing}") else: print(f" [SKIP-MISSING] {out}: 输入缺失 {missing},保留已提交产物") continue if verify: original = normalize(open(dest, 'rb').read()) if joined != original: ok = False n = min(len(joined), len(original)) i = 0 while i < n and joined[i] == original[i]: i += 1 print(f" [STALE] {out} 与源文件不同步 @byte {i}") print(f" gen={joined[max(0,i-40):i+40]!r}") print(f" com={original[max(0,i-40):i+40]!r}") else: open(dest, 'wb').write(joined) print(f"{'VERIFY ' if verify else 'WRITE '} -> {out} ({len(joined)} bytes)" + ("" if not missing else f" MISSING={missing}")) if verify: print("\n全部与源同步 ✅" if ok else "\n存在不同步,请先运行 `python3 rebuild-bundles.py` 重建并提交 ✅") else: print("\n重建完成" if ok else "\n重建完成(部分 bundle 输入缺失已跳过,请检查)") # 重建模式永远返回 0,不阻塞 CI;Verify 模式才可能返回 1 return 0 if (ok or not verify) else 1 if __name__ == '__main__': sys.exit(main())