464 lines
17 KiB
PowerShell
464 lines
17 KiB
PowerShell
param(
|
|
[string]$PackageRoot = 'C:\HyperTwist_worktrees\phase10validate_packaged_alpha_release19_shipping_20260723',
|
|
[string]$OutputDirectory = 'C:\HyperTwist_worktrees\installer-release19-20260726',
|
|
[string]$InstallerFileName = 'HyperTwist-Alpha-Test-20260723-R19-Setup.exe',
|
|
[string]$ProductVersion = '0.19.0.0',
|
|
[string]$DisplayVersion = 'Alpha R19',
|
|
[string]$ReleaseId = '20260723-R19',
|
|
[string]$MakensisPath = 'C:\Program Files (x86)\NSIS\makensis.exe',
|
|
[string]$VcRedistPath = '',
|
|
[string]$ReportPath = '',
|
|
[switch]$KeepBuildContext
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$ExpectedSolverTableSizeBytes = 676207080
|
|
$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034'
|
|
|
|
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 12
|
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
|
}
|
|
|
|
function Resolve-WindowsPayloadRoot {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath
|
|
)
|
|
|
|
$ResolvedRootPath = (Resolve-Path -LiteralPath $RootPath).Path
|
|
$WindowsCandidate = Join-Path $ResolvedRootPath 'Windows'
|
|
if (Test-Path -LiteralPath (Join-Path $WindowsCandidate 'UnrealHyperTwist.exe') -PathType Leaf)
|
|
{
|
|
return $WindowsCandidate
|
|
}
|
|
|
|
if (Test-Path -LiteralPath (Join-Path $ResolvedRootPath 'UnrealHyperTwist.exe') -PathType Leaf)
|
|
{
|
|
return $ResolvedRootPath
|
|
}
|
|
|
|
throw "No packaged Windows payload was found beneath '$RootPath'."
|
|
}
|
|
|
|
function Get-NormalizedRelativePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$FilePath
|
|
)
|
|
|
|
$RootPrefix = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
|
$ResolvedFilePath = [System.IO.Path]::GetFullPath($FilePath)
|
|
if (-not $ResolvedFilePath.StartsWith(
|
|
$RootPrefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase))
|
|
{
|
|
throw "File '$ResolvedFilePath' is outside payload root '$RootPath'."
|
|
}
|
|
|
|
return $ResolvedFilePath.Substring($RootPrefix.Length).Replace('\', '/')
|
|
}
|
|
|
|
function Assert-RequiredPayloadFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$PayloadRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RelativePath,
|
|
[Int64]$ExpectedSizeBytes = -1,
|
|
[string]$ExpectedSha256 = ''
|
|
)
|
|
|
|
$RequiredPath = Join-Path $PayloadRoot $RelativePath
|
|
if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf))
|
|
{
|
|
throw "Required payload file '$RelativePath' was not found."
|
|
}
|
|
|
|
$RequiredItem = Get-Item -LiteralPath $RequiredPath
|
|
if ($RequiredItem.Length -le 0)
|
|
{
|
|
throw "Required payload file '$RelativePath' is empty."
|
|
}
|
|
if ($ExpectedSizeBytes -ge 0 -and $RequiredItem.Length -ne $ExpectedSizeBytes)
|
|
{
|
|
throw "Required payload file '$RelativePath' has an unexpected size."
|
|
}
|
|
|
|
$ActualSha256 = (Get-FileHash -LiteralPath $RequiredPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) `
|
|
-and $ActualSha256 -ne $ExpectedSha256.ToLowerInvariant())
|
|
{
|
|
throw "Required payload file '$RelativePath' has an unexpected SHA-256."
|
|
}
|
|
}
|
|
|
|
function Resolve-VcRedistPath {
|
|
param(
|
|
[string]$RequestedPath
|
|
)
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($RequestedPath))
|
|
{
|
|
if (-not (Test-Path -LiteralPath $RequestedPath -PathType Leaf))
|
|
{
|
|
throw "Requested VC++ redistributable '$RequestedPath' was not found."
|
|
}
|
|
|
|
return (Resolve-Path -LiteralPath $RequestedPath).Path
|
|
}
|
|
|
|
$CandidateRoots = @(
|
|
'C:\Program Files\Epic Games\UE_5.7\Engine\Extras\Redist',
|
|
'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist',
|
|
'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Redist'
|
|
)
|
|
$Candidates = @(
|
|
foreach ($CandidateRoot in $CandidateRoots)
|
|
{
|
|
if (Test-Path -LiteralPath $CandidateRoot)
|
|
{
|
|
Get-ChildItem -LiteralPath $CandidateRoot `
|
|
-Filter 'vc_redist.x64.exe' `
|
|
-File `
|
|
-Recurse `
|
|
-ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
)
|
|
$Candidate = $Candidates |
|
|
Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending |
|
|
Select-Object -First 1
|
|
if ($null -eq $Candidate)
|
|
{
|
|
throw 'No Microsoft Visual C++ 2015-2022 x64 redistributable was found.'
|
|
}
|
|
|
|
return $Candidate.FullName
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container))
|
|
{
|
|
throw "Package root '$PackageRoot' was not found."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $MakensisPath -PathType Leaf))
|
|
{
|
|
throw "NSIS compiler '$MakensisPath' was not found."
|
|
}
|
|
try
|
|
{
|
|
$ParsedProductVersion = [version]$ProductVersion
|
|
}
|
|
catch
|
|
{
|
|
throw "ProductVersion '$ProductVersion' is not a valid numeric Windows file version."
|
|
}
|
|
if ($ParsedProductVersion.Build -lt 0 -or $ParsedProductVersion.Revision -lt 0)
|
|
{
|
|
throw 'ProductVersion must contain exactly four numeric components.'
|
|
}
|
|
if ($InstallerFileName -ne [System.IO.Path]::GetFileName($InstallerFileName) `
|
|
-or [System.IO.Path]::GetExtension($InstallerFileName) -ne '.exe')
|
|
{
|
|
throw "InstallerFileName '$InstallerFileName' must be a leaf .exe filename."
|
|
}
|
|
foreach ($MetadataValue in @($DisplayVersion, $ReleaseId))
|
|
{
|
|
if ([string]::IsNullOrWhiteSpace($MetadataValue) `
|
|
-or $MetadataValue -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$')
|
|
{
|
|
throw "Installer metadata value '$MetadataValue' contains unsupported characters or length."
|
|
}
|
|
}
|
|
|
|
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
|
$ResolvedPayloadRoot = Resolve-WindowsPayloadRoot -RootPath $ResolvedPackageRoot
|
|
$ResolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)
|
|
$ResolvedInstallerPath = Join-Path $ResolvedOutputDirectory $InstallerFileName
|
|
$PayloadRootPrefix = $ResolvedPayloadRoot.TrimEnd('\') + '\'
|
|
if ($ResolvedInstallerPath.StartsWith(
|
|
$PayloadRootPrefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase))
|
|
{
|
|
throw 'Installer output must not be written inside its source payload.'
|
|
}
|
|
$RepositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
|
|
$NsiScriptPath = Join-Path $RepositoryRoot 'packaging\windows\HyperTwistInstaller.nsi'
|
|
$ReadinessScriptPath = Join-Path $RepositoryRoot 'scripts\Test-HyperTwistWindowsReadiness.ps1'
|
|
if (-not (Test-Path -LiteralPath $NsiScriptPath -PathType Leaf))
|
|
{
|
|
throw "Installer definition '$NsiScriptPath' was not found."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $ReadinessScriptPath -PathType Leaf))
|
|
{
|
|
throw "Readiness script '$ReadinessScriptPath' was not found."
|
|
}
|
|
|
|
$ResolvedVcRedistPath = Resolve-VcRedistPath -RequestedPath $VcRedistPath
|
|
$VcRedistSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedVcRedistPath
|
|
if ($VcRedistSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid)
|
|
{
|
|
throw "VC++ redistributable '$ResolvedVcRedistPath' does not have a valid Authenticode signature."
|
|
}
|
|
|
|
$RequiredRelativePaths = @(
|
|
'UnrealHyperTwist.exe',
|
|
'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-Shipping.exe',
|
|
'UnrealHyperTwist\Binaries\Win64\tbb12.dll',
|
|
'UnrealHyperTwist\Binaries\Win64\tbbmalloc.dll',
|
|
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.pak',
|
|
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.utoc',
|
|
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.ucas',
|
|
'Content\Browser\index.html',
|
|
'Content\Browser\src\browser-runtime-bootstrap.js',
|
|
'Content\Browser\src\browser-spatial-runtime-fallback.js',
|
|
'Content\Browser\dist\browser-spatial-runtime.js'
|
|
)
|
|
foreach ($RequiredRelativePath in $RequiredRelativePaths)
|
|
{
|
|
Assert-RequiredPayloadFile `
|
|
-PayloadRoot $ResolvedPayloadRoot `
|
|
-RelativePath $RequiredRelativePath
|
|
}
|
|
Assert-RequiredPayloadFile `
|
|
-PayloadRoot $ResolvedPayloadRoot `
|
|
-RelativePath 'UnrealHyperTwist\Saved\twophase-ht.tbl' `
|
|
-ExpectedSizeBytes $ExpectedSolverTableSizeBytes `
|
|
-ExpectedSha256 $ExpectedSolverTableSha256
|
|
|
|
$ReparsePoints = @(
|
|
Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -Recurse |
|
|
Where-Object {
|
|
$_.Attributes -band [System.IO.FileAttributes]::ReparsePoint
|
|
}
|
|
)
|
|
if ($ReparsePoints.Count -gt 0)
|
|
{
|
|
throw (
|
|
"The payload contains reparse points, which are not permitted in an installer source: " +
|
|
(($ReparsePoints | Select-Object -ExpandProperty FullName) -join ', ')
|
|
)
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $ResolvedOutputDirectory | Out-Null
|
|
$ValidationDirectory = Join-Path $ResolvedOutputDirectory 'validation'
|
|
$BuildContextDirectory = Join-Path $ResolvedOutputDirectory 'build-context'
|
|
New-Item -ItemType Directory -Force -Path $ValidationDirectory | Out-Null
|
|
if (Test-Path -LiteralPath $BuildContextDirectory)
|
|
{
|
|
Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force
|
|
}
|
|
New-Item -ItemType Directory -Force -Path $BuildContextDirectory | Out-Null
|
|
|
|
if ([string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
$ReportPath = Join-Path $ValidationDirectory 'windows-installer-build-report.json'
|
|
}
|
|
$PayloadManifestPath = Join-Path $ValidationDirectory 'windows-installer-payload-manifest.json'
|
|
$SourceReadinessReportPath = Join-Path $ValidationDirectory 'windows-installer-source-readiness-report.json'
|
|
$MakensisLogPath = Join-Path $ValidationDirectory 'makensis.log'
|
|
$InstallerIconPath = Join-Path $BuildContextDirectory 'HyperTwist.ico'
|
|
|
|
$BuildReport = [ordered]@{
|
|
reportVersion = 'ht-windows-installer-build/v1'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
result = 'failed'
|
|
releaseId = $ReleaseId
|
|
displayVersion = $DisplayVersion
|
|
productVersion = $ProductVersion
|
|
packageRoot = $ResolvedPackageRoot
|
|
payloadRoot = $ResolvedPayloadRoot
|
|
installerPath = $ResolvedInstallerPath
|
|
payloadManifestPath = $PayloadManifestPath
|
|
sourceReadinessReportPath = $SourceReadinessReportPath
|
|
makensisPath = (Resolve-Path -LiteralPath $MakensisPath).Path
|
|
makensisLogPath = $MakensisLogPath
|
|
vcRedist = $null
|
|
excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt')
|
|
payloadFileCount = 0
|
|
payloadSizeBytes = 0
|
|
installerSizeBytes = 0
|
|
installerSha256 = $null
|
|
installerSignatureStatus = $null
|
|
incompleteInstallerRemoved = $false
|
|
error = $null
|
|
}
|
|
|
|
try
|
|
{
|
|
$VcRedistItem = Get-Item -LiteralPath $ResolvedVcRedistPath
|
|
$BuildReport.vcRedist = [pscustomobject][ordered]@{
|
|
path = $ResolvedVcRedistPath
|
|
version = [string]$VcRedistItem.VersionInfo.FileVersion
|
|
sizeBytes = [Int64]$VcRedistItem.Length
|
|
sha256 = (Get-FileHash -LiteralPath $ResolvedVcRedistPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
signatureStatus = $VcRedistSignature.Status.ToString()
|
|
signer = [string]$VcRedistSignature.SignerCertificate.Subject
|
|
}
|
|
|
|
$PayloadFiles = @(
|
|
Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -File -Recurse |
|
|
Where-Object {
|
|
$_.Extension -ne '.pdb' `
|
|
-and $_.Name -ne 'Manifest_DebugFiles_Win64.txt'
|
|
} |
|
|
Sort-Object FullName
|
|
)
|
|
$PayloadManifestEntries = @(
|
|
foreach ($PayloadFile in $PayloadFiles)
|
|
{
|
|
[pscustomobject][ordered]@{
|
|
relativePath = Get-NormalizedRelativePath `
|
|
-RootPath $ResolvedPayloadRoot `
|
|
-FilePath $PayloadFile.FullName
|
|
sizeBytes = [Int64]$PayloadFile.Length
|
|
sha256 = (Get-FileHash -LiteralPath $PayloadFile.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
}
|
|
}
|
|
)
|
|
$PayloadSizeBytes = [Int64](($PayloadFiles | Measure-Object Length -Sum).Sum)
|
|
$PayloadManifest = [pscustomobject][ordered]@{
|
|
manifestVersion = 'ht-windows-installer-payload/v1'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
releaseId = $ReleaseId
|
|
sourcePayloadRoot = $ResolvedPayloadRoot
|
|
excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt')
|
|
fileCount = $PayloadManifestEntries.Count
|
|
totalSizeBytes = $PayloadSizeBytes
|
|
files = $PayloadManifestEntries
|
|
}
|
|
Write-Utf8JsonFile -Path $PayloadManifestPath -Value $PayloadManifest
|
|
$BuildReport.payloadFileCount = $PayloadManifestEntries.Count
|
|
$BuildReport.payloadSizeBytes = $PayloadSizeBytes
|
|
|
|
$ReadinessReport = & $ReadinessScriptPath `
|
|
-PayloadRoot $ResolvedPayloadRoot `
|
|
-TargetInstallRoot (Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'HyperTwist') `
|
|
-ExpectedPayloadManifestPath $PayloadManifestPath `
|
|
-ReportPath $SourceReadinessReportPath `
|
|
-VerifyPayloadHashes `
|
|
-PassThru
|
|
if ($ReadinessReport.result -eq 'blocked')
|
|
{
|
|
throw 'Source payload or Windows host readiness is blocked.'
|
|
}
|
|
|
|
Add-Type -AssemblyName System.Drawing
|
|
$PayloadExecutablePath = Join-Path $ResolvedPayloadRoot 'UnrealHyperTwist.exe'
|
|
$InstallerIcon = [System.Drawing.Icon]::ExtractAssociatedIcon($PayloadExecutablePath)
|
|
if ($null -eq $InstallerIcon)
|
|
{
|
|
throw "No application icon could be extracted from '$PayloadExecutablePath'."
|
|
}
|
|
$IconStream = [System.IO.File]::Open(
|
|
$InstallerIconPath,
|
|
[System.IO.FileMode]::Create,
|
|
[System.IO.FileAccess]::Write,
|
|
[System.IO.FileShare]::None
|
|
)
|
|
try
|
|
{
|
|
$InstallerIcon.Save($IconStream)
|
|
}
|
|
finally
|
|
{
|
|
$IconStream.Dispose()
|
|
$InstallerIcon.Dispose()
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $ResolvedInstallerPath)
|
|
{
|
|
Remove-Item -LiteralPath $ResolvedInstallerPath -Force
|
|
}
|
|
|
|
$PayloadSizeKilobytes = [Math]::Ceiling($PayloadSizeBytes / 1KB)
|
|
$MakensisArguments = @(
|
|
'/V4',
|
|
"/DPRODUCT_VERSION=$ProductVersion",
|
|
"/DDISPLAY_VERSION=$DisplayVersion",
|
|
"/DRELEASE_ID=$ReleaseId",
|
|
"/DOUTPUT_FILE=$ResolvedInstallerPath",
|
|
"/DPAYLOAD_ROOT=$ResolvedPayloadRoot",
|
|
"/DPAYLOAD_MANIFEST=$PayloadManifestPath",
|
|
"/DREADINESS_SCRIPT=$ReadinessScriptPath",
|
|
"/DVC_REDIST_PATH=$ResolvedVcRedistPath",
|
|
"/DINSTALLER_ICON=$InstallerIconPath",
|
|
"/DPAYLOAD_SIZE_KB=$PayloadSizeKilobytes",
|
|
$NsiScriptPath
|
|
)
|
|
|
|
$MakensisOutput = @(
|
|
& $MakensisPath @MakensisArguments 2>&1 |
|
|
ForEach-Object {
|
|
$Line = $_.ToString()
|
|
[Console]::Out.WriteLine($Line)
|
|
$Line
|
|
}
|
|
)
|
|
$MakensisExitCode = $LASTEXITCODE
|
|
[System.IO.File]::WriteAllLines(
|
|
$MakensisLogPath,
|
|
$MakensisOutput,
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
if ($MakensisExitCode -ne 0)
|
|
{
|
|
throw "NSIS compilation failed with exit code $MakensisExitCode."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf))
|
|
{
|
|
throw "NSIS did not create '$ResolvedInstallerPath'."
|
|
}
|
|
|
|
$InstallerItem = Get-Item -LiteralPath $ResolvedInstallerPath
|
|
if ($InstallerItem.Length -le 0)
|
|
{
|
|
throw 'The generated installer is empty.'
|
|
}
|
|
$InstallerSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedInstallerPath
|
|
$BuildReport.installerSizeBytes = [Int64]$InstallerItem.Length
|
|
$BuildReport.installerSha256 = (Get-FileHash -LiteralPath $ResolvedInstallerPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$BuildReport.installerSignatureStatus = $InstallerSignature.Status.ToString()
|
|
$BuildReport.result = 'passed'
|
|
}
|
|
catch
|
|
{
|
|
$BuildReport.error = $_.Exception.Message
|
|
throw
|
|
}
|
|
finally
|
|
{
|
|
if ($BuildReport.result -ne 'passed' `
|
|
-and (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf))
|
|
{
|
|
Remove-Item -LiteralPath $ResolvedInstallerPath -Force
|
|
$BuildReport.incompleteInstallerRemoved = $true
|
|
}
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $BuildReport
|
|
if (-not $KeepBuildContext -and (Test-Path -LiteralPath $BuildContextDirectory))
|
|
{
|
|
Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force
|
|
}
|
|
}
|
|
|
|
[Console]::Out.WriteLine('HyperTwist Windows installer built successfully.')
|
|
[Console]::Out.WriteLine("Installer: $ResolvedInstallerPath")
|
|
[Console]::Out.WriteLine("SHA-256: $($BuildReport.installerSha256)")
|
|
[Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))")
|