From ce35f98fb755d23aef079589c5cd0558f07f84c8 Mon Sep 17 00:00:00 2001 From: Alex Loren Date: Sun, 30 Aug 2026 07:22:18 -0700 Subject: [PATCH] PowerShell rewrite --- export-windows.ps1 | 306 +++++++++++++++++++++++++-------------------- 1 file changed, 172 insertions(+), 134 deletions(-) diff --git a/export-windows.ps1 b/export-windows.ps1 index 85b73d0..3c1f475 100644 --- a/export-windows.ps1 +++ b/export-windows.ps1 @@ -1,11 +1,16 @@ [CmdletBinding()] -param() +param( + [switch]$SkipExecution +) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Fail([string]$Message) { - Write-Error "::error::$Message" + Write-Host "::error::$Message" + if ($SkipExecution) { + throw $Message + } exit 1 } @@ -47,160 +52,227 @@ function Get-ActualArchitecture { } } -function Get-Checksum([string]$ManifestPath, [string]$Asset) { +function Get-GodotAssets([string]$Version, [string]$Dotnet, [string]$Architecture) { + if ($Dotnet -eq 'true') { + $edition = 'dotnet' + $editorBaseName = switch ($Architecture) { + 'x86_32' { "Godot_v${Version}_mono_win32" } + 'x86_64' { "Godot_v${Version}_mono_win64" } + 'arm64' { "Godot_v${Version}_mono_windows_arm64" } + default { Fail "Unsupported Windows host-arch '$Architecture'." } + } + + return [PSCustomObject]@{ + Edition = $edition + EditorAsset = "$editorBaseName.zip" + EditorFileName = "${editorBaseName}_console.exe" + TemplatesAsset = "Godot_v${Version}_mono_export_templates.tpz" + TemplateVersion = ($Version -replace '-', '.') + '.mono' + } + } + + $edition = 'standard' + $editorBaseName = switch ($Architecture) { + 'x86_32' { "Godot_v${Version}_win32" } + 'x86_64' { "Godot_v${Version}_win64" } + 'arm64' { "Godot_v${Version}_windows_arm64" } + default { Fail "Unsupported Windows host-arch '$Architecture'." } + } + + return [PSCustomObject]@{ + Edition = $edition + EditorAsset = "${editorBaseName}.exe.zip" + EditorFileName = "${editorBaseName}_console.exe" + TemplatesAsset = "Godot_v${Version}_export_templates.tpz" + TemplateVersion = $Version -replace '-', '.' + } +} + +function Get-ExpectedPeMachine([string]$Architecture) { + switch ($Architecture) { + 'x86_32' { return [uint16]0x014c } + 'x86_64' { return [uint16]0x8664 } + 'arm64' { return [uint16]0xaa64 } + default { Fail "Unsupported Windows host-arch '$Architecture'." } + } +} + +function Get-Checksum([string]$ManifestPath, [string]$Asset, [string]$Version, [string]$Edition, [string]$Architecture, [string]$BaseUrl) { 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() } } - Fail "Godot $GodotVersion does not publish required $edition asset '$Asset' for Windows/$HostArch at $script:BaseUrl. Choose a supported stable release and architecture." + Fail "Godot $Version does not publish required $Edition asset '$Asset' for Windows/$Architecture at $BaseUrl. Choose a supported stable release and architecture." } -function Download-AndVerify([string]$Asset, [string]$Destination, [string]$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 Invoke-Download([string]$Uri, [string]$Destination) { + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Destination + $lastError = $null + break + } catch { + $lastError = $_ + if (Test-Path -LiteralPath $Destination) { + Remove-Item -LiteralPath $Destination -Force + } + if ($attempt -lt 3) { + Start-Sleep -Seconds $attempt + } + } + } + + if ($null -ne $lastError) { + Fail "Failed to download '$Uri' after 3 attempts: $($lastError.Exception.Message)" } } -function Get-CachePaths([string]$Root) { +function Invoke-VerifiedDownload([string]$Uri, [string]$Destination, [string]$ExpectedChecksum) { + Invoke-Download $Uri $Destination + $actualChecksum = (Get-FileHash -LiteralPath $Destination -Algorithm SHA512).Hash.ToLowerInvariant() + if ($actualChecksum -ne $ExpectedChecksum) { + Fail "SHA-512 verification failed for $(Split-Path -Leaf $Destination)." + } +} + +function Get-CachePaths([string]$Root, [string]$TemplateVersion) { $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 + TemplateDirectory = Join-Path $userHome "AppData\Roaming\Godot\export_templates\$TemplateVersion" Marker = Join-Path $Root '.complete' } } -function Get-EditorExecutable([string]$EditorDirectory) { +function Get-EditorExecutable([string]$EditorDirectory, [string]$ExpectedFileName) { $matches = @(Get-ChildItem -LiteralPath $EditorDirectory -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -eq $expectedEditorFileName }) - + Where-Object { $_.Name -eq $ExpectedFileName }) if ($matches.Count -eq 1) { return $matches[0].FullName } - return $null } function Get-PeMachine([string]$Path) { try { - $bytes = [IO.File]::ReadAllBytes($Path) + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + if ($stream.Length -lt 64) { return $null } + $header = New-Object byte[] 64 + if ($stream.Read($header, 0, $header.Length) -ne $header.Length -or $header[0] -ne 0x4d -or $header[1] -ne 0x5a) { + return $null + } + + $peOffset = [BitConverter]::ToInt32($header, 0x3c) + if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) { return $null } + $stream.Position = $peOffset + $signature = New-Object byte[] 6 + if ($stream.Read($signature, 0, $signature.Length) -ne $signature.Length) { return $null } + if ($signature[0] -ne 0x50 -or $signature[1] -ne 0x45 -or $signature[2] -ne 0 -or $signature[3] -ne 0) { return $null } + return [BitConverter]::ToUInt16($signature, 4) + } finally { + $stream.Dispose() + } } 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 Test-ExpectedEditor([string]$EditorDirectory, [string]$ExpectedFileName, [uint16]$ExpectedMachine) { + $editorExecutable = Get-EditorExecutable $EditorDirectory $ExpectedFileName + return $null -ne $editorExecutable -and (Get-PeMachine $editorExecutable) -eq $ExpectedMachine } -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'." +function Assert-ExpectedEditor([string]$EditorDirectory, [string]$ExpectedFileName, [uint16]$ExpectedMachine, [string]$Architecture) { + $editorExecutable = Get-EditorExecutable $EditorDirectory $ExpectedFileName + if ($null -eq $editorExecutable) { + Fail "The Godot editor archive did not contain the expected console executable '$ExpectedFileName'." } - - $machine = Get-PeMachine $editorExecutable - if ($machine -ne $expectedPeMachine) { - Fail "The expected Godot console executable '$expectedEditorFileName' is not a valid $HostArch Windows executable." + if ((Get-PeMachine $editorExecutable) -ne $ExpectedMachine) { + Fail "The expected Godot console executable '$ExpectedFileName' is not a valid $Architecture Windows executable." } - return $editorExecutable } -function Test-ValidCache($Paths) { - if (-not (Test-Path -LiteralPath $Paths.Marker -PathType Leaf)) { - return $false - } - +function Test-ValidCache($Paths, [string]$TemplateVersion, [string]$ExpectedFileName, [uint16]$ExpectedMachine) { + 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 + 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 $ExpectedFileName $ExpectedMachine } -function Install-ExportTemplates($Paths, [string]$StagingDirectory, [string]$TemplateAsset) { +function Install-ExportTemplates($Paths, [string]$StagingDirectory, [string]$TemplateAsset, [string]$TemplateVersion) { $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 $templateVersion) { - Fail "The export-template archive '$TemplateAsset' reports template version '$actualVersion', expected '$templateVersion'." + if ((Get-Content -LiteralPath $stagedVersion -Raw).Trim() -ne $TemplateVersion) { + Fail "The export-template archive '$TemplateAsset' reports template version '$((Get-Content -LiteralPath $stagedVersion -Raw).Trim())', expected '$TemplateVersion'." } 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) { +function Install-GodotCache($Paths, $Assets, [uint16]$ExpectedMachine, [string]$Architecture, [string]$Version, [string]$BaseUrl) { 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 + $editorArchive = Join-Path $Paths.Root $Assets.EditorAsset + $templatesArchive = Join-Path $Paths.Root $Assets.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 + Invoke-Download "$BaseUrl/SHA512-SUMS.txt" $manifestPath + $editorChecksum = Get-Checksum $manifestPath $Assets.EditorAsset $Version $Assets.Edition $Architecture $BaseUrl + $templatesChecksum = Get-Checksum $manifestPath $Assets.TemplatesAsset $Version $Assets.Edition $Architecture $BaseUrl + Invoke-VerifiedDownload "$BaseUrl/$($Assets.EditorAsset)" $editorArchive $editorChecksum + Invoke-VerifiedDownload "$BaseUrl/$($Assets.TemplatesAsset)" $templatesArchive $templatesChecksum Expand-Archive -LiteralPath $editorArchive -DestinationPath $Paths.EditorDirectory -Force - Assert-ExpectedEditor $Paths.EditorDirectory | Out-Null + Assert-ExpectedEditor $Paths.EditorDirectory $Assets.EditorFileName $ExpectedMachine $Architecture | Out-Null - # Expand-Archive only recognizes .zip files. The verified .tpz payload is a ZIP archive. - Move-Item -LiteralPath $templatesArchive -Destination $templatesZipArchive + # Expand-Archive recognizes .zip only; Godot's verified .tpz payload is a ZIP archive. + Copy-Item -LiteralPath $templatesArchive -Destination $templatesZipArchive -Force Expand-Archive -LiteralPath $templatesZipArchive -DestinationPath $templatesStagingDirectory -Force - Install-ExportTemplates $Paths $templatesStagingDirectory $templatesAsset + Install-ExportTemplates $Paths $templatesStagingDirectory $Assets.TemplatesAsset $Assets.TemplateVersion Remove-Item -LiteralPath $templatesStagingDirectory -Recurse -Force - @("release=$GodotVersion", "editor=$editorAsset", "templates=$templatesAsset") | - Set-Content -LiteralPath $Paths.Marker -NoNewline + @("release=$Version", "editor=$($Assets.EditorAsset)", "templates=$($Assets.TemplatesAsset)") | + Set-Content -LiteralPath $Paths.Marker -NoNewline -Encoding ASCII } +function Invoke-GodotExport([string]$EditorExecutable, [string]$ProjectDirectory, [string]$ExportMode, [string]$ExportPreset, [string]$OutputFile, [scriptblock]$EditorRunner) { + try { + if ($null -ne $EditorRunner) { + & $EditorRunner $EditorExecutable $ProjectDirectory $ExportMode $ExportPreset $OutputFile + } else { + & $EditorExecutable --headless --path $ProjectDirectory "--export-$ExportMode" $ExportPreset $OutputFile + } + } catch { + Fail "Godot could not start '$EditorExecutable': $($_.Exception.Message)" + } + + if ($LASTEXITCODE -ne 0) { + Fail "Godot export failed with exit code $LASTEXITCODE." + } + if (-not (Test-Path -LiteralPath $OutputFile -PathType Leaf)) { + Fail "Godot completed without creating the expected output '$(Split-Path -Leaf $OutputFile)'." + } +} + +if ($SkipExecution) { return } + $GodotVersion = Require-Environment 'GODOT_VERSION' $HostOs = Require-Environment 'GODOT_HOST_OS' $HostArch = Require-Environment 'GODOT_HOST_ARCH' @@ -228,54 +300,28 @@ $workspaceDirectory = [IO.Path]::GetFullPath($Workspace) if ([IO.Path]::IsPathRooted($ProjectPath)) { Fail 'project-path must be relative to the checked-out workspace.' } $ProjectDirectory = [IO.Path]::GetFullPath((Join-Path $workspaceDirectory $ProjectPath)) if (-not (Test-PathIsWithinDirectory $workspaceDirectory $ProjectDirectory)) { Fail 'project-path must remain inside the checked-out workspace.' } +if (-not (Test-Path -LiteralPath $ProjectDirectory -PathType Container)) { Fail "project-path '$ProjectPath' does not exist." } if (-not (Test-Path -LiteralPath (Join-Path $ProjectDirectory 'project.godot') -PathType Leaf)) { Fail "No project.godot was found in project-path '$ProjectPath'." } -$PresetFile = Join-Path $ProjectDirectory 'export_presets.cfg' -if (-not (Test-Path -LiteralPath $PresetFile -PathType Leaf)) { Fail "No export_presets.cfg was found in project-path '$ProjectPath'." } -if (-not (Select-String -LiteralPath $PresetFile -SimpleMatch "name=`"$ExportPreset`"" -Quiet)) { Fail "Export preset '$ExportPreset' was not found in export_presets.cfg." } +$presetFile = Join-Path $ProjectDirectory 'export_presets.cfg' +if (-not (Test-Path -LiteralPath $presetFile -PathType Leaf)) { Fail "No export_presets.cfg was found in project-path '$ProjectPath'." } +if (-not (Select-String -LiteralPath $presetFile -SimpleMatch "name=`"$ExportPreset`"" -Quiet)) { Fail "Export preset '$ExportPreset' was not found in export_presets.cfg." } -if ($Dotnet -eq 'true') { - $edition = 'dotnet' - $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' - $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 '-', '.' -} +$assets = Get-GodotAssets $GodotVersion $Dotnet $HostArch +$expectedMachine = Get-ExpectedPeMachine $HostArch +$baseUrl = "https://github.com/godotengine/godot/releases/download/$GodotVersion" +$cacheDirectory = Join-Path $ActionRoot "cache\$GodotVersion\$($assets.Edition)\windows-$HostArch" +$cachePaths = Get-CachePaths $cacheDirectory $assets.TemplateVersion -$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" -$cachePaths = Get-CachePaths $cacheDirectory - -if (-not (Test-ValidCache $cachePaths)) { +if (-not (Test-ValidCache $cachePaths $assets.TemplateVersion $assets.EditorFileName $expectedMachine)) { $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 try { - $stagingPaths = Get-CachePaths $stagingDirectory - Install-Cache $stagingPaths - if (-not (Test-ValidCache $stagingPaths)) { Fail 'Godot cache installation did not pass validation.' } + $stagingPaths = Get-CachePaths $stagingDirectory $assets.TemplateVersion + Install-GodotCache $stagingPaths $assets $expectedMachine $HostArch $GodotVersion $baseUrl + if (-not (Test-ValidCache $stagingPaths $assets.TemplateVersion $assets.EditorFileName $expectedMachine)) { 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 { @@ -283,7 +329,7 @@ if (-not (Test-ValidCache $cachePaths)) { } } -$editorExecutable = Assert-ExpectedEditor $cachePaths.EditorDirectory +$editorExecutable = Assert-ExpectedEditor $cachePaths.EditorDirectory $assets.EditorFileName $expectedMachine $HostArch $safePreset = ($ExportPreset -replace '[^A-Za-z0-9_.-]', '_').Trim('_') if ([string]::IsNullOrWhiteSpace($safePreset)) { $safePreset = 'export' } $buildDirectory = Join-Path $ActionRoot "builds\$safePreset-$ExportMode-$PID" @@ -293,14 +339,6 @@ New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null $env:USERPROFILE = $cachePaths.UserHome $env:APPDATA = Join-Path $cachePaths.UserHome 'AppData\Roaming' $env:LOCALAPPDATA = Join-Path $cachePaths.UserHome 'AppData\Local' +Invoke-GodotExport $editorExecutable $ProjectDirectory $ExportMode $ExportPreset $outputFile $null -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'." } - -"build-directory=$buildDirectory" | Add-Content -LiteralPath $GithubOutput +"build-directory=$buildDirectory" | Add-Content -LiteralPath $GithubOutput -Encoding ASCII