问题: - bundleconfig.json 中 Myjs.js/isoweek.js 在 Linux CI 上找不到 (实际文件名是 MyJs.js/isoWeek.js,Windows 不区分大小写所以本地能跑) - py/ps1 脚本遇到缺失输入文件时 exit 1,导致 dotnet publish 失败, Jenkins 构建被阻塞,部署无法进行 修复: - bundleconfig.json: Myjs.js → MyJs.js,isoweek.js → isoWeek.js (与实际文件名大小写一致,Linux CI 可正确找到) - rebuild-bundles.py/ps1: 重建模式遇到缺失输入文件时只警告不 fail, 跳过该 bundle 保留已提交产物,避免阻塞 CI 主构建 - 只有 Verify 模式(开发本地手动校验)才因缺失/BOM 报错 exit 1 设计原则:bundle 重建是"尽力而为",绝不能阻塞主构建和部署
198 lines
8.2 KiB
Python
198 lines
8.2 KiB
Python
#!/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):
|
|
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):
|
|
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:
|
|
# 缺失输入文件:警告但不 fail,避免阻塞 CI 主构建
|
|
# 重建模式下跳过此 bundle(保留已提交的产物);Verify 模式下报错
|
|
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())
|