feat(launcher): prepare Campaign LA11 user gate
This commit is contained in:
parent
09d84387a8
commit
f881e5b467
24 changed files with 3538 additions and 58 deletions
394
tools/run-campaign-la-preflight.ps1
Normal file
394
tools/run-campaign-la-preflight.ps1
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<#
|
||||
.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-<timestamp>
|
||||
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, '<redacted>', [StringComparison]::Ordinal)
|
||||
}
|
||||
}
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
||||
'$1$2<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||
'$1<redacted>$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 = '<internal contract check>'; 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 = '<internal contract check>'
|
||||
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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue