acdream/tools/run-campaign-la-preflight.ps1

530 lines
23 KiB
PowerShell

<#
.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,
[Parameter(Mandatory = $true)][string]$AllowedOutputRoot,
[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"
}
function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
$cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
while (-not (Test-Path -LiteralPath $cursor)) {
$parent = [IO.Path]::GetDirectoryName($cursor)
if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
$cursor = $parent
}
while (-not [string]::IsNullOrEmpty($cursor)) {
$item = Get-Item -LiteralPath $cursor -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Description has a reparse point in its ancestry."
}
$parent = [IO.Directory]::GetParent($cursor)
if ($null -eq $parent) { break }
$cursor = $parent.FullName
}
}
function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
return $Path.StartsWith(
$Ancestor + [IO.Path]::DirectorySeparatorChar,
$comparison)
}
if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) {
throw '-AllowedOutputRoot must be absolute.'
}
$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($AllowedOutputRoot))
if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) {
throw '-AllowedOutputRoot must be an existing campaign gate/log directory.'
}
Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root'
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath([Environment]::GetFolderPath(
[Environment+SpecialFolder]::UserProfile)))
if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or
[string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) {
throw '-AllowedOutputRoot cannot be the repository root or user home.'
}
$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath((Join-Path $Repository 'logs')))
$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot)
$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository
if ($allowedInRepository -and
-not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) {
throw '-AllowedOutputRoot inside the repository must be below its logs directory.'
}
if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and
-not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) {
throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.'
}
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 $AllowedOutputRoot "campaign-la-preflight-$stamp"
}
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
throw '-OutputDirectory must be absolute when supplied.'
}
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($OutputDirectory))
if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or
[string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) {
throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.'
}
if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or
[string]::Equals($OutputDirectory, $homeDirectory, $comparison)) {
throw '-OutputDirectory cannot be the repository root or user home.'
}
if (Test-Path -LiteralPath $OutputDirectory) {
throw '-OutputDirectory must be fresh and must not already exist.'
}
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
$logsDirectory = Join-Path $OutputDirectory 'commands'
$publishDirectory = Join-Path $OutputDirectory 'publish'
$null = New-Item -ItemType Directory -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
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(https?://)[^/\s:@]+:[^@\s/]+@',
'$1<redacted>@')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+',
'$1<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 ($key in @($startInfo.Environment.Keys)) {
if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) {
$startInfo.Environment.Remove($key)
}
}
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-GateCommand 'campaign-la-gate-helper-contracts' `
([Environment]::ProcessPath ??
$(throw 'The PowerShell process path is unavailable.')) `
@(
'-NoProfile',
'-File', 'tools/test-campaign-la-gate-helpers.ps1',
'-Repository', $Repository,
'-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts'))
Invoke-GateCommand 'campaign-la-script-safety-contracts' `
([Environment]::ProcessPath ??
$(throw 'The PowerShell process path is unavailable.')) `
@(
'-NoProfile',
'-File', 'tools/test-campaign-la-script-safety.ps1',
'-Repository', $Repository,
'-OutputDirectory', (Join-Path $OutputDirectory 'script-safety'))
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')
}
$headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json'
Add-InternalCheck 'portable-headless-write-empty-config' {
[IO.File]::WriteAllText(
$headlessValidationConfig,
'{"version":1,"sessions":[]}',
[Text.UTF8Encoding]::new($false))
}
Invoke-DotNet 'portable-headless-help-no-connect' @(
'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
'-c', 'Release', '--no-build', '--', '--help')
Invoke-DotNet 'portable-headless-validate-empty-no-connect' @(
'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
'-c', 'Release', '--no-build', '--',
'validate', '--config', $headlessValidationConfig)
Add-InternalCheck 'portable-headless-native-permission' {
if (-not $IsWindows) {
$headlessExecutable = Join-Path `
$Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless'
if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) {
throw 'The native Headless build output is missing.'
}
$mode = [IO.File]::GetUnixFileMode($headlessExecutable)
if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
throw 'The native Headless build output is not executable.'
}
}
}
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) {
[string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
ForEach-Object {
[IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
})
[Array]::Sort($artifactPaths, [StringComparer]::Ordinal)
$artifacts = @($artifactPaths | ForEach-Object {
$fullPath = Join-Path $OutputDirectory $_.Replace(
'/', [IO.Path]::DirectorySeparatorChar)
$item = Get-Item -LiteralPath $fullPath
[ordered]@{
path = $_
size = $item.Length
sha256 = (Get-FileHash -LiteralPath $fullPath -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
allowedOutputRoot = $AllowedOutputRoot
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
inheritedAcdreamEnvironmentCleared = $true
inheritedEnvironmentValuesRead = $false
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 }
}