diff --git a/scripts/build-frontend.ps1 b/scripts/build-frontend.ps1 index 1dd4ee42..485cc673 100644 --- a/scripts/build-frontend.ps1 +++ b/scripts/build-frontend.ps1 @@ -29,6 +29,7 @@ $dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha' $jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json' $cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json' $cachedVendorPath = Join-Path $frontendOutputRoot 'vendor' +$outputFingerprintPath = Join-Path $frontendCacheRoot 'frontend-output.sha' function Invoke-Native { param( @@ -147,6 +148,10 @@ $cssFingerprintInputs = @( $jsFingerprint = "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)" $cssFingerprint = "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)" $dependencyFingerprint = "$NodeVersion|$packageLockHash" +$outputCacheValid = (Test-Path -LiteralPath $outputFingerprintPath) -and + (Test-Path -LiteralPath $frontendOutputRoot) -and + ((Get-Content -Raw -LiteralPath $outputFingerprintPath).Trim() -eq + (Get-PathFingerprint @('.caiwu-build/public'))) $jsCacheValid = Test-CacheFingerprint ` -FingerprintPath $jsFingerprintPath ` @@ -170,6 +175,12 @@ $cssCacheValid = Test-CacheFingerprint ` $cssManifestCachePath ) +if (-not $outputCacheValid) { + $jsCacheValid = $false + $cssCacheValid = $false + Write-Host 'Frontend output integrity is missing or changed. Rebuilding assets.' +} + if ($jsCacheValid -and $cssCacheValid) { Write-Host 'Frontend inputs unchanged. Reusing JavaScript, CSS, and dependency caches.' @@ -337,12 +348,19 @@ try { foreach ($manifestPath in @($jsManifestCachePath, $cssManifestCachePath)) { $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json foreach ($property in $manifest.PSObject.Properties) { + if ($manifestPath -eq $jsManifestCachePath -and $property.Name -notlike '*.js') { continue } + if ($manifestPath -eq $cssManifestCachePath -and $property.Name -notlike '*.css') { continue } $mergedManifest[$property.Name] = $property.Value } } $mergedManifestJson = $mergedManifest | ConvertTo-Json Write-Utf8NoBom -Path (Join-Path $frontendOutputRoot 'mix-manifest.json') -Value $mergedManifestJson Write-Utf8NoBom -Path $publicManifestPath -Value $mergedManifestJson + if ($jsFingerprint -ne "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)" -or + $cssFingerprint -ne "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)") { + throw 'Frontend sources changed during build. Retry from a stable checkout.' + } + Write-Utf8NoBom -Path $outputFingerprintPath -Value (Get-PathFingerprint @('.caiwu-build/public')) } finally { Pop-Location diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index a89422bd..4d06b332 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -356,6 +356,20 @@ try { docker run --rm --network none --user www-data --entrypoint php $fullImage artisan --version } '发布镜像无法引导 Laravel。服务器未发生任何变更。' + # Compare the image's source and compiled assets with this workstation build. + # This also catches a Docker COPY or stale build-context overwrite before upload. + foreach ($asset in @( + @{ Local = 'resources/assets/js/components/business/AccountBills.vue'; Image = 'resources/assets/js/components/business/AccountBills.vue' }, + @{ Local = '.caiwu-build/public/js/business/pages.min.js'; Image = 'public/js/business/pages.min.js' }, + @{ Local = '.caiwu-build/public/mix-manifest.json'; Image = 'public/mix-manifest.json' } + )) { + $expectedHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot $asset.Local) -Algorithm SHA256).Hash.ToLowerInvariant() + $imageHash = @(& docker run --rm --network none --user www-data --entrypoint sha256sum $fullImage $asset.Image) + if ($LASTEXITCODE -ne 0 -or ($imageHash -join ' ') -notmatch "^$expectedHash\s") { + throw "发布镜像源码或前端资源不匹配:$($asset.Image)。服务器未发生任何变更。" + } + } + $previousShaOutput = @(& ssh @sshOptions -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true") $previousSha = ($previousShaOutput -join "`n").Trim() if ($LASTEXITCODE -ne 0) { diff --git a/tests/scripts/frontend-cache-integrity.test.ps1 b/tests/scripts/frontend-cache-integrity.test.ps1 new file mode 100644 index 00000000..3d054f74 --- /dev/null +++ b/tests/scripts/frontend-cache-integrity.test.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' +$source = Join-Path $PSScriptRoot '../../scripts/build-frontend.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile((Resolve-Path $source), [ref]$null, [ref]$null) +$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Get-PathFingerprint' }, $true) +Invoke-Expression $function.Extent.Text +$check = $ast.Find({ param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$outputCacheValid' }, $true).Right.Extent.Text +$repoRoot = Join-Path ([IO.Path]::GetTempPath()) ('caiwu-cache-test-' + [guid]::NewGuid().ToString('N')) +$frontendOutputRoot = Join-Path $repoRoot '.caiwu-build/public' +$outputFingerprintPath = Join-Path $repoRoot 'output.sha' +New-Item -ItemType Directory -Path $frontendOutputRoot -Force | Out-Null +try { + $asset = Join-Path $frontendOutputRoot 'pages.js' + Set-Content -LiteralPath $asset -Value 'new calendar' + if (Invoke-Expression $check) { throw 'Missing integrity stamp was accepted.' } + Set-Content -LiteralPath $outputFingerprintPath -Value (Get-PathFingerprint @('.caiwu-build/public')) + if (-not (Invoke-Expression $check)) { throw 'Valid output was rejected.' } + Set-Content -LiteralPath $asset -Value 'old calendar' + if (Invoke-Expression $check) { throw 'Overwritten output was accepted.' } + Remove-Item -LiteralPath $asset + if (Invoke-Expression $check) { throw 'Missing output was accepted.' } + Write-Host 'PASS missing stamp, valid cache, overwritten and missing asset detection' +} finally { + if ((Split-Path $repoRoot -Leaf) -notlike 'caiwu-cache-test-*') { throw 'Unexpected test directory.' } + Remove-Item -LiteralPath $repoRoot -Recurse -Force +}