diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ad964b..6f6a14f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,19 @@ jobs: uses: actions/checkout@v6 with: repository: metyatech/RuntimeAssetImportSample + ref: b4c78058993e68a62fc9c16b673aec65ee668573 path: RuntimeAssetImportSample fetch-depth: 0 submodules: recursive + - name: Checkout Fab release tools + uses: actions/checkout@v6 + with: + repository: metyatech/fab-plugin-release-tools + ref: de784111d9e88a3e13205494aa56ccb12a8740f3 + path: fab-plugin-release-tools + fetch-depth: 1 + - name: Link plugin into test project shell: powershell run: | @@ -57,4 +66,70 @@ jobs: - name: Validate Fab package shell: powershell working-directory: RuntimeAssetImportPlugin - run: pwsh -NoProfile -File .\PackageForFab.ps1 -EngineVersion 5.4 + run: | + $releaseOutput = Join-Path $env:RUNNER_TEMP 'RuntimeAssetImport-Fab-UE5.4' + $runnerTempRoot = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\', '/') + $resolvedReleaseOutput = [System.IO.Path]::GetFullPath($releaseOutput).TrimEnd('\', '/') + if ($resolvedReleaseOutput.Equals($runnerTempRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not $resolvedReleaseOutput.StartsWith($runnerTempRoot + [System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use release output outside RUNNER_TEMP: $resolvedReleaseOutput" + } + if ([System.IO.Directory]::Exists($resolvedReleaseOutput)) { + foreach ($file in [System.IO.Directory]::EnumerateFiles($resolvedReleaseOutput, '*', + [System.IO.SearchOption]::AllDirectories)) { + [System.IO.File]::SetAttributes($file, [System.IO.FileAttributes]::Normal) + } + [System.IO.Directory]::Delete($resolvedReleaseOutput, $true) + } + [void][System.IO.Directory]::CreateDirectory($resolvedReleaseOutput) + + pwsh -NoProfile -File .\PackageForFab.ps1 ` + -EngineVersion 5.4 ` + -ReleaseToolsRoot (Join-Path $env:GITHUB_WORKSPACE 'fab-plugin-release-tools') ` + -OutputDirectory $resolvedReleaseOutput + if ($LASTEXITCODE -ne 0) { + throw "PackageForFab.ps1 failed with exit code $LASTEXITCODE" + } + + $zipFiles = @([System.IO.Directory]::GetFiles($resolvedReleaseOutput, '*.zip', + [System.IO.SearchOption]::TopDirectoryOnly)) + $checksumFiles = @([System.IO.Directory]::GetFiles($resolvedReleaseOutput, '*.zip.sha256', + [System.IO.SearchOption]::TopDirectoryOnly)) + $reportFiles = @([System.IO.Directory]::GetFiles($resolvedReleaseOutput, '*.zip.report.json', + [System.IO.SearchOption]::TopDirectoryOnly)) + $logFiles = @([System.IO.Directory]::GetFiles($resolvedReleaseOutput, '*.zip.log', + [System.IO.SearchOption]::TopDirectoryOnly)) + if ($zipFiles.Count -ne 1 -or $checksumFiles.Count -ne 1 -or + $reportFiles.Count -ne 1 -or $logFiles.Count -ne 1) { + throw 'Fab release output must contain exactly one ZIP and each of its three sidecars.' + } + $zipPath = $zipFiles[0] + $zipName = [System.IO.Path]::GetFileName($zipPath) + $pluginHead = (git -C (Join-Path $env:GITHUB_WORKSPACE 'RuntimeAssetImportPlugin') rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Could not read the CI plugin checkout HEAD.' } + $report = Get-Content -Raw -LiteralPath $reportFiles[0] | ConvertFrom-Json + if ($report.status -cne 'PASS' -or [string]$report.toolVersion -cne '0.3.1' -or + [string]$report.repositoryHead -cne $pluginHead -or + @($report.gates).Count -ne 12 -or + @($report.gates | Where-Object { $_.status -cne 'PASS' }).Count -ne 0) { + throw 'Fab release report failed the CI contract checks.' + } + $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + $checksumText = [System.IO.File]::ReadAllText($checksumFiles[0]).TrimEnd("`r", "`n") + $checksumMatch = [System.Text.RegularExpressions.Regex]::Match( + $checksumText, '^(?[0-9a-f]{64}) (?[^\r\n]+)$') + if (-not $checksumMatch.Success -or $checksumMatch.Groups['Hash'].Value -cne $actualHash -or + $checksumMatch.Groups['Name'].Value -cne $zipName) { + throw 'Fab release checksum sidecar does not match the generated ZIP.' + } + if ([System.IO.Path]::GetFileName([string]$report.outputZip) -cne $zipName) { + throw 'Fab release report outputZip does not match the generated ZIP.' + } + + - name: Upload Fab release artifacts + uses: actions/upload-artifact@v7 + with: + name: RuntimeAssetImport-Fab-UE5.4 + path: ${{ runner.temp }}/RuntimeAssetImport-Fab-UE5.4/* + if-no-files-found: error diff --git a/FabPluginRelease.json b/FabPluginRelease.json new file mode 100644 index 0000000..5bb5aa5 --- /dev/null +++ b/FabPluginRelease.json @@ -0,0 +1,112 @@ +{ + "schemaVersion": 1, + "pluginName": "RuntimeAssetImport", + "descriptorFile": "RuntimeAssetImport.uplugin", + "engineVersions": [ + "5.4", + "5.5", + "5.7", + "5.8" + ], + "platforms": [ + "Win64" + ], + "distributionModules": [ + "RuntimeAssetImport" + ], + "enabledPluginDependencies": [ + "ProceduralMeshComponent" + ], + "publisher": { + "name": "metyatech", + "url": "https://github.com/metyatech", + "copyrightNotice": "// Copyright (c) 2026 metyatech. All rights reserved." + }, + "sourceCopyrightOverrides": [ + { + "path": "Source/RuntimeAssetImport/Private/RuntimeAssetImport.cpp", + "notices": [ + "// Copyright Epic Games, Inc. All Rights Reserved.", + "// Copyright (c) 2026 metyatech. All rights reserved." + ] + }, + { + "path": "Source/RuntimeAssetImport/Public/RuntimeAssetImport.h", + "notices": [ + "// Copyright Epic Games, Inc. All Rights Reserved.", + "// Copyright (c) 2026 metyatech. All rights reserved." + ] + } + ], + "listingId": "19cb2daa-b018-46ae-b28c-7bfe21075c4a", + "documentationUrl": "https://metyatech.github.io/unreal-plugin-docs/runtime-asset-import/", + "supportUrl": "https://github.com/metyatech/RuntimeAssetImportPlugin/issues", + "content": { + "mode": "plugin" + }, + "includeDirectories": [ + "Config", + "Content", + "Resources", + "Source/RuntimeAssetImport", + "Source/ThirdParty/assimp" + ], + "includeFiles": [ + "RuntimeAssetImport.uplugin", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], + "requiredPackageFiles": [ + "RuntimeAssetImport.uplugin", + "Config/FilterPlugin.ini", + "Content/AssetImporterMeshMaterial.uasset", + "Resources/Icon128.png", + "Source/RuntimeAssetImport/RuntimeAssetImport.Build.cs", + "Source/ThirdParty/assimp/assimp.Build.cs", + "Source/ThirdParty/assimp/BUILD-INFO.json", + "Source/ThirdParty/assimp/LICENSE", + "Source/ThirdParty/assimp/Bin/Win64/assimp-vc143-mt.dll", + "Source/ThirdParty/assimp/Lib/Win64/assimp-vc143-mt.lib", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], + "customDistributionPaths": [ + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], + "thirdPartyLicenseSets": [ + { + "root": "Source/ThirdParty/assimp/LICENSES", + "files": [ + "assimp/LICENSE", + "contrib/Open3DGC/LICENSE.txt", + "contrib/clipper/License.txt", + "contrib/earcut-hpp/LICENSE", + "contrib/openddlparser/LICENSE", + "contrib/poly2tri/LICENSE", + "contrib/pugixml/LICENSE.md", + "contrib/rapidjson/license.txt", + "contrib/stb/LICENSE.txt", + "contrib/unzip/MiniZip64_info.txt", + "contrib/utf8cpp/doc/LICENSE", + "contrib/zlib/LICENSE" + ] + } + ], + "forbiddenPackagePatterns": [ + "^Source/RuntimeAssetImportTest(?:/|$)", + "^(BuildAssimpForPlugin\\.ps1|PackageForFab\\.ps1|RunPackagedSmoke\\.ps1|MarketplaceListing\\.md|FabSubmissionChecklist\\.md|LICENSE-MIT-LEGACY)$", + "^Tests(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/contrib/draco(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/contrib/googletest(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/packaging(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/port(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/test(?:/|$)", + "^Source/ThirdParty/assimp/LICENSES/contrib/zlib/contrib/dotzlib(?:/|$)", + "(^|/)assimp\\.exe$" + ], + "buildLogFailPatterns": [] +} diff --git a/FabSubmissionChecklist.md b/FabSubmissionChecklist.md index 90960d8..8a5f6bc 100644 --- a/FabSubmissionChecklist.md +++ b/FabSubmissionChecklist.md @@ -24,16 +24,20 @@ - [x] Process-CWD-independent relative path resolution - [x] Auxiliary hard-link rejection - [x] Quaternion validity validation +- [x] FabPluginRelease.json validated by fab-plugin-release-tools 0.3.1 +- [x] Repository-specific ZIP implementation replaced by pinned central release pipeline +- [x] Deterministic ZIP, SHA-256, JSON report, and release log generated +- [x] Direct ZIP inspection, safe extraction, and manifest comparison pass ## Unreal Engine Package Matrix - [x] UE 5.4 Automation Tests -- [x] UE 5.4 `PackageForFab.ps1` +- [x] UE 5.4 central Fab release pipeline, Automation Tests, and `BuildPlugin` - [x] UE 5.4 `BuildPlugin` Win64 Rocket build -- [x] UE 5.5 Automation Tests, Fab package validation, and `BuildPlugin` +- [x] UE 5.5 Automation Tests, central Fab package validation, and `BuildPlugin` - [ ] UE 5.6 Automation Tests, Fab package validation, and `BuildPlugin` — Stock UE 5.6.1 verification failed; no engine files were modified. -- [x] UE 5.7 Automation Tests, Fab package validation, and `BuildPlugin` -- [x] UE 5.8 Automation Tests, Fab package validation, and `BuildPlugin` +- [x] UE 5.7 Automation Tests, central Fab package validation, and `BuildPlugin` +- [x] UE 5.8 Automation Tests, central Fab package validation, and `BuildPlugin` ## Packaged Shipping Smoke diff --git a/PackageForFab.ps1 b/PackageForFab.ps1 index 7441fb1..5893b97 100644 --- a/PackageForFab.ps1 +++ b/PackageForFab.ps1 @@ -1,13 +1,23 @@ +[CmdletBinding()] param( [ValidatePattern('^5\.[0-9]+$')] - [string]$EngineVersion = '5.4', + [string]$EngineVersion = '5.8', - [string]$OutputDirectory = $PSScriptRoot + [string]$ReleaseToolsRoot, + + [string]$EngineRoot, + + [string]$OutputDirectory, + + [switch]$KeepWorkingDirectory ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$ExpectedToolCommit = 'de784111d9e88a3e13205494aa56ccb12a8740f3' +$ExpectedToolVersion = '0.3.1' + function ConvertTo-NativeArgument { param([AllowEmptyString()][string]$Argument) @@ -15,7 +25,7 @@ function ConvertTo-NativeArgument { return $Argument } - $builder = New-Object System.Text.StringBuilder + $builder = [System.Text.StringBuilder]::new() [void]$builder.Append('"') $backslashCount = 0 foreach ($character in $Argument.ToCharArray()) { @@ -42,268 +52,214 @@ function ConvertTo-NativeArgument { return $builder.ToString() } -function Remove-DirectorySafely { - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [string]$AllowedRoot - ) - - $resolvedPath = [System.IO.Path]::GetFullPath($Path) - $resolvedRoot = [System.IO.Path]::GetFullPath($AllowedRoot).TrimEnd( - [System.IO.Path]::DirectorySeparatorChar, - [System.IO.Path]::AltDirectorySeparatorChar) - $requiredPrefix = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar - if (-not $resolvedPath.StartsWith($requiredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to remove path outside output directory: $resolvedPath" - } - if (-not [System.IO.Directory]::Exists($resolvedPath)) { - return - } - - foreach ($file in [System.IO.Directory]::EnumerateFiles( - $resolvedPath, '*', [System.IO.SearchOption]::AllDirectories)) { - [System.IO.File]::SetAttributes($file, [System.IO.FileAttributes]::Normal) - } - [System.IO.Directory]::Delete($resolvedPath, $true) - if ([System.IO.Directory]::Exists($resolvedPath)) { - throw "Failed to remove directory: $resolvedPath" - } -} - -function Require-File { - param([Parameter(Mandatory = $true)][string]$Path) - if (-not [System.IO.File]::Exists($Path)) { - throw "Required file is missing: $Path" - } -} - -function Require-Directory { - param([Parameter(Mandatory = $true)][string]$Path) - if (-not [System.IO.Directory]::Exists($Path)) { - throw "Required directory is missing: $Path" - } -} - -function Copy-DirectoryWithRobocopy { +function Invoke-CapturedProcess { param( - [Parameter(Mandatory = $true)][string]$Source, - [Parameter(Mandatory = $true)][string]$Destination + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$WorkingDirectory, + [switch]$DisplayOutput ) - Require-Directory -Path $Source - [System.IO.Directory]::CreateDirectory($Destination) | Out-Null - $startInfo = [System.Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = 'robocopy.exe' + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true - $arguments = @($Source, $Destination, '/E', '/COPY:DAT', '/DCOPY:DAT', '/R:2', '/W:1', - '/NFL', '/NDL', '/NJH', '/NJS', '/NP') + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true if ($null -ne $startInfo.PSObject.Properties['ArgumentList']) { - foreach ($argument in $arguments) { + foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) } } else { - $quotedArguments = @($arguments | ForEach-Object { ConvertTo-NativeArgument -Argument $_ }) + $quotedArguments = @($Arguments | ForEach-Object { + ConvertTo-NativeArgument -Argument $_ + }) $startInfo.Arguments = [string]::Join(' ', $quotedArguments) } - $process = [System.Diagnostics.Process]::Start($startInfo) + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Failed to start process: $FilePath" + } + $standardOutputTask = $process.StandardOutput.ReadToEndAsync() + $standardErrorTask = $process.StandardError.ReadToEndAsync() $process.WaitForExit() + $standardOutput = $standardOutputTask.GetAwaiter().GetResult() + $standardError = $standardErrorTask.GetAwaiter().GetResult() $exitCode = $process.ExitCode $process.Dispose() - if ($exitCode -lt 0 -or $exitCode -gt 7) { - throw "robocopy failed with exit code ${exitCode}: $Source" + + if ($DisplayOutput) { + if (-not [string]::IsNullOrEmpty($standardOutput)) { + [Console]::Out.Write($standardOutput) + } + if (-not [string]::IsNullOrEmpty($standardError)) { + [Console]::Error.Write($standardError) + } + } + + return [pscustomobject][ordered]@{ + ExitCode = $exitCode + StandardOutput = $standardOutput + StandardError = $standardError + } +} + +function Get-GitCommandPath { + $commands = @(Get-Command 'git.exe' -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -eq 0) { + throw 'git.exe was not found on PATH; install Git or add it to PATH.' } + return [string]$commands[0].Source } -function Copy-RequiredFile { +function Invoke-Git { param( - [Parameter(Mandatory = $true)][string]$Source, - [Parameter(Mandatory = $true)][string]$Destination + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string[]]$Arguments ) - Require-File -Path $Source - $destinationParent = [System.IO.Path]::GetDirectoryName($Destination) - [System.IO.Directory]::CreateDirectory($destinationParent) | Out-Null - [System.IO.File]::Copy($Source, $Destination, $true) + $result = Invoke-CapturedProcess -FilePath $GitPath -Arguments (@('-C', $RepositoryRoot) + $Arguments) ` + -WorkingDirectory $RepositoryRoot + if ($result.ExitCode -ne 0) { + throw "Git command failed with exit code $($result.ExitCode): git -C $RepositoryRoot $($Arguments -join ' ')`n$($result.StandardError.Trim())" + } + return $result.StandardOutput.Trim() } -$repositoryRoot = [System.IO.Path]::GetFullPath($PSScriptRoot) -$resolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) -[System.IO.Directory]::CreateDirectory($resolvedOutputDirectory) | Out-Null - -$versionName = '1.0.0' -$engineFolderName = "UE$EngineVersion" -$stagingRoot = [System.IO.Path]::Combine($resolvedOutputDirectory, 'PackedForFab', $engineFolderName) -$stagedPluginRoot = [System.IO.Path]::Combine($stagingRoot, 'RuntimeAssetImport') -$zipPath = [System.IO.Path]::Combine( - $resolvedOutputDirectory, "RuntimeAssetImport_${versionName}_${engineFolderName}_Win64.zip") +function Test-IsSameOrDescendantPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Candidate + ) -Remove-DirectorySafely -Path $stagingRoot -AllowedRoot $resolvedOutputDirectory -if ([System.IO.File]::Exists($zipPath)) { - [System.IO.File]::SetAttributes($zipPath, [System.IO.FileAttributes]::Normal) - [System.IO.File]::Delete($zipPath) + $resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $resolvedCandidate = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + return $resolvedCandidate.Equals($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedCandidate.StartsWith($resolvedRoot + [System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase) } -[System.IO.Directory]::CreateDirectory($stagedPluginRoot) | Out-Null -$directoryMappings = [ordered]@{ - 'Config' = 'Config' - 'Content' = 'Content' - 'Resources' = 'Resources' - 'Source/RuntimeAssetImport' = 'Source/RuntimeAssetImport' - 'Source/ThirdParty/assimp' = 'Source/ThirdParty/assimp' -} -foreach ($mapping in $directoryMappings.GetEnumerator()) { - $sourceDirectory = [System.IO.Path]::Combine($repositoryRoot, $mapping.Key) - $destinationDirectory = [System.IO.Path]::Combine($stagedPluginRoot, $mapping.Value) - Copy-DirectoryWithRobocopy -Source $sourceDirectory -Destination $destinationDirectory -} +function Resolve-ReleaseToolsRoot { + param( + [Parameter(Mandatory = $true)][string]$ProductRoot, + [Parameter(Mandatory = $true)][bool]$ExplicitlyProvided, + [string]$ExplicitRoot + ) -$fileMappings = [ordered]@{ - 'RuntimeAssetImport.uplugin' = 'RuntimeAssetImport.uplugin' - 'README.md' = 'README.md' - 'LICENSE' = 'LICENSE' - 'THIRD_PARTY_NOTICES.md' = 'THIRD_PARTY_NOTICES.md' -} -foreach ($mapping in $fileMappings.GetEnumerator()) { - $sourceFile = [System.IO.Path]::Combine($repositoryRoot, $mapping.Key) - $destinationFile = [System.IO.Path]::Combine($stagedPluginRoot, $mapping.Value) - Copy-RequiredFile -Source $sourceFile -Destination $destinationFile + if ($ExplicitlyProvided) { + if ([string]::IsNullOrWhiteSpace($ExplicitRoot)) { + throw 'ReleaseToolsRoot was explicitly provided but empty. Set -ReleaseToolsRoot to the fab-plugin-release-tools checkout.' + } + return [System.IO.Path]::GetFullPath($ExplicitRoot) + } + if (-not [string]::IsNullOrWhiteSpace($env:FAB_PLUGIN_RELEASE_TOOLS_ROOT)) { + return [System.IO.Path]::GetFullPath($env:FAB_PLUGIN_RELEASE_TOOLS_ROOT) + } + return [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $ProductRoot) 'fab-plugin-release-tools')) } -$stagedDescriptorPath = [System.IO.Path]::Combine($stagedPluginRoot, 'RuntimeAssetImport.uplugin') -$descriptor = Get-Content -LiteralPath $stagedDescriptorPath -Raw | ConvertFrom-Json -$descriptor.EngineVersion = "$EngineVersion.0" -$descriptor.Installed = $true -$descriptor.Modules = @($descriptor.Modules | Where-Object { $_.Name -eq 'RuntimeAssetImport' }) -if ($descriptor.Modules.Count -ne 1 -or $descriptor.Modules[0].Type -ne 'Runtime') { - throw 'The staged descriptor must contain exactly one RuntimeAssetImport Runtime module.' -} -$descriptorJson = $descriptor | ConvertTo-Json -Depth 20 -$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) -[System.IO.File]::WriteAllText($stagedDescriptorPath, $descriptorJson + [Environment]::NewLine, $utf8WithoutBom) +function Assert-ReleaseToolsRoot { + param( + [Parameter(Mandatory = $true)][string]$ToolRoot, + [Parameter(Mandatory = $true)][string]$ProductRoot, + [Parameter(Mandatory = $true)][string]$GitPath + ) -Add-Type -AssemblyName System.IO.Compression.FileSystem -[System.IO.Compression.ZipFile]::CreateFromDirectory( - $stagingRoot, - $zipPath, - [System.IO.Compression.CompressionLevel]::Optimal, - $false) + if (-not [System.IO.Directory]::Exists($ToolRoot)) { + throw "fab-plugin-release-tools checkout was not found at '$ToolRoot'. Set -ReleaseToolsRoot, FAB_PLUGIN_RELEASE_TOOLS_ROOT, or place the checkout beside the product repository." + } + if (Test-IsSameOrDescendantPath -Root $ProductRoot -Candidate $ToolRoot) { + throw "Release tools root must not be the product root or a directory below it: $ToolRoot" + } -$requiredEntries = @( - 'RuntimeAssetImport/RuntimeAssetImport.uplugin', - 'RuntimeAssetImport/Config/FilterPlugin.ini', - 'RuntimeAssetImport/Content/AssetImporterMeshMaterial.uasset', - 'RuntimeAssetImport/Resources/Icon128.png', - 'RuntimeAssetImport/Source/RuntimeAssetImport/RuntimeAssetImport.Build.cs', - 'RuntimeAssetImport/Source/RuntimeAssetImport/Private/RestrictedAssimpIOSystem.h', - 'RuntimeAssetImport/Source/RuntimeAssetImport/Private/RestrictedAssimpIOSystem.cpp', - 'RuntimeAssetImport/Source/RuntimeAssetImport/Private/AssetImportLimits.h', - 'RuntimeAssetImport/Source/RuntimeAssetImport/Private/CompressedTextureValidation.h', - 'RuntimeAssetImport/Source/RuntimeAssetImport/Private/CompressedTextureValidation.cpp', - 'RuntimeAssetImport/Source/ThirdParty/assimp/assimp.Build.cs', - 'RuntimeAssetImport/Source/ThirdParty/assimp/BUILD-INFO.json', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/Open3DGC/LICENSE.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/stb/LICENSE.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/unzip/MiniZip64_info.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/Bin/Win64/assimp-vc143-mt.dll', - 'RuntimeAssetImport/Source/ThirdParty/assimp/Lib/Win64/assimp-vc143-mt.lib', - 'RuntimeAssetImport/README.md', - 'RuntimeAssetImport/LICENSE', - 'RuntimeAssetImport/THIRD_PARTY_NOTICES.md' -) + $repositoryRoot = Invoke-Git -GitPath $GitPath -RepositoryRoot $ToolRoot -Arguments @('rev-parse', '--show-toplevel') + $resolvedToolRoot = [System.IO.Path]::GetFullPath($ToolRoot).TrimEnd('\', '/') + $normalizedRepositoryRoot = [System.IO.Path]::GetFullPath($repositoryRoot).TrimEnd('\', '/') + if (-not $normalizedRepositoryRoot.Equals($resolvedToolRoot, + [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Release tools root must be the Git repository root. Git reported '$repositoryRoot' for '$ToolRoot'." + } -$archive = [System.IO.Compression.ZipFile]::OpenRead($zipPath) -try { - $entries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\\', '/') }) - $topLevelDirectories = @($entries | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique) - if ($topLevelDirectories.Count -ne 1 -or $topLevelDirectories[0] -ne 'RuntimeAssetImport') { - throw "ZIP must contain exactly one top-level RuntimeAssetImport directory. Found: $($topLevelDirectories -join ', ')" + $head = Invoke-Git -GitPath $GitPath -RepositoryRoot $ToolRoot -Arguments @('rev-parse', 'HEAD') + if ($head -cne $ExpectedToolCommit) { + throw "Release tools HEAD mismatch. Expected $ExpectedToolCommit but found $head." + } + $status = Invoke-Git -GitPath $GitPath -RepositoryRoot $ToolRoot -Arguments @( + 'status', '--porcelain', '--untracked-files=all') + if (-not [string]::IsNullOrWhiteSpace($status)) { + throw "Release tools working tree must be clean: $ToolRoot" } - foreach ($requiredEntry in $requiredEntries) { - if ($entries -notcontains $requiredEntry) { - throw "Required ZIP entry is missing: $requiredEntry" + foreach ($requiredFile in @( + 'Invoke-FabPluginRelease.ps1', + 'FabPluginReleaseTools.psd1', + 'FabPluginRelease.schema.json')) { + if (-not [System.IO.File]::Exists((Join-Path $ToolRoot $requiredFile))) { + throw "Required central release tool file is missing: $(Join-Path $ToolRoot $requiredFile)" } } + $manifest = Import-PowerShellDataFile -LiteralPath (Join-Path $ToolRoot 'FabPluginReleaseTools.psd1') + if ([string]$manifest.ModuleVersion -cne $ExpectedToolVersion) { + throw "Release tools ModuleVersion mismatch. Expected $ExpectedToolVersion but found $($manifest.ModuleVersion)." + } +} - $expectedLicenseEntries = @( - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/assimp/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/Open3DGC/LICENSE.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/clipper/License.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/earcut-hpp/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/openddlparser/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/poly2tri/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/pugixml/LICENSE.md', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/rapidjson/license.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/stb/LICENSE.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/unzip/MiniZip64_info.txt', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/utf8cpp/doc/LICENSE', - 'RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/contrib/zlib/LICENSE' - ) | Sort-Object - $actualLicenseEntries = @($entries | Where-Object { - $_.StartsWith('RuntimeAssetImport/Source/ThirdParty/assimp/LICENSES/', - [System.StringComparison]::Ordinal) -and -not $_.EndsWith('/') - } | Sort-Object) - if ([string]::Join("`n", $actualLicenseEntries) -cne [string]::Join("`n", $expectedLicenseEntries)) { - throw "ZIP curated license allowlist mismatch. Expected: $($expectedLicenseEntries -join ', '). Actual: $($actualLicenseEntries -join ', ')." +$productRoot = [System.IO.Path]::GetFullPath($PSScriptRoot) +$gitPath = $null +$childStarted = $false +try { + $gitPath = Get-GitCommandPath + $toolRoot = Resolve-ReleaseToolsRoot -ProductRoot $productRoot ` + -ExplicitlyProvided ($PSBoundParameters.ContainsKey('ReleaseToolsRoot')) -ExplicitRoot $ReleaseToolsRoot + Assert-ReleaseToolsRoot -ToolRoot $toolRoot -ProductRoot $productRoot -GitPath $gitPath + + $configPath = Join-Path $productRoot 'FabPluginRelease.json' + if (-not [System.IO.File]::Exists($configPath)) { + throw "FabPluginRelease.json was not found at '$configPath'." } - $forbiddenPatterns = @( - '^RuntimeAssetImport/Source/RuntimeAssetImportTest/', - '^RuntimeAssetImport/BuildAssimpForPlugin\.ps1$', - '^RuntimeAssetImport/MarketplaceListing\.md$', - '^RuntimeAssetImport/FabSubmissionChecklist\.md$', - '^RuntimeAssetImport/LICENSE-MIT-LEGACY$', - '^RuntimeAssetImport/agent-rules', - '^RuntimeAssetImport/AGENTS\.md$', - '^RuntimeAssetImport/\.github/', - '^RuntimeAssetImport/Binaries/', - '^RuntimeAssetImport/Intermediate/', - '^RuntimeAssetImport/Saved/', - '^RuntimeAssetImport/Build/', - '/Source/ThirdParty/assimp/LICENSES/contrib/draco/', - '/Source/ThirdParty/assimp/LICENSES/contrib/googletest/', - '/Source/ThirdParty/assimp/LICENSES/packaging/', - '/Source/ThirdParty/assimp/LICENSES/port/', - '/Source/ThirdParty/assimp/LICENSES/test/', - '/Source/ThirdParty/assimp/LICENSES/contrib/zlib/contrib/dotzlib/', - '\.pdb$', - '(^|/)assimp\.exe$', - '(^|/)PackedForFab/', - 'RuntimeAssetImport_.*_UE.*_Win64\.zip$' - ) - foreach ($entry in $entries) { - foreach ($pattern in $forbiddenPatterns) { - if ($entry -match $pattern) { - throw "Forbidden ZIP entry is present: $entry" - } - } + $pwshCommands = @(Get-Command 'pwsh.exe' -CommandType Application -ErrorAction SilentlyContinue) + if ($pwshCommands.Count -eq 0) { + throw 'pwsh.exe was not found on PATH; install PowerShell 7.4 or later and add it to PATH.' } -} -finally { - $archive.Dispose() -} + $pwshPath = [string]$pwshCommands[0].Source -$validatedDescriptor = Get-Content -LiteralPath $stagedDescriptorPath -Raw | ConvertFrom-Json -if ($validatedDescriptor.EngineVersion -ne "$EngineVersion.0") { - throw "Staged EngineVersion is invalid: $($validatedDescriptor.EngineVersion)" -} -if ($validatedDescriptor.Installed -ne $true) { - throw 'Staged Installed must be true.' + $arguments = [System.Collections.Generic.List[string]]::new() + $arguments.Add('-NoProfile') + $arguments.Add('-File') + $arguments.Add((Join-Path $toolRoot 'Invoke-FabPluginRelease.ps1')) + $arguments.Add('-PluginPath') + $arguments.Add($productRoot) + $arguments.Add('-EngineVersion') + $arguments.Add($EngineVersion) + $arguments.Add('-ConfigPath') + $arguments.Add($configPath) + if ($PSBoundParameters.ContainsKey('EngineRoot')) { + $arguments.Add('-EngineRoot') + $arguments.Add($EngineRoot) + } + if ($PSBoundParameters.ContainsKey('OutputDirectory')) { + $arguments.Add('-OutputDirectory') + $arguments.Add($OutputDirectory) + } + if ($KeepWorkingDirectory) { + $arguments.Add('-KeepWorkingDirectory') + } + + $childStarted = $true + $result = Invoke-CapturedProcess -FilePath $pwshPath -Arguments $arguments.ToArray() ` + -WorkingDirectory $productRoot -DisplayOutput + exit $result.ExitCode } -if ($validatedDescriptor.Modules.Count -ne 1 -or - $validatedDescriptor.Modules[0].Name -ne 'RuntimeAssetImport' -or - $validatedDescriptor.Modules[0].Type -ne 'Runtime') { - throw 'Staged descriptor validation failed: expected one RuntimeAssetImport Runtime module.' +catch { + Write-Error -ErrorRecord $_ -ErrorAction Continue + if (-not $childStarted) { + Write-Output 'FAB PLUGIN RELEASE: FAIL' + } + exit 1 } - -$zipHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() -Write-Output "Fab staging directory: $stagedPluginRoot" -Write-Output "Fab ZIP: $zipPath" -Write-Output "Fab ZIP SHA-256: $zipHash" diff --git a/README.md b/README.md index 32df2c8..d4ffc64 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,74 @@ ## Documentation https://metyatech.github.io/unreal-plugin-docs/runtime-asset-import/ + +## Release packaging + +Fab packaging uses `fab-plugin-release-tools` at commit +`de784111d9e88a3e13205494aa56ccb12a8740f3` (version `0.3.1`). The +product repository must be completely clean, and release output must be +outside the product repository. + +The local wrapper resolves the tools checkout from `-ReleaseToolsRoot`, +`FAB_PLUGIN_RELEASE_TOOLS_ROOT`, or the sibling `fab-plugin-release-tools` +directory, in that order: + +```powershell +pwsh .\PackageForFab.ps1 ` + -EngineVersion 5.8 ` + -ReleaseToolsRoot ..\fab-plugin-release-tools +``` + +To choose an explicit output directory: + +```powershell +pwsh .\PackageForFab.ps1 ` + -EngineVersion 5.8 ` + -ReleaseToolsRoot ..\fab-plugin-release-tools ` + -OutputDirectory ..\FabArtifacts\RuntimeAssetImport\UE5.8 +``` + +The wrapper delegates packaging to the pinned central tool. It does not +implement a product-specific ZIP or descriptor transformation. A successful +release produces these four files: + +```text +RuntimeAssetImport__UE_Win64.zip +RuntimeAssetImport__UE_Win64.zip.sha256 +RuntimeAssetImport__UE_Win64.zip.report.json +RuntimeAssetImport__UE_Win64.zip.log +``` + +## Packaged smoke + +The Shipping smoke validates the central release ZIP, checksum, report, log, +safe extraction, and the existing FBX, OBJ, DAE, glTF, GLB, texture, buffer, +geometry, material, collision, owner-transform, Assimp, memory-I/O, and +compressed-texture checks. The default Sample repository is the clean `main` +branch at commit `b4c78058993e68a62fc9c16b673aec65ee668573`. + +To package and run the smoke in one command: + +```powershell +pwsh .\RunPackagedSmoke.ps1 ` + -EngineVersion 5.8 ` + -ReleaseToolsRoot ..\fab-plugin-release-tools ` + -SampleRoot ..\RuntimeAssetImportSample +``` + +To reuse an existing release ZIP, do not rerun packaging: + +```powershell +pwsh .\RunPackagedSmoke.ps1 ` + -EngineVersion 5.8 ` + -SampleRoot ..\RuntimeAssetImportSample ` + -PackageZipPath ..\FabArtifacts\RuntimeAssetImport_1.0.0_UE5.8_Win64.zip +``` + +The ZIP is inspected before extraction. Entry names must use safe relative +paths, have one `RuntimeAssetImport` top-level directory, contain no +case-insensitive duplicates or file/directory collisions, and remain under +the temporary extraction root. The source Sample checkout is never modified; +the smoke changes only its temporary `main` clone. + +`AGENTS.md` is generated by `compose-agentsmd` and must not be edited by hand. diff --git a/RunPackagedSmoke.ps1 b/RunPackagedSmoke.ps1 index cff536d..098bad7 100644 --- a/RunPackagedSmoke.ps1 +++ b/RunPackagedSmoke.ps1 @@ -1,10 +1,18 @@ [CmdletBinding()] param( [ValidatePattern('^5\.[0-9]+$')] - [string]$EngineVersion = '5.4', + [string]$EngineVersion = '5.8', [string]$WorkDirectory, + [string]$ReleaseToolsRoot, + + [string]$EngineRoot, + + [string]$SampleRoot, + + [string]$PackageZipPath, + [switch]$KeepWorkDirectory ) @@ -15,6 +23,8 @@ $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) $CreatedDefaultWorkDirectory = [string]::IsNullOrWhiteSpace($WorkDirectory) $WorkRoot = $null $CompletedSuccessfully = $false +$ExpectedSampleHead = 'b4c78058993e68a62fc9c16b673aec65ee668573' +$ExpectedToolVersion = '0.3.1' function ConvertTo-NativeArgument { @@ -230,6 +240,319 @@ function Copy-Directory } } +function Test-IsSameOrDescendantPath +{ + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $ResolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $ResolvedCandidate = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + return $ResolvedCandidate.Equals($ResolvedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $ResolvedCandidate.StartsWith($ResolvedRoot + [System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase) +} + +function Invoke-GitValue +{ + param( + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + $Result = Invoke-NativeCommand -FilePath $GitPath -Arguments (@('-C', $RepositoryRoot) + $Arguments) ` + -WorkingDirectory $RepositoryRoot + return $Result.StandardOutput.Trim() +} + +function Assert-SampleRepository +{ + param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string]$GitPath + ) + + if (-not [System.IO.Directory]::Exists($RepositoryRoot)) + { + throw "RuntimeAssetImportSample repository was not found: $RepositoryRoot" + } + $ResolvedRoot = [System.IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\', '/') + $GitRoot = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $ResolvedRoot ` + -Arguments @('rev-parse', '--show-toplevel') + $NormalizedGitRoot = [System.IO.Path]::GetFullPath($GitRoot).TrimEnd('\', '/') + if (-not $NormalizedGitRoot.Equals($ResolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) + { + throw "SampleRoot must be the exact Git repository root. Git reported '$GitRoot' for '$ResolvedRoot'." + } + $Status = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $ResolvedRoot ` + -Arguments @('status', '--porcelain', '--untracked-files=all') + if (-not [string]::IsNullOrWhiteSpace($Status)) + { + throw "RuntimeAssetImportSample source repository must be clean: $ResolvedRoot" + } + $Branch = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $ResolvedRoot ` + -Arguments @('branch', '--show-current') + if ($Branch -cne 'main') + { + throw "RuntimeAssetImportSample source repository must be on branch main, found '$Branch'." + } + $Head = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $ResolvedRoot ` + -Arguments @('rev-parse', 'HEAD') + if ($Head -cne $ExpectedSampleHead) + { + throw "RuntimeAssetImportSample HEAD mismatch. Expected $ExpectedSampleHead but found $Head." + } + $OriginMain = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $ResolvedRoot ` + -Arguments @('rev-parse', 'origin/main') + if ($OriginMain -cne $ExpectedSampleHead) + { + throw "RuntimeAssetImportSample origin/main mismatch. Expected $ExpectedSampleHead but found $OriginMain." + } + return $ResolvedRoot +} + +function Resolve-ReleaseArtifactSet +{ + param( + [string]$RequestedZipPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$RequestedEngineVersion + ) + + $ZipPath = $null + if (-not [string]::IsNullOrWhiteSpace($RequestedZipPath)) + { + $ZipPath = [System.IO.Path]::GetFullPath($RequestedZipPath) + Assert-RequiredFile -Path $ZipPath + } + else + { + Assert-RequiredDirectory -Path $OutputDirectory + $ZipFiles = @([System.IO.Directory]::GetFiles($OutputDirectory, '*.zip', + [System.IO.SearchOption]::TopDirectoryOnly)) + $ZipPattern = '^RuntimeAssetImport_.+_UE' + [regex]::Escape($RequestedEngineVersion) + '_Win64\.zip$' + $MatchingZipFiles = @($ZipFiles | Where-Object { + [System.Text.RegularExpressions.Regex]::IsMatch( + [System.IO.Path]::GetFileName($_), $ZipPattern) + }) + if ($ZipFiles.Count -ne 1 -or $MatchingZipFiles.Count -ne 1) + { + throw "Expected exactly one RuntimeAssetImport UE$RequestedEngineVersion ZIP in $OutputDirectory." + } + $ZipPath = [System.IO.Path]::GetFullPath($MatchingZipFiles[0]) + } + + if (([System.IO.FileInfo]::new($ZipPath)).Length -le 0) + { + throw "Release ZIP is empty: $ZipPath" + } + $ZipName = [System.IO.Path]::GetFileName($ZipPath) + $ZipPattern = '^RuntimeAssetImport_.+_UE' + [regex]::Escape($RequestedEngineVersion) + '_Win64\.zip$' + if (-not [System.Text.RegularExpressions.Regex]::IsMatch($ZipName, $ZipPattern)) + { + throw "Release ZIP filename does not match the requested engine version: $ZipName" + } + + $ChecksumPath = "$ZipPath.sha256" + $ReportPath = "$ZipPath.report.json" + $LogPath = "$ZipPath.log" + Assert-RequiredFile -Path $ChecksumPath + Assert-RequiredFile -Path $ReportPath + Assert-RequiredFile -Path $LogPath + + $ChecksumText = [System.IO.File]::ReadAllText($ChecksumPath).TrimEnd("`r", "`n") + $ChecksumMatch = [System.Text.RegularExpressions.Regex]::Match( + $ChecksumText, '^(?[0-9a-f]{64}) (?[^\r\n]+)$') + if (-not $ChecksumMatch.Success -or $ChecksumMatch.Groups['Name'].Value -cne $ZipName) + { + throw "Checksum sidecar must contain '': $ChecksumPath" + } + $ActualHash = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($ChecksumMatch.Groups['Hash'].Value -cne $ActualHash) + { + throw "Release ZIP checksum mismatch. Sidecar: $($ChecksumMatch.Groups['Hash'].Value); actual: $ActualHash" + } + + $Report = Get-Content -Raw -LiteralPath $ReportPath | ConvertFrom-Json + if ($Report.status -cne 'PASS') { throw "Release report status is not PASS: $ReportPath" } + if ([string]$Report.toolVersion -cne $ExpectedToolVersion) { + throw "Release report toolVersion mismatch: $($Report.toolVersion)" + } + if ([string]$Report.pluginName -cne 'RuntimeAssetImport') { + throw "Release report pluginName mismatch: $($Report.pluginName)" + } + if ([string]$Report.engineVersion -cne $RequestedEngineVersion) { + throw "Release report engineVersion mismatch: $($Report.engineVersion)" + } + if ([string]$Report.repositoryHead -cne $ProductHead) { + throw "Release report repositoryHead mismatch. Expected $ProductHead but found $($Report.repositoryHead)." + } + if ([string]$Report.zipSha256 -cne $ActualHash) { + throw "Release report zipSha256 mismatch: $($Report.zipSha256)" + } + if ([System.IO.Path]::GetFileName([string]$Report.outputZip) -cne $ZipName) { + throw "Release report outputZip does not name the inspected ZIP: $($Report.outputZip)" + } + if ($Report.build.exitCode -ne 0) { throw 'Release report build.exitCode is not 0.' } + if ($Report.build.timedOut -ne $false) { throw 'Release report build.timedOut is not false.' } + $Gates = @($Report.gates) + if ($Gates.Count -ne 12) { throw "Release report gate count must be 12, found $($Gates.Count)." } + foreach ($Gate in $Gates) { + if ([string]$Gate.status -cne 'PASS') { + throw "Release report gate is not PASS: $($Gate.name)" + } + } + $LogText = [System.IO.File]::ReadAllText($LogPath) + if ($LogText.IndexOf('Release pipeline completed successfully.', [System.StringComparison]::Ordinal) -lt 0) { + throw "Release log does not contain the successful completion marker: $LogPath" + } + if ($LogText.IndexOf('GATE FAIL', [System.StringComparison]::Ordinal) -ge 0) { + throw "Release log contains GATE FAIL: $LogPath" + } + + return [pscustomobject][ordered]@{ + ZipPath = $ZipPath + ZipName = $ZipName + ChecksumPath = $ChecksumPath + ReportPath = $ReportPath + LogPath = $LogPath + Report = $Report + Sha256 = $ActualHash + } +} + +function Expand-ReleaseZipSafely +{ + param( + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$DestinationRoot + ) + + if ([System.IO.Directory]::Exists($DestinationRoot)) + { + throw "ZIP extraction destination already exists: $DestinationRoot" + } + [void][System.IO.Directory]::CreateDirectory($DestinationRoot) + $ResolvedRoot = [System.IO.Path]::GetFullPath($DestinationRoot).TrimEnd('\', '/') + $SeenEntries = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $PathTypes = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $TopLevels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $Archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) + try + { + foreach ($Entry in $Archive.Entries) + { + $EntryName = $Entry.FullName + if ([string]::IsNullOrWhiteSpace($EntryName) -or $EntryName.Contains('\')) + { + throw "ZIP entry has an empty or backslash path: '$EntryName'" + } + $IsDirectory = $EntryName.EndsWith('/') + $NormalizedEntry = $EntryName.TrimEnd('/') + if ([string]::IsNullOrWhiteSpace($NormalizedEntry) -or + $NormalizedEntry.StartsWith('/') -or $NormalizedEntry.StartsWith('\') -or + $NormalizedEntry -match '^[A-Za-z]:' -or + [System.IO.Path]::IsPathFullyQualified($NormalizedEntry)) + { + throw "ZIP entry has an absolute or drive-qualified path: $EntryName" + } + $Segments = $NormalizedEntry.Split([char]'/') + if (@($Segments | Where-Object { [string]::IsNullOrEmpty($_) -or $_ -eq '.' -or $_ -eq '..' }).Count -gt 0) + { + throw "ZIP entry has an unsafe path segment: $EntryName" + } + if ($Segments.Count -lt 2 -and -not $IsDirectory) + { + throw "ZIP file entry must be below the RuntimeAssetImport top-level directory: $EntryName" + } + [void]$TopLevels.Add($Segments[0]) + if ($Segments[0] -cne 'RuntimeAssetImport') + { + throw "ZIP entry is outside the RuntimeAssetImport top-level directory: $EntryName" + } + if (-not $SeenEntries.Add($NormalizedEntry)) + { + throw "Case-insensitive duplicate ZIP entry: $EntryName" + } + + $EntryType = if ($IsDirectory) { 'directory' } else { 'file' } + if ($PathTypes.ContainsKey($NormalizedEntry)) + { + if ($PathTypes[$NormalizedEntry] -ne $EntryType) + { + throw "ZIP file/directory collision: $EntryName" + } + throw "Case-insensitive duplicate ZIP entry: $EntryName" + } + for ($Index = 1; $Index -lt $Segments.Count; $Index++) + { + $Parent = [string]::Join('/', $Segments[0..($Index - 1)]) + if ($PathTypes.ContainsKey($Parent) -and $PathTypes[$Parent] -eq 'file') + { + throw "ZIP file/directory collision: $EntryName" + } + if (-not $PathTypes.ContainsKey($Parent)) + { + $PathTypes[$Parent] = 'directory' + } + } + if ($EntryType -eq 'file') + { + foreach ($ExistingPath in @($PathTypes.Keys)) + { + if ($ExistingPath.StartsWith($NormalizedEntry + '/', + [System.StringComparison]::OrdinalIgnoreCase)) + { + throw "ZIP file/directory collision: $EntryName" + } + } + } + $PathTypes[$NormalizedEntry] = $EntryType + } + if ($TopLevels.Count -ne 1 -or -not $TopLevels.Contains('RuntimeAssetImport')) + { + throw 'ZIP must contain exactly one top-level RuntimeAssetImport directory.' + } + + foreach ($Entry in $Archive.Entries) + { + $EntryName = $Entry.FullName + $RelativeEntryPath = $EntryName.Replace('/', [System.IO.Path]::DirectorySeparatorChar).TrimEnd('\', '/') + $Destination = [System.IO.Path]::GetFullPath((Join-Path $ResolvedRoot $RelativeEntryPath)) + if (-not (Test-IsSameOrDescendantPath -Root $ResolvedRoot -Candidate $Destination) -or + $Destination.Equals($ResolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) + { + throw "ZIP entry escapes the extraction root: $EntryName" + } + if ($EntryName.EndsWith('/')) + { + [void][System.IO.Directory]::CreateDirectory($Destination) + continue + } + [void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($Destination)) + $InputStream = $Entry.Open() + $OutputStream = [System.IO.File]::Open($Destination, [System.IO.FileMode]::CreateNew) + try + { + $InputStream.CopyTo($OutputStream) + } + finally + { + $InputStream.Dispose() + $OutputStream.Dispose() + } + } + } + finally + { + $Archive.Dispose() + } +} + function Add-SampleModuleDependencies { param([Parameter(Mandatory = $true)][string]$BuildCsPath) @@ -305,15 +628,27 @@ try $PwshPath = Get-RequiredCommandPath -Name 'pwsh.exe' $PowerShellPath = Get-RequiredCommandPath -Name 'powershell.exe' - $SourceSampleRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\RuntimeAssetImportSample')) - Assert-RequiredDirectory -Path $SourceSampleRoot - $SampleStatus = Invoke-NativeCommand -FilePath $GitPath -Arguments @('-C', $SourceSampleRoot, 'status', '--short') - if (-not [string]::IsNullOrWhiteSpace($SampleStatus.StandardOutput)) - { - throw "RuntimeAssetImportSample source repository must be clean: $SourceSampleRoot" + $SourceSampleCandidate = if ($PSBoundParameters.ContainsKey('SampleRoot')) { + $SampleRoot } - $SampleHeadResult = Invoke-NativeCommand -FilePath $GitPath -Arguments @('-C', $SourceSampleRoot, 'rev-parse', 'HEAD') - $ExpectedSampleHead = $SampleHeadResult.StandardOutput.Trim() + else { + Join-Path $PSScriptRoot '..\RuntimeAssetImportSample' + } + $SourceSampleRoot = Assert-SampleRepository ` + -RepositoryRoot ([System.IO.Path]::GetFullPath($SourceSampleCandidate)) -GitPath $GitPath + $SourceSampleConfigRoot = Join-Path $SourceSampleRoot 'Config' + Assert-RequiredDirectory -Path $SourceSampleConfigRoot + $SourceSampleConfigHashes = @( + foreach ($ConfigFile in @(Get-ChildItem -LiteralPath $SourceSampleConfigRoot -File -Recurse)) + { + [pscustomobject][ordered]@{ + RelativePath = $ConfigFile.FullName.Substring($SourceSampleConfigRoot.Length).TrimStart('\', '/') + Hash = (Get-FileHash -LiteralPath $ConfigFile.FullName -Algorithm SHA256).Hash + } + } + ) + $ProductHead = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $PSScriptRoot ` + -Arguments @('rev-parse', 'HEAD') if ($CreatedDefaultWorkDirectory) { @@ -339,21 +674,50 @@ try $TempSampleRoot = Join-Path $WorkRoot 'S' [void](Invoke-NativeCommand -FilePath $GitPath ` - -Arguments @('clone', '--recursive', '--no-local', $SourceSampleRoot, $TempSampleRoot) ` + -Arguments @('clone', '--recursive', '--no-local', '--branch', 'main', '--single-branch', + $SourceSampleRoot, $TempSampleRoot) ` -WorkingDirectory $WorkRoot) - $ClonedHeadResult = Invoke-NativeCommand -FilePath $GitPath -Arguments @('-C', $TempSampleRoot, 'rev-parse', 'HEAD') - $ClonedHead = $ClonedHeadResult.StandardOutput.Trim() - if ($ClonedHead -cne $ExpectedSampleHead) + $TempSampleBranch = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $TempSampleRoot ` + -Arguments @('branch', '--show-current') + $TempSampleHead = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $TempSampleRoot ` + -Arguments @('rev-parse', 'HEAD') + $TempSampleOriginMain = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $TempSampleRoot ` + -Arguments @('rev-parse', 'origin/main') + $TempSampleStatus = Invoke-GitValue -GitPath $GitPath -RepositoryRoot $TempSampleRoot ` + -Arguments @('status', '--porcelain', '--untracked-files=all') + if ($TempSampleBranch -cne 'main' -or $TempSampleHead -cne $ExpectedSampleHead -or + $TempSampleOriginMain -cne $ExpectedSampleHead -or + -not [string]::IsNullOrWhiteSpace($TempSampleStatus)) { - throw "Cloned Sample HEAD mismatch. Expected $ExpectedSampleHead but found $ClonedHead." + throw "Temp Sample clone must be main at $ExpectedSampleHead with origin/main at the same SHA and clean status." } $PackageOutput = Join-Path $WorkRoot 'P' - [void](Invoke-NativeCommand -FilePath $PwshPath -Arguments @( + if ([string]::IsNullOrWhiteSpace($PackageZipPath)) + { + $PackageArguments = @( '-NoProfile', '-File', (Join-Path $PSScriptRoot 'PackageForFab.ps1'), - '-EngineVersion', $EngineVersion, '-OutputDirectory', $PackageOutput)) - $StagedPluginRoot = Join-Path $PackageOutput "PackedForFab\UE$EngineVersion\RuntimeAssetImport" + '-EngineVersion', $EngineVersion, '-OutputDirectory', $PackageOutput) + if ($PSBoundParameters.ContainsKey('ReleaseToolsRoot')) + { + $PackageArguments += @('-ReleaseToolsRoot', $ReleaseToolsRoot) + } + if ($PSBoundParameters.ContainsKey('EngineRoot')) + { + $PackageArguments += @('-EngineRoot', $EngineRoot) + } + [void](Invoke-NativeCommand -FilePath $PwshPath -Arguments $PackageArguments) + } + $ArtifactSet = Resolve-ReleaseArtifactSet -RequestedZipPath $PackageZipPath ` + -OutputDirectory $PackageOutput -RequestedEngineVersion $EngineVersion + Write-Host "Release ZIP validated: $($ArtifactSet.ZipPath)" -ForegroundColor Green + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $ExtractionRoot = Join-Path $WorkRoot 'E' + Expand-ReleaseZipSafely -ZipPath $ArtifactSet.ZipPath -DestinationRoot $ExtractionRoot + $StagedPluginRoot = Join-Path $ExtractionRoot 'RuntimeAssetImport' Assert-RequiredDirectory -Path $StagedPluginRoot + Assert-RequiredFile -Path (Join-Path $StagedPluginRoot 'RuntimeAssetImport.uplugin') $TempPluginRoot = Join-Path $TempSampleRoot 'Plugins\RuntimeAssetImport' Remove-DirectorySafely -Path $TempPluginRoot -AllowedRoot $TempSampleRoot @@ -420,6 +784,7 @@ try [/Script/UnrealEd.ProjectPackagingSettings] +DirectoriesToAlwaysStageAsNonUFS=(Path="SmokeAssets") ++DirectoriesToAlwaysCook=(Path="/RuntimeAssetImport") '@ + "`n" [System.IO.File]::WriteAllText($DefaultGamePath, $DefaultGameText, $Utf8NoBom) @@ -431,13 +796,52 @@ try [/Script/EngineSettings.GameMapsSettings] GameDefaultMap=/Game/Smoke/SmokeMap GameInstanceClass=/Script/RuntimeAssetImportSample.RuntimeAssetImportSmokeGameInstance - -[/Script/UnrealEd.ProjectPackagingSettings] -+DirectoriesToAlwaysStageAsNonUFS=(Path="SmokeAssets") -+DirectoriesToAlwaysCook=(Path="/RuntimeAssetImport") '@ [System.IO.File]::WriteAllText($DefaultEnginePath, $DefaultEngineText + $SmokeConfiguration + "`n", $Utf8NoBom) + $DefaultGameText = [System.IO.File]::ReadAllText($DefaultGamePath) + $DefaultEngineText = [System.IO.File]::ReadAllText($DefaultEnginePath) + $ProjectPackagingSectionPattern = '(?m)^\[/Script/UnrealEd\.ProjectPackagingSettings\]\r?$' + $DirectoriesToAlwaysCookPattern = '(?m)^\+DirectoriesToAlwaysCook=\(Path="/RuntimeAssetImport"\)\r?$' + $SmokeAssetsStagePattern = '(?m)^\+DirectoriesToAlwaysStageAsNonUFS=\(Path="SmokeAssets"\)\r?$' + $GameMapsSectionPattern = '(?m)^\[/Script/EngineSettings\.GameMapsSettings\]\r?$' + $DefaultGameProjectPackagingCount = [System.Text.RegularExpressions.Regex]::Matches( + $DefaultGameText, $ProjectPackagingSectionPattern).Count + $DefaultGameCookCount = [System.Text.RegularExpressions.Regex]::Matches( + $DefaultGameText, $DirectoriesToAlwaysCookPattern).Count + $DefaultGameSmokeAssetsCount = [System.Text.RegularExpressions.Regex]::Matches( + $DefaultGameText, $SmokeAssetsStagePattern).Count + $DefaultEngineProjectPackagingCount = [System.Text.RegularExpressions.Regex]::Matches( + $DefaultEngineText, $ProjectPackagingSectionPattern).Count + $DefaultEngineGameMapsCount = [System.Text.RegularExpressions.Regex]::Matches( + $DefaultEngineText, $GameMapsSectionPattern).Count + if ($DefaultGameProjectPackagingCount -ne 1 -or $DefaultGameCookCount -ne 1 -or + $DefaultGameSmokeAssetsCount -ne 1 -or $DefaultEngineProjectPackagingCount -ne 0 -or + $DefaultEngineGameMapsCount -ne 1) + { + throw ("Temp Sample packaging configuration is invalid: DefaultGame ProjectPackagingSettings={0}, " + + "Cook={1}, SmokeAssets={2}; DefaultEngine ProjectPackagingSettings={3}, GameMapsSettings={4}.") -f + $DefaultGameProjectPackagingCount, $DefaultGameCookCount, $DefaultGameSmokeAssetsCount, + $DefaultEngineProjectPackagingCount, $DefaultEngineGameMapsCount + } + + $CurrentSourceSampleConfigFiles = @(Get-ChildItem -LiteralPath $SourceSampleConfigRoot -File -Recurse) + if ($CurrentSourceSampleConfigFiles.Count -ne $SourceSampleConfigHashes.Count) + { + throw 'Source Sample Config file count changed during packaged smoke preparation.' + } + foreach ($ExpectedConfig in $SourceSampleConfigHashes) + { + $CurrentConfigPath = Join-Path $SourceSampleConfigRoot $ExpectedConfig.RelativePath + Assert-RequiredFile -Path $CurrentConfigPath + $CurrentConfigHash = (Get-FileHash -LiteralPath $CurrentConfigPath -Algorithm SHA256).Hash + if ($CurrentConfigHash -cne $ExpectedConfig.Hash) + { + throw "Source Sample Config hash changed: $($ExpectedConfig.RelativePath)" + } + } + Write-Host 'Temp Sample packaging configuration and source Config hashes validated.' -ForegroundColor Green + $SmokeAssetDestination = Join-Path $TempSampleRoot 'Content\SmokeAssets' [void][System.IO.Directory]::CreateDirectory($SmokeAssetDestination) foreach ($SmokeAssetFile in @( @@ -468,10 +872,17 @@ GameInstanceClass=/Script/RuntimeAssetImportSample.RuntimeAssetImportSmokeGameIn throw "Staged red PNG SHA-256 mismatch: $ActualRedPngHash" } - $EngineResolverPath = Join-Path $TempSampleRoot 'UnrealBuildRunTestScript\Get-UEInstallPath.ps1' - $EngineResult = Invoke-NativeCommand -FilePath $PowerShellPath -Arguments @( - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $EngineResolverPath, '-Version', $EngineVersion) - $EngineRoot = $EngineResult.StandardOutput.Trim() + if ($PSBoundParameters.ContainsKey('EngineRoot')) + { + $EngineRoot = [System.IO.Path]::GetFullPath($EngineRoot) + } + else + { + $EngineResolverPath = Join-Path $TempSampleRoot 'UnrealBuildRunTestScript\Get-UEInstallPath.ps1' + $EngineResult = Invoke-NativeCommand -FilePath $PowerShellPath -Arguments @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $EngineResolverPath, '-Version', $EngineVersion) + $EngineRoot = $EngineResult.StandardOutput.Trim() + } Assert-RequiredDirectory -Path $EngineRoot $BuildBatch = Join-Path $EngineRoot 'Engine\Build\BatchFiles\Build.bat' diff --git a/Source/RuntimeAssetImport/Private/LogAssetConstructor.h b/Source/RuntimeAssetImport/Private/LogAssetConstructor.h index 924ef95..d4992fd 100644 --- a/Source/RuntimeAssetImport/Private/LogAssetConstructor.h +++ b/Source/RuntimeAssetImport/Private/LogAssetConstructor.h @@ -1,3 +1,5 @@ +// Copyright (c) 2026 metyatech. All rights reserved. + #pragma once #include "Logging/LogMacros.h" diff --git a/Source/RuntimeAssetImport/Private/LogAssetLoader.h b/Source/RuntimeAssetImport/Private/LogAssetLoader.h index 58817df..bf60154 100644 --- a/Source/RuntimeAssetImport/Private/LogAssetLoader.h +++ b/Source/RuntimeAssetImport/Private/LogAssetLoader.h @@ -1,3 +1,5 @@ +// Copyright (c) 2026 metyatech. All rights reserved. + #pragma once #include "Logging/LogMacros.h" diff --git a/Source/RuntimeAssetImport/Private/RuntimeAssetImport.cpp b/Source/RuntimeAssetImport/Private/RuntimeAssetImport.cpp index cfb80fd..f6d130b 100644 --- a/Source/RuntimeAssetImport/Private/RuntimeAssetImport.cpp +++ b/Source/RuntimeAssetImport/Private/RuntimeAssetImport.cpp @@ -1,4 +1,5 @@ // Copyright Epic Games, Inc. All Rights Reserved. +// Copyright (c) 2026 metyatech. All rights reserved. #include "RuntimeAssetImport.h" @@ -12,13 +13,9 @@ DEFINE_LOG_CATEGORY(LogRuntimeAssetImport); void FRuntimeAssetImportModule::StartupModule() { static const TCHAR *AssimpDllName = TEXT("assimp-vc143-mt.dll"); - AssimpDllHandle = FPlatformProcess::GetDllHandle(AssimpDllName); - if (AssimpDllHandle != nullptr) - { - return; - } - - const TArray CandidatePaths = {FPaths::Combine(FPaths::ProjectPluginsDir(), TEXT("RuntimeAssetImport"), + const FString BaseDirCandidate = FPaths::Combine(FPlatformProcess::BaseDir(), AssimpDllName); + const TArray CandidatePaths = {BaseDirCandidate, FString(AssimpDllName), + FPaths::Combine(FPaths::ProjectPluginsDir(), TEXT("RuntimeAssetImport"), TEXT("Source/ThirdParty/assimp/Bin/Win64"), AssimpDllName), FPaths::Combine(FPaths::EnginePluginsDir(), TEXT("Marketplace/RuntimeAssetImport"), @@ -34,8 +31,8 @@ void FRuntimeAssetImportModule::StartupModule() if (AssimpDllHandle == nullptr) { - UE_LOG(LogRuntimeAssetImport, Error, - TEXT("Failed to load '%s' from the target output directory or bundled Win64 paths."), AssimpDllName); + UE_LOG(LogRuntimeAssetImport, Error, TEXT("Failed to load '%s'. BaseDir candidate: '%s'."), AssimpDllName, + *BaseDirCandidate); } } diff --git a/Source/RuntimeAssetImport/Public/HasFeatureFix.h b/Source/RuntimeAssetImport/Public/HasFeatureFix.h index b01b18f..6bfc0ff 100644 --- a/Source/RuntimeAssetImport/Public/HasFeatureFix.h +++ b/Source/RuntimeAssetImport/Public/HasFeatureFix.h @@ -1,3 +1,5 @@ +// Copyright (c) 2026 metyatech. All rights reserved. + #pragma once // MSVC does not define __has_feature (it is a Clang extension). diff --git a/Source/RuntimeAssetImport/Public/RuntimeAssetImport.h b/Source/RuntimeAssetImport/Public/RuntimeAssetImport.h index 04da648..8160960 100644 --- a/Source/RuntimeAssetImport/Public/RuntimeAssetImport.h +++ b/Source/RuntimeAssetImport/Public/RuntimeAssetImport.h @@ -1,4 +1,5 @@ // Copyright Epic Games, Inc. All Rights Reserved. +// Copyright (c) 2026 metyatech. All rights reserved. #pragma once diff --git a/Source/RuntimeAssetImportTest/Private/RuntimeAssetImportTest.cpp b/Source/RuntimeAssetImportTest/Private/RuntimeAssetImportTest.cpp index e822a94..a6762a0 100644 --- a/Source/RuntimeAssetImportTest/Private/RuntimeAssetImportTest.cpp +++ b/Source/RuntimeAssetImportTest/Private/RuntimeAssetImportTest.cpp @@ -1,3 +1,5 @@ +// Copyright (c) 2026 metyatech. All rights reserved. + #include "Modules/ModuleManager.h" IMPLEMENT_MODULE(FDefaultModuleImpl, RuntimeAssetImportTest) diff --git a/Source/RuntimeAssetImportTest/RuntimeAssetImportTest.Build.cs b/Source/RuntimeAssetImportTest/RuntimeAssetImportTest.Build.cs index 16fbdeb..94c80dd 100644 --- a/Source/RuntimeAssetImportTest/RuntimeAssetImportTest.Build.cs +++ b/Source/RuntimeAssetImportTest/RuntimeAssetImportTest.Build.cs @@ -1,3 +1,5 @@ +// Copyright (c) 2026 metyatech. All rights reserved. + using System.IO; using UnrealBuildTool;