452 lines
15 KiB
PowerShell
452 lines
15 KiB
PowerShell
param(
|
|
[string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
|
|
[string]$DestinationZipPath = '',
|
|
[string]$ReportPath = '',
|
|
[Int64]$ExpectedSolverTableSizeBytes = 676207080,
|
|
[string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034'
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
Add-Type -AssemblyName System.IO.Compression
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
|
|
$ZipCreationRetryCount = 15
|
|
$ZipCreationRetryDelayMilliseconds = 1000
|
|
|
|
function Write-Utf8JsonFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path,
|
|
[Parameter(Mandatory = $true)]
|
|
[object]$Value
|
|
)
|
|
|
|
$ParentPath = Split-Path -Parent $Path
|
|
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
|
|
{
|
|
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
|
|
}
|
|
|
|
$Json = $Value | ConvertTo-Json -Depth 10
|
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
|
}
|
|
|
|
function Test-IsTransientFileLockMessage {
|
|
param(
|
|
[string]$Message
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Message))
|
|
{
|
|
return $false
|
|
}
|
|
|
|
return $Message -like '*being used by another process*' `
|
|
-or $Message -like '*cannot access the file*'
|
|
}
|
|
|
|
function Remove-ItemWithRetry {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
|
|
{
|
|
try
|
|
{
|
|
if (Test-Path -LiteralPath $Path)
|
|
{
|
|
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
|
|
}
|
|
|
|
return
|
|
}
|
|
catch
|
|
{
|
|
if ($AttemptIndex -ge $ZipCreationRetryCount `
|
|
-or -not (Test-IsTransientFileLockMessage -Message $_.Exception.Message))
|
|
{
|
|
throw
|
|
}
|
|
|
|
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
|
|
}
|
|
}
|
|
}
|
|
|
|
function Resolve-PackagedExecutablePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath
|
|
)
|
|
|
|
$CandidateExecutablePaths = @(
|
|
(Join-Path $RootPath 'Windows\UnrealHyperTwist.exe'),
|
|
(Join-Path $RootPath 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
|
(Join-Path $RootPath 'UnrealHyperTwist.exe')
|
|
)
|
|
|
|
return $CandidateExecutablePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
|
}
|
|
|
|
function Resolve-PackagedGameExecutablePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$PackagedProjectRoot
|
|
)
|
|
|
|
$GameExecutableDirectory = Join-Path $PackagedProjectRoot 'Binaries\Win64'
|
|
$CandidateExecutablePaths = @(
|
|
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist.exe'),
|
|
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Shipping.exe'),
|
|
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Development.exe')
|
|
)
|
|
|
|
return $CandidateExecutablePaths |
|
|
Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
|
|
Select-Object -First 1
|
|
}
|
|
|
|
function Invoke-ZipCreationWithRetry {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$SourceDirectory,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$DestinationZipPath
|
|
)
|
|
|
|
$LastException = $null
|
|
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
|
|
{
|
|
try
|
|
{
|
|
if (Test-Path -LiteralPath $DestinationZipPath)
|
|
{
|
|
Remove-ItemWithRetry -Path $DestinationZipPath
|
|
}
|
|
|
|
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
|
$SourceDirectory,
|
|
$DestinationZipPath,
|
|
[System.IO.Compression.CompressionLevel]::Optimal,
|
|
$false
|
|
)
|
|
return
|
|
}
|
|
catch
|
|
{
|
|
$LastException = $_.Exception
|
|
if ($AttemptIndex -ge $ZipCreationRetryCount `
|
|
-or -not (($_.Exception -is [System.IO.IOException]) `
|
|
-or (Test-IsTransientFileLockMessage -Message $_.Exception.Message)))
|
|
{
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
|
|
}
|
|
}
|
|
|
|
if ($null -ne $LastException)
|
|
{
|
|
throw $LastException
|
|
}
|
|
|
|
throw "Packaged zip export failed for an unknown reason."
|
|
}
|
|
|
|
function Test-ZipEntryMatchesExecutablePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$EntryFullName
|
|
)
|
|
|
|
$NormalizedEntryPath = $EntryFullName.Replace('\', '/')
|
|
return $NormalizedEntryPath.EndsWith('UnrealHyperTwist.exe', [System.StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
|
|
function Get-NormalizedRelativePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$FilePath
|
|
)
|
|
|
|
$RootWithSeparator = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
|
$FullFilePath = [System.IO.Path]::GetFullPath($FilePath)
|
|
if (-not $FullFilePath.StartsWith($RootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
|
|
{
|
|
throw "Required package file '$FullFilePath' is not beneath package root '$RootPath'."
|
|
}
|
|
|
|
return $FullFilePath.Substring($RootWithSeparator.Length).Replace('\', '/')
|
|
}
|
|
|
|
function Get-RequiredPackageFileEvidence {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Kind,
|
|
[Int64]$ExpectedSizeBytes = -1,
|
|
[string]$ExpectedSha256 = ''
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf))
|
|
{
|
|
throw "Required packaged $Kind file was not found at '$Path'."
|
|
}
|
|
|
|
$Item = Get-Item -LiteralPath $Path
|
|
if ($Item.Length -le 0)
|
|
{
|
|
throw "Required packaged $Kind file '$Path' was empty."
|
|
}
|
|
|
|
if ($ExpectedSizeBytes -ge 0 -and $Item.Length -ne $ExpectedSizeBytes)
|
|
{
|
|
throw "Required packaged $Kind file '$Path' was $($Item.Length) bytes; expected exactly $ExpectedSizeBytes bytes."
|
|
}
|
|
|
|
$ActualSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) `
|
|
-and $ActualSha256 -ne $ExpectedSha256.Trim().ToLowerInvariant())
|
|
{
|
|
throw "Required packaged $Kind file '$Path' did not match the expected SHA-256."
|
|
}
|
|
|
|
return [pscustomobject][ordered]@{
|
|
kind = $Kind
|
|
relativePath = Get-NormalizedRelativePath -RootPath $RootPath -FilePath $Item.FullName
|
|
sizeBytes = [Int64]$Item.Length
|
|
sha256 = $ActualSha256
|
|
}
|
|
}
|
|
|
|
function Get-ZipEntrySha256 {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[System.IO.Compression.ZipArchiveEntry]$Entry
|
|
)
|
|
|
|
$Sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
$EntryStream = $Entry.Open()
|
|
try
|
|
{
|
|
$Digest = $Sha256.ComputeHash($EntryStream)
|
|
return ([System.BitConverter]::ToString($Digest)).Replace('-', '').ToLowerInvariant()
|
|
}
|
|
finally
|
|
{
|
|
$EntryStream.Dispose()
|
|
$Sha256.Dispose()
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $PackageRoot))
|
|
{
|
|
throw "Package root '$PackageRoot' was not found."
|
|
}
|
|
|
|
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
|
$PackagedExecutablePath = Resolve-PackagedExecutablePath -RootPath $ResolvedPackageRoot
|
|
if ($null -eq $PackagedExecutablePath)
|
|
{
|
|
throw "No packaged UnrealHyperTwist executable was found beneath '$ResolvedPackageRoot'."
|
|
}
|
|
|
|
$PackagedExecutableDirectory = Split-Path -Parent $PackagedExecutablePath
|
|
$PackagedProjectRoot = Join-Path $PackagedExecutableDirectory 'UnrealHyperTwist'
|
|
$PackagedGameExecutablePath = Resolve-PackagedGameExecutablePath `
|
|
-PackagedProjectRoot $PackagedProjectRoot
|
|
if ($null -eq $PackagedGameExecutablePath)
|
|
{
|
|
throw "No packaged inner UnrealHyperTwist game executable was found beneath '$PackagedProjectRoot'."
|
|
}
|
|
$RequiredPackageFiles = @(
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path $PackagedExecutablePath `
|
|
-Kind 'launcher-executable'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path $PackagedGameExecutablePath `
|
|
-Kind 'game-executable'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedProjectRoot 'Binaries\Win64\tbbmalloc.dll') `
|
|
-Kind 'runtime-dependency-tbbmalloc'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.pak') `
|
|
-Kind 'cooked-pak'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.utoc') `
|
|
-Kind 'cooked-utoc'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.ucas') `
|
|
-Kind 'cooked-ucas'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedProjectRoot 'Saved\twophase-ht.tbl') `
|
|
-Kind 'solver-table' `
|
|
-ExpectedSizeBytes $ExpectedSolverTableSizeBytes `
|
|
-ExpectedSha256 $ExpectedSolverTableSha256
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\index.html') `
|
|
-Kind 'browser-shell-index'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-runtime-bootstrap.js') `
|
|
-Kind 'browser-shell-bootstrap'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-spatial-runtime-fallback.js') `
|
|
-Kind 'browser-shell-fallback'
|
|
Get-RequiredPackageFileEvidence `
|
|
-RootPath $ResolvedPackageRoot `
|
|
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\dist\browser-spatial-runtime.js') `
|
|
-Kind 'browser-shell-runtime'
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($DestinationZipPath))
|
|
{
|
|
$PackageLeafName = Split-Path -Leaf $ResolvedPackageRoot
|
|
$DestinationZipPath = Join-Path (
|
|
Split-Path -Parent $ResolvedPackageRoot
|
|
) ("{0}-{1}.zip" -f $PackageLeafName, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
$ReportPath = Join-Path $ResolvedPackageRoot 'validation\zip-export-report.json'
|
|
}
|
|
|
|
$ResolvedDestinationZipPath = [System.IO.Path]::GetFullPath($DestinationZipPath)
|
|
$ResolvedPackageRootWithSeparator = $ResolvedPackageRoot.TrimEnd('\') + '\'
|
|
if ($ResolvedDestinationZipPath.StartsWith($ResolvedPackageRootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
|
|
{
|
|
throw "Destination zip path '$ResolvedDestinationZipPath' must live outside the package root '$ResolvedPackageRoot'."
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ResolvedDestinationZipPath) | Out-Null
|
|
|
|
if (Test-Path -LiteralPath $ResolvedDestinationZipPath)
|
|
{
|
|
Remove-ItemWithRetry -Path $ResolvedDestinationZipPath
|
|
}
|
|
|
|
$ZipReport = [ordered]@{
|
|
reportVersion = 'ht-packaged-build-zip-export/v2'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
packageRoot = $ResolvedPackageRoot
|
|
packagedExecutablePath = $PackagedExecutablePath
|
|
destinationZipPath = $ResolvedDestinationZipPath
|
|
result = 'failed'
|
|
entryCount = 0
|
|
packagedExecutableEntry = $null
|
|
requiredEntries = @($RequiredPackageFiles)
|
|
archivedRequiredEntries = @()
|
|
zipSizeBytes = 0
|
|
zipSha256 = $null
|
|
error = $null
|
|
}
|
|
|
|
try
|
|
{
|
|
Invoke-ZipCreationWithRetry `
|
|
-SourceDirectory $ResolvedPackageRoot `
|
|
-DestinationZipPath $ResolvedDestinationZipPath
|
|
|
|
$ZipArchive = [System.IO.Compression.ZipFile]::OpenRead($ResolvedDestinationZipPath)
|
|
try
|
|
{
|
|
$Entries = @($ZipArchive.Entries)
|
|
$ZipReport.entryCount = $Entries.Count
|
|
|
|
$ExecutableEntry = $Entries | Where-Object {
|
|
Test-ZipEntryMatchesExecutablePath -EntryFullName $_.FullName
|
|
} | Select-Object -First 1
|
|
if ($null -eq $ExecutableEntry)
|
|
{
|
|
throw "The exported zip '$ResolvedDestinationZipPath' did not contain a packaged UnrealHyperTwist executable entry."
|
|
}
|
|
|
|
$ZipReport.packagedExecutableEntry = $ExecutableEntry.FullName
|
|
if ($ZipReport.entryCount -le 0)
|
|
{
|
|
throw "The exported zip '$ResolvedDestinationZipPath' contained zero entries."
|
|
}
|
|
|
|
foreach ($RequiredFile in $RequiredPackageFiles)
|
|
{
|
|
$MatchingRequiredEntries = @($Entries | Where-Object {
|
|
$_.FullName.Replace('\', '/').Equals(
|
|
$RequiredFile.relativePath,
|
|
[System.StringComparison]::OrdinalIgnoreCase)
|
|
})
|
|
if ($MatchingRequiredEntries.Count -ne 1)
|
|
{
|
|
throw (
|
|
"The exported zip '$ResolvedDestinationZipPath' contained " +
|
|
"$($MatchingRequiredEntries.Count) copies of required $($RequiredFile.kind) " +
|
|
"entry '$($RequiredFile.relativePath)'; expected exactly one."
|
|
)
|
|
}
|
|
|
|
$RequiredEntry = $MatchingRequiredEntries[0]
|
|
if ([Int64]$RequiredEntry.Length -ne [Int64]$RequiredFile.sizeBytes)
|
|
{
|
|
throw "Required zip entry '$($RequiredFile.relativePath)' had an unexpected uncompressed size."
|
|
}
|
|
|
|
$ArchivedSha256 = Get-ZipEntrySha256 -Entry $RequiredEntry
|
|
if ($ArchivedSha256 -ne $RequiredFile.sha256)
|
|
{
|
|
throw (
|
|
"Required zip entry '$($RequiredFile.relativePath)' did not preserve " +
|
|
"the source SHA-256 '$($RequiredFile.sha256)'."
|
|
)
|
|
}
|
|
|
|
$ZipReport.archivedRequiredEntries += @(
|
|
[pscustomobject][ordered]@{
|
|
kind = $RequiredFile.kind
|
|
relativePath = $RequiredFile.relativePath
|
|
sizeBytes = [Int64]$RequiredEntry.Length
|
|
sha256 = $ArchivedSha256
|
|
}
|
|
)
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if ($null -ne $ZipArchive)
|
|
{
|
|
$ZipArchive.Dispose()
|
|
}
|
|
}
|
|
|
|
$ZipItem = Get-Item -LiteralPath $ResolvedDestinationZipPath
|
|
$ZipReport.zipSizeBytes = $ZipItem.Length
|
|
$ZipReport.zipSha256 = (Get-FileHash -LiteralPath $ResolvedDestinationZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$ZipReport.result = 'passed'
|
|
}
|
|
catch
|
|
{
|
|
$ZipReport.error = $_.Exception.Message
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
|
|
throw
|
|
}
|
|
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
|
|
Write-Host "Exported verified packaged zip to '$ResolvedDestinationZipPath'."
|