<# .SYNOPSIS Campaign LA11 display-free, connection-free automated preflight. .DESCRIPTION Runs the exact Release and portability ladder used before the launcher user gate. It never starts App/Headless in connected mode, never opens a window, never reads credentials, and never bakes retail DATs. All logs and publishes are contained beneath one logs/campaign-la-gate- directory. Use -DryRun to emit the complete command matrix without executing it. #> [CmdletBinding()] param( [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, [string]$OutputDirectory, [switch]$DryRun, [switch]$IncludeInstalledDat, [string]$InstalledDatDirectory ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'Campaign LA preflight requires PowerShell 7 or newer.' } $Repository = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($Repository)) if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { throw "Repository does not contain AcDream.slnx: $Repository" } if ($IncludeInstalledDat) { if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.' } $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($InstalledDatDirectory)) foreach ($file in @( 'client_portal.dat', 'client_cell_1.dat', 'client_highres.dat', 'client_local_English.dat')) { if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) { throw "Installed DAT directory is missing $file." } } } $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" } elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { $OutputDirectory = Join-Path $Repository $OutputDirectory } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) $logsDirectory = Join-Path $OutputDirectory 'commands' $publishDirectory = Join-Path $OutputDirectory 'publish' $null = New-Item -ItemType Directory -Force -Path $logsDirectory $commandResults = [Collections.Generic.List[object]]::new() $failures = [Collections.Generic.List[string]]::new() $startedUtc = [DateTime]::UtcNow function Protect-Text([string]$Text) { if ($null -eq $Text) { return '' } $protected = $Text foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { $value = [Environment]::GetEnvironmentVariable($name) if (-not [string]::IsNullOrEmpty($value)) { $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) } } $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)(--password|-password)(\s+|=)([^\s"'']+)', '$1$2') $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', '$1$2') return $protected } function Format-Command([string]$FilePath, [string[]]$Arguments) { $parts = [Collections.Generic.List[string]]::new() $parts.Add($FilePath) foreach ($argument in $Arguments) { if ($argument -match '[\s"]') { $parts.Add('"' + $argument.Replace('"', '\"') + '"') } else { $parts.Add($argument) } } return $parts -join ' ' } function Add-PlannedCommand( [string]$Name, [string]$FilePath, [string[]]$Arguments, [Collections.IDictionary]$Environment = @{}) { $commandResults.Add([ordered]@{ name = $Name command = Format-Command $FilePath $Arguments status = 'planned' startedUtc = $null durationSeconds = 0 exitCode = $null stdout = $null stderr = $null environmentKeys = @($Environment.Keys | Sort-Object) }) } function Invoke-GateCommand( [string]$Name, [string]$FilePath, [string[]]$Arguments, [Collections.IDictionary]$Environment = @{}) { if ($DryRun) { Add-PlannedCommand $Name $FilePath $Arguments $Environment return } $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-' $stdoutRelative = "commands/$safeName.out.log" $stderrRelative = "commands/$safeName.err.log" $stdoutPath = Join-Path $OutputDirectory $stdoutRelative $stderrPath = Join-Path $OutputDirectory $stderrRelative $begin = [DateTime]::UtcNow $watch = [Diagnostics.Stopwatch]::StartNew() $exitCode = 74 try { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $FilePath $startInfo.WorkingDirectory = $Repository $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } foreach ($entry in $Environment.GetEnumerator()) { $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value } $process = [Diagnostics.Process]::new() $process.StartInfo = $startInfo if (-not $process.Start()) { throw "Could not start $FilePath." } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() $exitCode = $process.ExitCode $process.Dispose() [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout)) [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr)) } catch { [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String))) } finally { $watch.Stop() $commandResults.Add([ordered]@{ name = $Name command = Format-Command $FilePath $Arguments status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } startedUtc = $begin.ToString('O') durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) exitCode = $exitCode stdout = $stdoutRelative stderr = $stderrRelative environmentKeys = @($Environment.Keys | Sort-Object) }) } if ($exitCode -ne 0) { throw "Preflight command '$Name' failed with exit code $exitCode." } } function Add-InternalCheck([string]$Name, [scriptblock]$Action) { if ($DryRun) { $commandResults.Add([ordered]@{ name = $Name; command = ''; status = 'planned' startedUtc = $null; durationSeconds = 0; exitCode = $null stdout = $null; stderr = $null; environmentKeys = @() }) return } $begin = [DateTime]::UtcNow $watch = [Diagnostics.Stopwatch]::StartNew() $exitCode = 0 try { & $Action } catch { $exitCode = 1; throw } finally { $watch.Stop() $commandResults.Add([ordered]@{ name = $Name; command = '' status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } startedUtc = $begin.ToString('O') durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @() }) } } function Invoke-DotNet([string]$Name, [string[]]$Arguments) { Invoke-GateCommand $Name 'dotnet' $Arguments } $portableBuildProjects = @( 'src/AcDream.Platform/AcDream.Platform.csproj', 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj', 'src/AcDream.Bake/AcDream.Bake.csproj', 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj', 'src/AcDream.Core/AcDream.Core.csproj', 'src/AcDream.Core.Net/AcDream.Core.Net.csproj', 'src/AcDream.Content/AcDream.Content.csproj', 'src/AcDream.Runtime/AcDream.Runtime.csproj', 'src/AcDream.Headless/AcDream.Headless.csproj' ) $portableTestProjects = @( 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj', 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj', 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj', 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj', 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj' ) try { Invoke-DotNet 'release-build' @( 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') Invoke-DotNet 'release-tests-serial' @( 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', '--', 'RunConfiguration.MaxCpuCount=1') Invoke-DotNet 'focused-launcher-updater-core' @( 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', '-c', 'Release', '--no-build', '--nologo', '--filter', 'FullyQualifiedName~Updates') Invoke-DotNet 'focused-launcher-updater-ui' @( 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj', '-c', 'Release', '--no-build', '--nologo', '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests') foreach ($project in $portableBuildProjects) { $leaf = [IO.Path]::GetFileNameWithoutExtension($project) Invoke-DotNet "portable-build-$leaf" @( 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1') } foreach ($project in $portableTestProjects) { $leaf = [IO.Path]::GetFileNameWithoutExtension($project) Invoke-DotNet "portable-test-$leaf" @( 'test', $project, '-c', 'Release', '--no-build', '--nologo', '--', 'RunConfiguration.MaxCpuCount=1') } foreach ($rid in @('win-x64', 'linux-x64')) { $destination = Join-Path $publishDirectory $rid Invoke-DotNet "publish-launcher-$rid" @( 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj', '-c', 'Release', '-r', $rid, '--self-contained', 'true', '-p:PublishSingleFile=true', '-o', $destination, '--nologo') Add-InternalCheck "publish-contract-$rid" { $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' } foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) { if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) { throw "$rid publish is missing $name." } } if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) { throw "$rid launcher publish is not single-file." } if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) { throw "$rid bake publish is not single-file." } if (-not $IsWindows -and $rid -eq 'linux-x64') { $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher')) if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { throw 'linux-x64 launcher is not executable.' } } } } $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' } $nativeSuffix = if ($IsWindows) { '.exe' } else { '' } $nativeRoot = Join-Path $publishDirectory $nativeRid $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' } $bogusEnvironment = @{ DOTNET_ROOT = $bogusRoot DOTNET_ROOT_X64 = $bogusRoot DOTNET_MULTILEVEL_LOOKUP = '0' } Invoke-GateCommand 'native-launcher-bogus-dotnet-root' ` (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") ` @('--verify-publish') $bogusEnvironment Invoke-GateCommand 'native-bake-bogus-dotnet-root' ` (Join-Path $nativeRoot "acdream-bake$nativeSuffix") ` @('--help') $bogusEnvironment if ($IncludeInstalledDat) { $datEnvironment = @{ ACDREAM_DAT_DIR = $InstalledDatDirectory ACDREAM_PROBE_LIVE_MOUNT = '1' } $datResults = Join-Path $OutputDirectory 'installed-dat-results' Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @( 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', '-c', 'Release', '--no-build', '--nologo', '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests', '--results-directory', $datResults, '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment Add-InternalCheck 'installed-dat-character-management-require-pass' { $trx = Join-Path $datResults 'character-management.trx' if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) { throw 'CharacterManagementLiveDatTests did not produce a TRX result.' } [xml]$result = Get-Content -LiteralPath $trx -Raw $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome }) if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) { throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')." } } Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @( 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj', '-c', 'Release', '--no-build', '--nologo', '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @( 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', '-c', 'Release', '--no-build', '--nologo', '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment } } catch { $failures.Add((Protect-Text ($_ | Out-String)).Trim()) } finally { $finishedUtc = [DateTime]::UtcNow $head = (& git -C $Repository rev-parse HEAD).Trim() $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) $artifacts = @() if (-not $DryRun) { $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | Sort-Object FullName | ForEach-Object { [ordered]@{ path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') size = $_.Length sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } }) } $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' }) $report = [ordered]@{ schemaVersion = 1 kind = 'campaign-la-automated-preflight' dryRun = [bool]$DryRun success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) repository = $Repository head = $head dirty = ($dirtyLines.Count -gt 0) dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) platform = [ordered]@{ os = [Runtime.InteropServices.RuntimeInformation]::OSDescription architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription powershell = $PSVersionTable.PSVersion.ToString() } startedUtc = $startedUtc.ToString('O') finishedUtc = $finishedUtc.ToString('O') durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3) installedDatIncluded = [bool]$IncludeInstalledDat commands = @($commandResults) failures = @($failures) redaction = [ordered]@{ applied = $true environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') credentialArgumentsAllowed = $false } artifacts = $artifacts } $reportPath = Join-Path $OutputDirectory 'report.json' $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM Write-Host "Campaign LA preflight report: $reportPath" if (-not $report.success) { exit 1 } }