Files
iofficeai--aionui/scripts/build-fast-debug-worktrees.ps1
2026-07-13 13:20:22 +08:00

342 lines
14 KiB
PowerShell

param(
[string[]]$Versions = @(),
[string]$SentryDsnFile = '',
[string]$OutputDir = (Join-Path $PSScriptRoot '..\out-fast-builds'),
[string]$WorktreeRoot = (Join-Path $PSScriptRoot '..\..\aionui-build-worktrees'),
[int]$TimeoutSeconds = 1800,
[switch]$Sequential
)
$ErrorActionPreference = 'Stop'
function Resolve-RepoRoot {
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
}
function Get-PackageVersion([string]$RepoRoot) {
$packageJsonPath = Join-Path $RepoRoot 'package.json'
$packageJson = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json
if (-not $packageJson.version) {
throw "package.json is missing version: $packageJsonPath"
}
return [string]$packageJson.version
}
function Resolve-SentryDsn([string]$Path) {
if ($env:SENTRY_DSN) {
Write-Host 'Using SENTRY_DSN from the current environment.'
return $env:SENTRY_DSN.Trim()
}
if ($Path) {
if (-not (Test-Path -LiteralPath $Path)) {
throw "SENTRY_DSN file not found: $Path"
}
Write-Host "Using SENTRY_DSN from file: $Path"
return (Get-Content -LiteralPath $Path -Raw).Trim()
}
Write-Warning 'SENTRY_DSN is not set. Building without installer/app Sentry reporting.'
return ''
}
function ConvertTo-ProcessArgument([string]$Value) {
if ($null -eq $Value) {
return '""'
}
return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"'
}
function Invoke-Git([string]$RepoRoot, [string[]]$GitArgs) {
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = 'git'
$psi.WorkingDirectory = $RepoRoot
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$allArgs = @('-C', $RepoRoot) + $GitArgs
$psi.Arguments = ($allArgs | ForEach-Object { ConvertTo-ProcessArgument $_ }) -join ' '
$process = [System.Diagnostics.Process]::Start($psi)
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
$process.WaitForExit()
$output = ($stdout + $stderr).Trim()
if ($process.ExitCode -ne 0) {
throw "git $($GitArgs -join ' ') failed:`n$output"
}
return $output
}
function Remove-LongPathTree([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) {
return
}
$reparsePoints = @(
Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } |
Sort-Object FullName -Descending
)
foreach ($item in $reparsePoints) {
try {
Start-Process -FilePath 'cmd.exe' -ArgumentList ('/c rmdir "' + $item.FullName + '"') -WindowStyle Hidden -Wait -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to remove reparse point $($item.FullName): $($_.Exception.Message)"
}
}
$fullPath = [System.IO.Path]::GetFullPath($Path)
$longPath = if ($fullPath.StartsWith('\\')) { '\\?\UNC\' + $fullPath.TrimStart('\') } else { '\\?\' + $fullPath }
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList ('/c rmdir /s /q "' + $longPath + '"') -WindowStyle Hidden -Wait -PassThru
if ($process.ExitCode -ne 0 -and (Test-Path -LiteralPath $Path)) {
throw "cmd rmdir failed with exit code $($process.ExitCode): $Path"
}
}
function New-BuildCommandFile([string]$WorktreePath, [string]$Version, [string]$Dsn, [string]$LocalAioncoreBinary, [string]$LocalAioncoreBundleDir) {
$scriptPath = Join-Path $WorktreePath "build-$Version.ps1"
$dsnBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Dsn))
$lines = @(
'$ErrorActionPreference = ''Stop''',
'$buildTemp = Join-Path $PSScriptRoot ''.tmp''',
'New-Item -ItemType Directory -Force -Path $buildTemp | Out-Null',
'$env:TEMP = $buildTemp',
'$env:TMP = $buildTemp',
'$env:SENTRY_DSN = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(''' + $dsnBase64 + '''))',
'$env:AIONUI_DEBUG_AUTO_UPDATE_CURRENT_VERSION = ''' + $Version + '''',
'$env:ELECTRON_BUILDER_COMPRESSION_LEVEL = ''1''',
'$env:AIONUI_BACKEND_LOCAL_BINARY = ''' + ($LocalAioncoreBinary -replace "'", "''") + '''',
'$env:AIONUI_BACKEND_LOCAL_BUNDLE_DIR = ''' + ($LocalAioncoreBundleDir -replace "'", "''") + '''',
'$env:ELECTRON_CACHE = Join-Path $env:LOCALAPPDATA ''electron\Cache''',
'& bun run build-win:x64:fast',
'exit $LASTEXITCODE'
)
[System.IO.File]::WriteAllLines($scriptPath, $lines, (New-Object System.Text.UTF8Encoding $false))
return $scriptPath
}
function Start-BuildProcess([string]$WorktreePath, [string]$Version, [string]$Dsn, [string]$LogDir, [string]$LocalAioncoreBinary, [string]$LocalAioncoreBundleDir) {
$scriptPath = New-BuildCommandFile $WorktreePath $Version $Dsn $LocalAioncoreBinary $LocalAioncoreBundleDir
$stdoutPath = Join-Path $LogDir "build-$Version.out.log"
$stderrPath = Join-Path $LogDir "build-$Version.err.log"
$process = Start-Process -FilePath 'powershell.exe' `
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) `
-WorkingDirectory $WorktreePath `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-WindowStyle Hidden `
-PassThru
return [pscustomobject]@{
version = $Version
worktreePath = $WorktreePath
process = $process
stdoutPath = $stdoutPath
stderrPath = $stderrPath
startedAt = Get-Date
}
}
function Copy-UntrackedBuildInputs([string]$RepoRoot, [string]$WorktreePath, [string[]]$UntrackedFiles) {
foreach ($relative in $UntrackedFiles) {
$normalized = $relative -replace '\\', '/'
if (-not $normalized.StartsWith('resources/')) { continue }
$source = Join-Path $RepoRoot $relative
$target = Join-Path $WorktreePath $relative
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { continue }
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
Copy-Item -LiteralPath $source -Destination $target -Force
}
}
function Wait-BuildProcess($Build, [int]$TimeoutSeconds) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $Build.process.HasExited) {
if ((Get-Date) -gt $deadline) {
Stop-Process -Id $Build.process.Id -Force -ErrorAction SilentlyContinue
throw "build $($Build.version) timed out after $TimeoutSeconds seconds. Logs: $($Build.stdoutPath), $($Build.stderrPath)"
}
Start-Sleep -Seconds 5
$Build.process.Refresh()
}
$Build.process.WaitForExit()
$Build.process.Refresh()
$exitCode = $Build.process.ExitCode
$source = Join-Path $Build.worktreePath "out\AionUi-$($Build.version)-win-x64.exe"
if (-not (Test-Path -LiteralPath $source)) {
$tailParts = @()
if (Test-Path -LiteralPath $Build.stderrPath) {
$raw = Get-Content -LiteralPath $Build.stderrPath -Raw -ErrorAction SilentlyContinue
if ($raw.Length -gt 12000) {
$tailParts += "stderr tail:`n" + $raw.Substring($raw.Length - 12000)
} else {
$tailParts += "stderr tail:`n" + $raw
}
}
if (Test-Path -LiteralPath $Build.stdoutPath) {
$raw = Get-Content -LiteralPath $Build.stdoutPath -Raw -ErrorAction SilentlyContinue
if ($raw.Length -gt 12000) {
$tailParts += "stdout tail:`n" + $raw.Substring($raw.Length - 12000)
} else {
$tailParts += "stdout tail:`n" + $raw
}
}
$tail = $tailParts -join "`n`n"
throw "build $($Build.version) failed with exit code $exitCode and did not produce $source. Logs: $($Build.stdoutPath), $($Build.stderrPath)`n$tail"
}
if ($null -ne $exitCode -and $exitCode -ne 0) {
throw "build $($Build.version) produced an artifact but exited with code $exitCode. Logs: $($Build.stdoutPath), $($Build.stderrPath)"
}
$sourceItem = Get-Item -LiteralPath $source
if ($sourceItem.Length -lt 50MB) {
throw "build $($Build.version) produced an unexpectedly small installer ($($sourceItem.Length) bytes): $source. Logs: $($Build.stdoutPath), $($Build.stderrPath)"
}
$target = Join-Path $script:OutputDirResolved "AionUi-$($Build.version)-win-x64.exe"
Copy-Item -LiteralPath $source -Destination $target -Force
$item = Get-Item -LiteralPath $target
$hash = (Get-FileHash -LiteralPath $target -Algorithm SHA512).Hash
[pscustomobject]@{
version = $Build.version
artifact = $target
size = $item.Length
sha512 = $hash
stdoutLog = $Build.stdoutPath
stderrLog = $Build.stderrPath
}
}
$repoRoot = Resolve-RepoRoot
$dsn = Resolve-SentryDsn $SentryDsnFile
$runId = Get-Date -Format 'yyyyMMdd-HHmmss'
$runRoot = Join-Path $WorktreeRoot $runId
$patchPath = Join-Path $runRoot 'current-worktree.patch'
$buildVersions = @($Versions | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($buildVersions.Count -eq 0) {
$buildVersions = @(Get-PackageVersion $repoRoot)
}
New-Item -ItemType Directory -Force -Path $runRoot, $OutputDir | Out-Null
$script:OutputDirResolved = (Resolve-Path -LiteralPath $OutputDir).Path
$untracked = @(git -C $repoRoot ls-files --others --exclude-standard)
$untrackedBuildInputs = @($untracked | Where-Object { ($_ -replace '\\', '/').StartsWith('resources/') })
$untrackedIgnored = @($untracked | Where-Object { -not (($_ -replace '\\', '/').StartsWith('resources/')) })
if ($untrackedBuildInputs.Count -gt 0) {
Write-Warning "Copying untracked build inputs into worktrees: $($untrackedBuildInputs -join ', ')"
}
if ($untrackedIgnored.Count -gt 0) {
Write-Warning "Untracked files are not copied into build worktrees: $($untrackedIgnored -join ', ')"
}
Invoke-Git $repoRoot @('diff', '--binary', 'HEAD', "--output=$patchPath") | Out-Null
$hasPatch = (Get-Item -LiteralPath $patchPath).Length -gt 0
$baseRef = (Invoke-Git $repoRoot @('rev-parse', 'HEAD')).Trim()
$nodeModules = Join-Path $repoRoot 'node_modules'
$localAioncoreBinary = Join-Path $repoRoot 'resources\bundled-aioncore\win32-x64\aioncore.exe'
$localAioncoreBundleDir = Join-Path $repoRoot 'out\win-unpacked\resources\bundled-aioncore\win32-x64'
if (-not (Test-Path -LiteralPath (Join-Path $localAioncoreBundleDir 'managed-resources') -PathType Container)) {
$localAioncoreBundleDir = Join-Path $env:LOCALAPPDATA 'Programs\AionUi\resources\bundled-aioncore\win32-x64'
}
if (Test-Path -LiteralPath (Join-Path $localAioncoreBundleDir 'managed-resources') -PathType Container) {
$localAioncoreBundleDir = (Resolve-Path -LiteralPath $localAioncoreBundleDir).Path
Write-Host "=== using local aioncore bundle fallback: $localAioncoreBundleDir ==="
} else {
$localAioncoreBundleDir = ''
Write-Warning 'Local aioncore bundle fallback was not found; builds may need to prepare managed resources.'
}
if (Test-Path -LiteralPath $localAioncoreBinary -PathType Leaf) {
$localAioncoreBinary = (Resolve-Path -LiteralPath $localAioncoreBinary).Path
Write-Host "=== using local aioncore fallback: $localAioncoreBinary ==="
} else {
$localAioncoreBinary = ''
Write-Warning 'Local aioncore fallback was not found; builds may need to download aioncore.'
}
$worktrees = @()
$builds = @()
$results = @()
$completed = $false
try {
foreach ($version in $buildVersions) {
$worktreePath = Join-Path $runRoot "AionUi-$version"
$worktrees += $worktreePath
Write-Host "=== prepare worktree ${version}: $worktreePath ==="
Invoke-Git $repoRoot @('worktree', 'add', '--detach', $worktreePath, $baseRef) | Out-Null
if ($hasPatch) {
Invoke-Git $worktreePath @('apply', '--whitespace=nowarn', $patchPath) | Out-Null
}
Copy-UntrackedBuildInputs $repoRoot $worktreePath $untrackedBuildInputs
if ((Test-Path -LiteralPath $nodeModules) -and -not (Test-Path -LiteralPath (Join-Path $worktreePath 'node_modules'))) {
New-Item -ItemType Junction -Path (Join-Path $worktreePath 'node_modules') -Target $nodeModules | Out-Null
}
}
foreach ($version in $buildVersions) {
$worktreePath = Join-Path $runRoot "AionUi-$version"
Write-Host "=== build $version start: $(Get-Date -Format o) ==="
$build = Start-BuildProcess $worktreePath $version $dsn $runRoot $localAioncoreBinary $localAioncoreBundleDir
if ($Sequential) {
$result = Wait-BuildProcess $build $TimeoutSeconds
$results += $result
Write-Host "=== build $version done: $(Get-Date -Format o) size=$($result.size) sha512=$($result.sha512) ==="
} else {
$builds += $build
}
}
if (-not $Sequential) {
foreach ($build in $builds) {
$result = Wait-BuildProcess $build $TimeoutSeconds
$results += $result
Write-Host "=== build $($build.version) done: $(Get-Date -Format o) size=$($result.size) sha512=$($result.sha512) ==="
}
}
$results
$completed = $true
} finally {
foreach ($build in $builds) {
if ($build.process -and -not $build.process.HasExited) {
Stop-Process -Id $build.process.Id -Force -ErrorAction SilentlyContinue
}
}
if ($completed) {
foreach ($worktreePath in $worktrees) {
try {
Invoke-Git $repoRoot @('worktree', 'remove', '--force', $worktreePath) | Out-Null
} catch {
Write-Warning "git worktree remove failed for $worktreePath; removing directory with long-path fallback. $($_.Exception.Message)"
try {
Remove-LongPathTree $worktreePath
} catch {
Write-Warning "long-path fallback cleanup failed for $worktreePath. $($_.Exception.Message)"
}
try {
Invoke-Git $repoRoot @('worktree', 'prune') | Out-Null
} catch {
Write-Warning "git worktree prune failed. $($_.Exception.Message)"
}
}
}
try {
Remove-LongPathTree $runRoot
} catch {
Write-Warning "run root cleanup failed for $runRoot. $($_.Exception.Message)"
}
} else {
Write-Warning "Build failed; preserving worktrees and logs under $runRoot"
}
}