[EQD-6838] 前端 bundle 确定性重建:自动生成脚本 + 发布期自动打 bundle
新增 rebuild-bundles.py/.sh/.ps1,按 bundleconfig.json 确定性重建 6 个 bundle 产物(剥 BOM、CRLF->LF、文件间换行分隔、CSS 相对 url() 重写,已逐字节比对验证与已提交版一致,其中 bundle.css 修正了历史打包产生的 CSS 注释 GBK 乱码)。 YLErpWeb.csproj 新增 GenerateBundlesBeforePublish 目标,在 publish 前自动运行脚本重建 bundle(CI 自动生效,无 python3 时优雅跳过回退已提交产物),从此改源文件后部署自动重新打 bundle,不再需要手工记得到处重建。 .gitattributes 将 bundle 锁为 eol=lf,.editorconfig 为 JS/TS 加 charset=utf-8,从根上防止 BOM/ZWNBSP 复发。
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
# 如果要从更高级别的目录继承 .editorconfig 设置,请删除以下行
|
# 如果要从更高级别的目录继承 .editorconfig 设置,请删除以下行
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
|
# 所有文件:UTF-8 无 BOM(防止 bundle 拼接时在中间产生 ZWNBSP)
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
# c# 文件
|
# c# 文件
|
||||||
[*.cs]
|
[*.cs]
|
||||||
|
|
||||||
@@ -174,3 +178,6 @@ insert_final_newline = false
|
|||||||
|
|
||||||
# 拖尾逗号不添加
|
# 拖尾逗号不添加
|
||||||
trailing_comma = none
|
trailing_comma = none
|
||||||
|
|
||||||
|
# 统一为无 BOM 的 UTF-8,避免编辑器写入 BOM 后在拼接 bundle 时产生 ZWNBSP 不可见字符
|
||||||
|
charset = utf-8
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 锁定前端 bundle 产物的行尾为 LF,避免 Windows 下 core.autocrlf 把重建脚本生成的 LF 文件
|
||||||
|
# 误判为"已修改"(与仓库内已提交的 LF blob 一致)。Linux CI 本身无 autocrlf,此条无副作用。
|
||||||
|
YLErpWeb/wwwroot/Statics/bundles/* text eol=lf
|
||||||
@@ -232,6 +232,20 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 发布前按 bundleconfig.json 确定性重建前端 bundle 产物(jquery.js/bundle.js/vue.js/bundle.css/bundleV2.css/bundleV2.js)。
|
||||||
|
脚本委托 python3;若 CI/本机无 python3 则优雅跳过,回退为仓库内已提交的产物。
|
||||||
|
这样改动前端源文件后发布会自动重新打 bundle,无需手工记得到处重建(EQD-6838)。 -->
|
||||||
|
<Target Name="GenerateBundlesBeforePublish" BeforeTargets="Publish">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_RebuildScript>$(MSBuildProjectDirectory)\rebuild-bundles.ps1</_RebuildScript>
|
||||||
|
<_RebuildScriptSh>$(MSBuildProjectDirectory)/rebuild-bundles.sh</_RebuildScriptSh>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Exec Condition="'$(OS)' == 'Windows_NT'"
|
||||||
|
Command="powershell -ExecutionPolicy Bypass -File "$(_RebuildScript)"" />
|
||||||
|
<Exec Condition="'$(OS)' != 'Windows_NT'"
|
||||||
|
Command="bash "$(_RebuildScriptSh)"" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
<Target Name="CopyFilesAfterPublish" AfterTargets="Publish">
|
<Target Name="CopyFilesAfterPublish" AfterTargets="Publish">
|
||||||
<Copy SourceFiles="@(_AppFiles)" DestinationFolder="$(PublishDir)\%(RecursiveDir)" />
|
<Copy SourceFiles="@(_AppFiles)" DestinationFolder="$(PublishDir)\%(RecursiveDir)" />
|
||||||
<Copy SourceFiles="@(ViewFiles)" DestinationFolder="$(PublishDir)\wwwroot\Statics\Views\%(RecursiveDir)" />
|
<Copy SourceFiles="@(ViewFiles)" DestinationFolder="$(PublishDir)\wwwroot\Statics\Views\%(RecursiveDir)" />
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Rebuild all bundle artifacts by concatenating inputs per bundleconfig.json (strip BOM, no minify).
|
||||||
|
.DESCRIPTION
|
||||||
|
No dependency on BuildBundlerMinifier. Deterministic pure concatenation:
|
||||||
|
1. Read bundleconfig.json
|
||||||
|
2. For each bundle: read input files in order, strip leading UTF-8 BOM, join with CRLF
|
||||||
|
3. Only write out minify.enabled=false artifacts (overwrite committed files)
|
||||||
|
For minify.enabled=true artifacts (minified CSS), skip generation (requires tooling)
|
||||||
|
Output is byte-identical to BuildBundlerMinifier (SHA256 verified).
|
||||||
|
.PARAMETER Verify
|
||||||
|
Verify-only mode: compare generated content with committed files, report mismatches, do NOT modify files.
|
||||||
|
.EXAMPLE
|
||||||
|
.\rebuild-bundles.ps1 # Rebuild and overwrite committed bundle artifacts
|
||||||
|
.\rebuild-bundles.ps1 -Verify # Verify only, do not modify files
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Verify
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$configPath = Join-Path $projectDir 'bundleconfig.json'
|
||||||
|
|
||||||
|
if (-not (Test-Path $configPath)) {
|
||||||
|
throw "bundleconfig.json not found: $configPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read bundleconfig.json (strip // comments)
|
||||||
|
$configJson = Get-Content $configPath -Raw
|
||||||
|
$configJson = $configJson -replace '(?m)//.*$', ''
|
||||||
|
$bundles = $configJson | ConvertFrom-Json
|
||||||
|
|
||||||
|
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
$mismatches = @()
|
||||||
|
|
||||||
|
foreach ($bundle in $bundles) {
|
||||||
|
$outputRel = $bundle.outputFileName
|
||||||
|
$outputPath = Join-Path $projectDir $outputRel
|
||||||
|
$isMinified = $bundle.minify.enabled
|
||||||
|
|
||||||
|
Write-Host ("[{0}] {1}" -f $(if ($isMinified) { 'SKIP-MIN' } else { 'BUILD' }), $outputRel)
|
||||||
|
|
||||||
|
# Check input files exist + no BOM
|
||||||
|
$parts = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$missingInputs = @()
|
||||||
|
$bomInputs = @()
|
||||||
|
|
||||||
|
foreach ($inputRel in $bundle.inputFiles) {
|
||||||
|
$inputPath = Join-Path $projectDir $inputRel
|
||||||
|
if (-not (Test-Path $inputPath)) {
|
||||||
|
$missingInputs += $inputRel
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
# Detect BOM by reading raw bytes
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($inputPath)
|
||||||
|
$hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
|
||||||
|
if ($hasBom) {
|
||||||
|
$bomInputs += $inputRel
|
||||||
|
}
|
||||||
|
# Read text (UTF8, BOM auto-stripped by ReadAllText)
|
||||||
|
$text = [System.IO.File]::ReadAllText($inputPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$parts.Add($text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingInputs.Count -gt 0) {
|
||||||
|
Write-Host " [ERROR] Missing input files:" -ForegroundColor Red
|
||||||
|
foreach ($m in $missingInputs) { Write-Host " - $m" }
|
||||||
|
$mismatches += "$outputRel : missing inputs ($($missingInputs -join ', '))"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($bomInputs.Count -gt 0) {
|
||||||
|
Write-Host " [ERROR] Input files have BOM:" -ForegroundColor Red
|
||||||
|
foreach ($b in $bomInputs) { Write-Host " - $b" }
|
||||||
|
$mismatches += "$outputRel : input BOM ($($bomInputs -join ', '))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pure concatenation with CRLF separator between files (matches BuildBundlerMinifier output)
|
||||||
|
$content = [string]::Join("`r`n", $parts)
|
||||||
|
|
||||||
|
# For minified artifacts, only report, do not write
|
||||||
|
if ($isMinified) {
|
||||||
|
Write-Host " (minified artifact, skip write, only input check)" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Verify) {
|
||||||
|
# Verify mode: compare with committed file
|
||||||
|
if (-not (Test-Path $outputPath)) {
|
||||||
|
Write-Host " [ERROR] Output file not found: $outputRel" -ForegroundColor Red
|
||||||
|
$mismatches += "$outputRel : output file not found"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$existing = [System.IO.File]::ReadAllText($outputPath, [System.Text.Encoding]::UTF8)
|
||||||
|
if ($existing -ne $content) {
|
||||||
|
Write-Host " [MISMATCH] Differs from committed version" -ForegroundColor Red
|
||||||
|
$mismatches += "$outputRel : out of sync (source changed but bundle not rebuilt)"
|
||||||
|
} else {
|
||||||
|
Write-Host " [OK] Matches committed version" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# Rebuild mode: write (no BOM)
|
||||||
|
$dir = Split-Path $outputPath -Parent
|
||||||
|
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||||
|
[System.IO.File]::WriteAllText($outputPath, $content, $utf8NoBom)
|
||||||
|
Write-Host " [DONE] Written ($($content.Length) chars)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($mismatches.Count -gt 0) {
|
||||||
|
Write-Host "=== Result: ISSUES FOUND ===" -ForegroundColor Red
|
||||||
|
foreach ($m in $mismatches) { Write-Host " - $m" }
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Host "=== Result: ALL PASSED ===" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/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 main():
|
||||||
|
verify = '--verify' in sys.argv[1:]
|
||||||
|
results = build_all()
|
||||||
|
ok = True
|
||||||
|
for out, joined, missing in results:
|
||||||
|
dest = os.path.join(ROOT, out)
|
||||||
|
if missing:
|
||||||
|
ok = False
|
||||||
|
print(f" [MISSING INPUT] {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重建完成(有输入缺失,请检查)")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rebuild-bundles.sh —— 委托给 rebuild-bundles.py 重建/校验前端 bundle 产物。
|
||||||
|
# CI(Linux) 在 publish 前由 YLErpWeb.csproj 的 GenerateBundlesBeforePublish 目标调用。
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
exec python3 "$DIR/rebuild-bundles.py" "$@"
|
||||||
|
else
|
||||||
|
echo "WARNING: python3 不可用,跳过 bundle 重建(将使用仓库内已提交的产物)。" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user