diff --git a/export-windows.ps1 b/export-windows.ps1 index db29ddb..85b73d0 100644 --- a/export-windows.ps1 +++ b/export-windows.ps1 @@ -18,20 +18,16 @@ function Require-Environment([string]$Name) { } function Test-PathIsWithinDirectory([string]$ParentDirectory, [string]$CandidateDirectory) { - # [IO.Path]::GetRelativePath is not available in Windows PowerShell 5.1. - # Compare complete paths with a separator boundary so, for example, - # C:\workspace-other is not considered a child of C:\workspace. $parentPath = [IO.Path]::GetFullPath($ParentDirectory) $candidatePath = [IO.Path]::GetFullPath($CandidateDirectory) - $directorySeparator = [IO.Path]::DirectorySeparatorChar.ToString() - $alternateSeparator = [IO.Path]::AltDirectorySeparatorChar.ToString() + $separator = [IO.Path]::DirectorySeparatorChar.ToString() if ($candidatePath.Equals($parentPath, [StringComparison]::OrdinalIgnoreCase)) { return $true } - if (-not $parentPath.EndsWith($directorySeparator) -and -not $parentPath.EndsWith($alternateSeparator)) { - $parentPath += $directorySeparator + if (-not $parentPath.EndsWith($separator)) { + $parentPath += $separator } return $candidatePath.StartsWith($parentPath, [StringComparison]::OrdinalIgnoreCase) @@ -42,6 +38,7 @@ function Get-ActualArchitecture { if ([string]::IsNullOrWhiteSpace($architecture)) { $architecture = $env:PROCESSOR_ARCHITECTURE } + switch ($architecture.ToUpperInvariant()) { 'AMD64' { return 'x86_64' } 'X86' { return 'x86_32' } @@ -51,49 +48,159 @@ function Get-ActualArchitecture { } function Get-Checksum([string]$ManifestPath, [string]$Asset) { - $line = Get-Content -LiteralPath $ManifestPath | Where-Object { - $_ -match '^([0-9A-Fa-f]{128})\s+\*?(.+)$' -and $Matches[2] -eq $Asset - } | Select-Object -First 1 - if (-not $line) { - Fail "Godot $GodotVersion does not publish required $edition asset '$Asset' for Windows/$HostArch at $script:BaseUrl. Choose a supported stable release and architecture." + foreach ($line in Get-Content -LiteralPath $ManifestPath) { + if ($line -match '^([0-9A-Fa-f]{128})\s+\*?(.+)$' -and $Matches[2] -eq $Asset) { + return $Matches[1].ToLowerInvariant() + } } - return ($line -split '\s+')[0].ToLowerInvariant() + + Fail "Godot $GodotVersion does not publish required $edition asset '$Asset' for Windows/$HostArch at $script:BaseUrl. Choose a supported stable release and architecture." } function Download-AndVerify([string]$Asset, [string]$Destination, [string]$ExpectedChecksum) { - Invoke-WebRequest -Uri "$script:BaseUrl/$Asset" -OutFile $Destination - $actual = (Get-FileHash -LiteralPath $Destination -Algorithm SHA512).Hash.ToLowerInvariant() - if ($actual -ne $ExpectedChecksum) { + Invoke-WebRequest -UseBasicParsing -Uri "$script:BaseUrl/$Asset" -OutFile $Destination + $actualChecksum = (Get-FileHash -LiteralPath $Destination -Algorithm SHA512).Hash.ToLowerInvariant() + if ($actualChecksum -ne $ExpectedChecksum) { Fail "SHA-512 verification failed for $Asset." } } -function Find-Editor([string]$Directory) { - $console = Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'Godot*console.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($console) { return $console.FullName } - $editor = Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'Godot*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($editor) { return $editor.FullName } +function Get-CachePaths([string]$Root) { + $editorDirectory = Join-Path $Root 'editor' + $userHome = Join-Path $Root 'home' + $templateDirectory = Join-Path $userHome "AppData\Roaming\Godot\export_templates\$templateVersion" + + return [PSCustomObject]@{ + Root = $Root + EditorDirectory = $editorDirectory + UserHome = $userHome + TemplateDirectory = $templateDirectory + Marker = Join-Path $Root '.complete' + } +} + +function Get-EditorExecutable([string]$EditorDirectory) { + $matches = @(Get-ChildItem -LiteralPath $EditorDirectory -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $expectedEditorFileName }) + + if ($matches.Count -eq 1) { + return $matches[0].FullName + } + return $null } -function Install-ExportTemplates([string]$StagingDirectory, [string]$TemplateDirectory, [string]$TemplateAsset, [string]$ExpectedVersion) { +function Get-PeMachine([string]$Path) { + try { + $bytes = [IO.File]::ReadAllBytes($Path) + } catch { + return $null + } + + if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + return $null + } + + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset -gt ($bytes.Length - 6)) { + return $null + } + + if ($bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45 -or $bytes[$peOffset + 2] -ne 0 -or $bytes[$peOffset + 3] -ne 0) { + return $null + } + + return [BitConverter]::ToUInt16($bytes, $peOffset + 4) +} + +function Test-ExpectedEditor([string]$EditorDirectory) { + $editorExecutable = Get-EditorExecutable $EditorDirectory + if (-not $editorExecutable) { + return $false + } + + return (Get-PeMachine $editorExecutable) -eq $expectedPeMachine +} + +function Assert-ExpectedEditor([string]$EditorDirectory) { + $editorExecutable = Get-EditorExecutable $EditorDirectory + if (-not $editorExecutable) { + Fail "The Godot editor archive did not contain the expected console executable '$expectedEditorFileName'." + } + + $machine = Get-PeMachine $editorExecutable + if ($machine -ne $expectedPeMachine) { + Fail "The expected Godot console executable '$expectedEditorFileName' is not a valid $HostArch Windows executable." + } + + return $editorExecutable +} + +function Test-ValidCache($Paths) { + if (-not (Test-Path -LiteralPath $Paths.Marker -PathType Leaf)) { + return $false + } + + $installedVersion = Join-Path $Paths.TemplateDirectory 'version.txt' + if (-not (Test-Path -LiteralPath $installedVersion -PathType Leaf)) { + return $false + } + + if ((Get-Content -LiteralPath $installedVersion -Raw).Trim() -ne $templateVersion) { + return $false + } + + return Test-ExpectedEditor $Paths.EditorDirectory +} + +function Install-ExportTemplates($Paths, [string]$StagingDirectory, [string]$TemplateAsset) { $stagedTemplates = Join-Path $StagingDirectory 'templates' $stagedVersion = Join-Path $stagedTemplates 'version.txt' if (-not (Test-Path -LiteralPath $stagedVersion -PathType Leaf)) { Fail "The export-template archive '$TemplateAsset' did not contain templates/version.txt." } + $actualVersion = (Get-Content -LiteralPath $stagedVersion -Raw).Trim() - if ($actualVersion -ne $ExpectedVersion) { - Fail "The export-template archive '$TemplateAsset' reports template version '$actualVersion', expected '$ExpectedVersion'." + if ($actualVersion -ne $templateVersion) { + Fail "The export-template archive '$TemplateAsset' reports template version '$actualVersion', expected '$templateVersion'." } - New-Item -ItemType Directory -Force -Path $TemplateDirectory | Out-Null - Get-ChildItem -LiteralPath $stagedTemplates -Force | Copy-Item -Destination $TemplateDirectory -Recurse -Force - Remove-Item -LiteralPath $StagingDirectory -Recurse -Force - if (-not (Test-Path -LiteralPath (Join-Path $TemplateDirectory 'version.txt') -PathType Leaf)) { - Fail "The export-template archive '$TemplateAsset' could not be installed to '$TemplateDirectory'." + + New-Item -ItemType Directory -Force -Path $Paths.TemplateDirectory | Out-Null + Get-ChildItem -LiteralPath $stagedTemplates -Force | Copy-Item -Destination $Paths.TemplateDirectory -Recurse -Force + + if (-not (Test-Path -LiteralPath (Join-Path $Paths.TemplateDirectory 'version.txt') -PathType Leaf)) { + Fail "The export-template archive '$TemplateAsset' could not be installed to '$($Paths.TemplateDirectory)'." } } +function Install-Cache($Paths) { + New-Item -ItemType Directory -Force -Path $Paths.EditorDirectory | Out-Null + + $manifestPath = Join-Path $Paths.Root 'SHA512-SUMS.txt' + $editorArchive = Join-Path $Paths.Root $editorAsset + $templatesArchive = Join-Path $Paths.Root $templatesAsset + $templatesZipArchive = Join-Path $Paths.Root 'export-templates.zip' + $templatesStagingDirectory = Join-Path $Paths.Root 'templates-staging' + + Invoke-WebRequest -UseBasicParsing -Uri "$script:BaseUrl/SHA512-SUMS.txt" -OutFile $manifestPath + $editorChecksum = Get-Checksum $manifestPath $editorAsset + $templatesChecksum = Get-Checksum $manifestPath $templatesAsset + Download-AndVerify $editorAsset $editorArchive $editorChecksum + Download-AndVerify $templatesAsset $templatesArchive $templatesChecksum + + Expand-Archive -LiteralPath $editorArchive -DestinationPath $Paths.EditorDirectory -Force + Assert-ExpectedEditor $Paths.EditorDirectory | Out-Null + + # Expand-Archive only recognizes .zip files. The verified .tpz payload is a ZIP archive. + Move-Item -LiteralPath $templatesArchive -Destination $templatesZipArchive + Expand-Archive -LiteralPath $templatesZipArchive -DestinationPath $templatesStagingDirectory -Force + Install-ExportTemplates $Paths $templatesStagingDirectory $templatesAsset + Remove-Item -LiteralPath $templatesStagingDirectory -Recurse -Force + + @("release=$GodotVersion", "editor=$editorAsset", "templates=$templatesAsset") | + Set-Content -LiteralPath $Paths.Marker -NoNewline +} + $GodotVersion = Require-Environment 'GODOT_VERSION' $HostOs = Require-Environment 'GODOT_HOST_OS' $HostArch = Require-Environment 'GODOT_HOST_ARCH' @@ -113,7 +220,9 @@ if ($ExportMode -notin @('release', 'debug')) { Fail "export-mode must be 'relea if ([string]::IsNullOrWhiteSpace($ExportPreset)) { Fail 'export-preset must not be empty.' } if ([string]::IsNullOrWhiteSpace($ExportFileName) -or $ExportFileName -in @('.', '..') -or $ExportFileName.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0 -or $ExportFileName.Contains('/') -or $ExportFileName.Contains('\')) { Fail 'export-file-name must be a filename without path separators.' } if ($HostArch -notin @('x86_32', 'x86_64', 'arm64')) { Fail "Unsupported Windows host-arch '$HostArch'." } -if ($HostArch -ne (Get-ActualArchitecture)) { Fail "host-arch '$HostArch' does not match this Windows runner ($(Get-ActualArchitecture))." } + +$actualArchitecture = Get-ActualArchitecture +if ($HostArch -ne $actualArchitecture) { Fail "host-arch '$HostArch' does not match this Windows runner ($actualArchitecture)." } $workspaceDirectory = [IO.Path]::GetFullPath($Workspace) if ([IO.Path]::IsPathRooted($ProjectPath)) { Fail 'project-path must be relative to the checked-out workspace.' } @@ -126,69 +235,71 @@ if (-not (Select-String -LiteralPath $PresetFile -SimpleMatch "name=`"$ExportPre if ($Dotnet -eq 'true') { $edition = 'dotnet' - $editorAsset = switch ($HostArch) { - 'x86_32' { "Godot_v${GodotVersion}_mono_win32.zip" } - 'x86_64' { "Godot_v${GodotVersion}_mono_win64.zip" } - 'arm64' { "Godot_v${GodotVersion}_mono_windows_arm64.zip" } + $editorBaseName = switch ($HostArch) { + 'x86_32' { "Godot_v${GodotVersion}_mono_win32" } + 'x86_64' { "Godot_v${GodotVersion}_mono_win64" } + 'arm64' { "Godot_v${GodotVersion}_mono_windows_arm64" } } + $editorAsset = "$editorBaseName.zip" $templatesAsset = "Godot_v${GodotVersion}_mono_export_templates.tpz" $templateVersion = ($GodotVersion -replace '-', '.') + '.mono' } else { $edition = 'standard' - $editorAsset = switch ($HostArch) { - 'x86_32' { "Godot_v${GodotVersion}_win32.exe.zip" } - 'x86_64' { "Godot_v${GodotVersion}_win64.exe.zip" } - 'arm64' { "Godot_v${GodotVersion}_windows_arm64.exe.zip" } + $editorBaseName = switch ($HostArch) { + 'x86_32' { "Godot_v${GodotVersion}_win32" } + 'x86_64' { "Godot_v${GodotVersion}_win64" } + 'arm64' { "Godot_v${GodotVersion}_windows_arm64" } } + $editorAsset = "$editorBaseName.exe.zip" $templatesAsset = "Godot_v${GodotVersion}_export_templates.tpz" $templateVersion = $GodotVersion -replace '-', '.' } +$expectedEditorFileName = "${editorBaseName}_console.exe" +$expectedPeMachine = switch ($HostArch) { + 'x86_32' { [uint16]0x014c } + 'x86_64' { [uint16]0x8664 } + 'arm64' { [uint16]0xaa64 } +} + $script:BaseUrl = "https://github.com/godotengine/godot/releases/download/$GodotVersion" $cacheDirectory = Join-Path $ActionRoot "cache\$GodotVersion\$edition\windows-$HostArch" -$editorDirectory = Join-Path $cacheDirectory 'editor' -$userHome = Join-Path $cacheDirectory 'home' -$templateDirectory = Join-Path $userHome "AppData\Roaming\Godot\export_templates\$templateVersion" -$marker = Join-Path $cacheDirectory '.complete' +$cachePaths = Get-CachePaths $cacheDirectory -$editorExecutable = $null -if ((Test-Path -LiteralPath $marker -PathType Leaf) -and (Test-Path -LiteralPath (Join-Path $templateDirectory 'version.txt') -PathType Leaf)) { - $editorExecutable = Find-Editor $editorDirectory -} +if (-not (Test-ValidCache $cachePaths)) { + $cacheParent = Split-Path -Parent $cacheDirectory + $stagingDirectory = Join-Path $cacheParent ((Split-Path -Leaf $cacheDirectory) + ".staging-$PID") + if (Test-Path -LiteralPath $stagingDirectory) { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force } + New-Item -ItemType Directory -Force -Path $cacheParent | Out-Null -if (-not $editorExecutable) { - if (Test-Path -LiteralPath $cacheDirectory) { Remove-Item -LiteralPath $cacheDirectory -Recurse -Force } - New-Item -ItemType Directory -Force -Path $editorDirectory | Out-Null - $manifestPath = Join-Path $cacheDirectory 'SHA512-SUMS.txt' - $editorArchive = Join-Path $cacheDirectory $editorAsset - $templatesArchive = Join-Path $cacheDirectory $templatesAsset - $templatesZipArchive = Join-Path $cacheDirectory ($templatesAsset -replace '\.tpz$', '.zip') - $templatesStagingDirectory = Join-Path $cacheDirectory 'templates-staging' - Invoke-WebRequest -Uri "$script:BaseUrl/SHA512-SUMS.txt" -OutFile $manifestPath - $editorChecksum = Get-Checksum $manifestPath $editorAsset - $templatesChecksum = Get-Checksum $manifestPath $templatesAsset - Download-AndVerify $editorAsset $editorArchive $editorChecksum - Download-AndVerify $templatesAsset $templatesArchive $templatesChecksum - Expand-Archive -LiteralPath $editorArchive -DestinationPath $editorDirectory -Force - Move-Item -LiteralPath $templatesArchive -Destination $templatesZipArchive - Expand-Archive -LiteralPath $templatesZipArchive -DestinationPath $templatesStagingDirectory -Force - Install-ExportTemplates $templatesStagingDirectory $templateDirectory $templatesAsset $templateVersion - $editorExecutable = Find-Editor $editorDirectory - if (-not $editorExecutable) { Fail 'The Godot editor archive did not contain an executable.' } - if (-not (Test-Path -LiteralPath (Join-Path $templateDirectory 'version.txt') -PathType Leaf)) { Fail 'The export-template archive did not contain version.txt.' } - @("release=$GodotVersion", "editor=$editorAsset", "templates=$templatesAsset") | Set-Content -LiteralPath $marker -NoNewline + try { + $stagingPaths = Get-CachePaths $stagingDirectory + Install-Cache $stagingPaths + if (-not (Test-ValidCache $stagingPaths)) { Fail 'Godot cache installation did not pass validation.' } + if (Test-Path -LiteralPath $cacheDirectory) { Remove-Item -LiteralPath $cacheDirectory -Recurse -Force } + Move-Item -LiteralPath $stagingDirectory -Destination $cacheDirectory + } finally { + if (Test-Path -LiteralPath $stagingDirectory) { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force } + } } +$editorExecutable = Assert-ExpectedEditor $cachePaths.EditorDirectory $safePreset = ($ExportPreset -replace '[^A-Za-z0-9_.-]', '_').Trim('_') if ([string]::IsNullOrWhiteSpace($safePreset)) { $safePreset = 'export' } $buildDirectory = Join-Path $ActionRoot "builds\$safePreset-$ExportMode-$PID" $outputFile = Join-Path $buildDirectory $ExportFileName New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null -$env:USERPROFILE = $userHome -$env:APPDATA = Join-Path $userHome 'AppData\Roaming' -$env:LOCALAPPDATA = Join-Path $userHome 'AppData\Local' -& $editorExecutable --headless --path $ProjectDirectory "--export-$ExportMode" $ExportPreset $outputFile +$env:USERPROFILE = $cachePaths.UserHome +$env:APPDATA = Join-Path $cachePaths.UserHome 'AppData\Roaming' +$env:LOCALAPPDATA = Join-Path $cachePaths.UserHome 'AppData\Local' + +try { + & $editorExecutable --headless --path $ProjectDirectory "--export-$ExportMode" $ExportPreset $outputFile +} catch { + Fail "Godot could not start '$editorExecutable': $($_.Exception.Message)" +} + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not (Test-Path -LiteralPath $outputFile)) { Fail "Godot completed without creating the expected output '$ExportFileName'." }