[CmdletBinding()] param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Fail([string]$Message) { Write-Error "::error::$Message" exit 1 } function Require-Environment([string]$Name) { $value = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrWhiteSpace($value)) { Fail "Required environment variable '$Name' is not set." } return $value } function Test-PathIsWithinDirectory([string]$ParentDirectory, [string]$CandidateDirectory) { $parentPath = [IO.Path]::GetFullPath($ParentDirectory) $candidatePath = [IO.Path]::GetFullPath($CandidateDirectory) $separator = [IO.Path]::DirectorySeparatorChar.ToString() if ($candidatePath.Equals($parentPath, [StringComparison]::OrdinalIgnoreCase)) { return $true } if (-not $parentPath.EndsWith($separator)) { $parentPath += $separator } return $candidatePath.StartsWith($parentPath, [StringComparison]::OrdinalIgnoreCase) } function Get-ActualArchitecture { $architecture = $env:PROCESSOR_ARCHITEW6432 if ([string]::IsNullOrWhiteSpace($architecture)) { $architecture = $env:PROCESSOR_ARCHITECTURE } switch ($architecture.ToUpperInvariant()) { 'AMD64' { return 'x86_64' } 'X86' { return 'x86_32' } 'ARM64' { return 'arm64' } default { Fail "Unsupported Windows runner architecture: $architecture." } } } function Get-Checksum([string]$ManifestPath, [string]$Asset) { 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." } 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 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 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 $templateVersion) { Fail "The export-template archive '$TemplateAsset' reports template version '$actualVersion', 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) { 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' $Dotnet = Require-Environment 'GODOT_DOTNET' $ProjectPath = Require-Environment 'GODOT_PROJECT_PATH' $ExportPreset = Require-Environment 'GODOT_EXPORT_PRESET' $ExportMode = Require-Environment 'GODOT_EXPORT_MODE' $ExportFileName = Require-Environment 'GODOT_EXPORT_FILE_NAME' $ActionRoot = Require-Environment 'GODOT_ACTION_ROOT' $Workspace = Require-Environment 'GITHUB_WORKSPACE' $GithubOutput = Require-Environment 'GITHUB_OUTPUT' if ($GodotVersion -notmatch '^4(\.[0-9]+){1,2}-stable$') { Fail 'godot-version must be a stable Godot 4 tag such as 4.7.2-stable. Alpha, beta, RC, and dev tags are not supported.' } if ($HostOs -ne 'windows') { Fail "host-os must be 'windows' when this action runs on Windows." } if ($Dotnet -notin @('true', 'false')) { Fail "dotnet must be 'true' or 'false'." } if ($ExportMode -notin @('release', 'debug')) { Fail "export-mode must be 'release' or 'debug'." } 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'." } $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.' } $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 (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." } 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 '-', '.' } $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)) { $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.' } 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 = $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'." } "build-directory=$buildDirectory" | Add-Content -LiteralPath $GithubOutput