Ctrl K
ComunidadGuías…

release-local.ps1

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$Version,
    [string]$OutputDir,
    [switch]$SkipTests,
    [switch]$SkipDocker,
    [switch]$SkipWindows,
    [switch]$SkipMsix,
    [switch]$DryRun,
    [switch]$KeepTemp,
    [switch]$Overwrite
)

$ErrorActionPreference = 'Stop'
$Root = [System.IO.Path]::GetFullPath((Split-Path -Parent $MyInvocation.MyCommand.Path))
$SetVersion = Join-Path $Root 'scripts\set-version.ps1'
$CheckVersion = Join-Path $Root 'scripts\check-version.ps1'
$WindowsBuild = Join-Path $Root 'windows\build.ps1'
$Timestamp = (Get-Date).ToUniversalTime().ToString('o')
$StageResults = New-Object System.Collections.Generic.List[object]
$Events = New-Object System.Collections.Generic.List[string]
$Artifacts = New-Object System.Collections.Generic.List[object]
$OutputPath = $null
$Failure = $null
$DockerContainer = $null
$DockerContainerStarted = $false
$DockerTemp = $null
$ToolVersions = [ordered]@{}
$StorePackageIdentityName = 'jms1717.8mb.local'
$StorePublisher = 'CN=AAE66F20-996E-4A3C-B08E-182952BAD9F7'
$StorePublisherDisplayName = 'jms1717'
$ReleaseMarkerName = '.8mblocal-release-output'
$ReleaseMarkerValue = '8mb.local local release output'

function Write-Event {
    param([string]$Message)
    $line = (Get-Date).ToString('s') + ' ' + $Message
    $Events.Add($line) | Out-Null
    Write-Host $line
}

function Get-CommandPath {
    param([string]$Name)
    if ($Name -in @('npm', 'npx')) {
        $cmdShim = Get-Command ($Name + '.cmd') -ErrorAction SilentlyContinue
        if ($null -ne $cmdShim) {
            return $cmdShim.Source
        }
    }
    $command = Get-Command $Name -ErrorAction SilentlyContinue
    if ($null -eq $command) {
        return $null
    }
    return $command.Source
}

function Get-NormalizedFourPartVersion {
    param([string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
    $match = [regex]::Match($Value, '\d+(?:\.\d+){1,3}')
    if (-not $match.Success) { return $null }
    try {
        $parsed = [version]$match.Value
        return '{0}.{1}.{2}.{3}' -f $parsed.Major, $parsed.Minor,
            $(if ($parsed.Build -lt 0) { 0 } else { $parsed.Build }),
            $(if ($parsed.Revision -lt 0) { 0 } else { $parsed.Revision })
    } catch {
        return $null
    }
}

function Require-Command {
    param([string]$Name, [string]$Purpose)
    $path = Get-CommandPath $Name
    if ($null -eq $path) {
        throw "Required tool '$Name' is missing ($Purpose). Install it separately or use a stage skip option."
    }
    return $path
}

function Get-ToolVersion {
    param([string]$Path, [string[]]$Arguments = @('--version'))
    $oldErrorActionPreference = $ErrorActionPreference
    try {
        $ErrorActionPreference = 'Continue'
        $line = (& $Path @Arguments 2>&1 |
            Select-Object -First 1 |
            ForEach-Object {
                if ($_ -is [System.Management.Automation.ErrorRecord] -and $_.Exception -and $_.Exception.Message) {
                    $_.Exception.Message
                } else {
                    $_.ToString()
                }
            } |
            Out-String).Trim()
        if ($line) { return $line }
    } catch {}
    finally {
        $ErrorActionPreference = $oldErrorActionPreference
    }
    return 'unavailable'
}

function Invoke-Stage {
    param(
        [string]$Name,
        [string]$Executable,
        [string[]]$Arguments = @(),
        [string]$WorkingDirectory = $Root,
        [switch]$AllowFailure
    )
    $safeName = ($Name -replace '[^A-Za-z0-9_.-]', '_')
    $logPath = $null
    if ($null -ne $OutputPath) {
        $logPath = Join-Path $OutputPath ($safeName + '.log')
    }
    Write-Event ("COMMAND " + $Name + ": " + $Executable + " " + ($Arguments -join ' '))
    $started = Get-Date
    $lines = New-Object System.Collections.Generic.List[string]
    Push-Location $WorkingDirectory
    $oldErrorActionPreference = $ErrorActionPreference
    try {
        # Windows PowerShell promotes native stderr (including compiler
        # warnings) into ErrorRecord objects. Capture that stream as log text
        # and use the process exit code as the stage result instead of
        # aborting on a warning.
        $ErrorActionPreference = 'Continue'
        & $Executable @Arguments 2>&1 | ForEach-Object {
            $text = $_.ToString()
            $lines.Add($text) | Out-Null
            Write-Host $text
        }
        $exitCode = $LASTEXITCODE
    } finally {
        $ErrorActionPreference = $oldErrorActionPreference
        Pop-Location
    }
    if ($null -ne $logPath) {
        # Always create a log file, including for successful commands that
        # produce no stdout/stderr. The manifest must never point at a missing
        # stage log.
        $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
        [System.IO.File]::WriteAllLines($logPath, [string[]]$lines.ToArray(), $utf8NoBom)
    }
    $passed = ($exitCode -eq 0)
    $StageResults.Add([pscustomobject]@{
        name = $Name
        command = $Executable + ' ' + ($Arguments -join ' ')
        working_directory = $WorkingDirectory
        exit_code = $exitCode
        passed = $passed
        duration_seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 3)
        log = $logPath
    }) | Out-Null
    if (-not $passed -and -not $AllowFailure) {
        throw "Stage '$Name' failed with exit code $exitCode. See $logPath"
    }
    return $lines
}

function Invoke-Capture {
    param(
        [string]$Name,
        [string]$Executable,
        [string[]]$Arguments = @(),
        [string]$WorkingDirectory = $Root
    )
    $lines = Invoke-Stage -Name $Name -Executable $Executable -Arguments $Arguments -WorkingDirectory $WorkingDirectory
    return ($lines -join [Environment]::NewLine)
}

function Invoke-StageProcess {
    param(
        [string]$Name,
        [string]$Executable,
        [string[]]$Arguments = @(),
        [string]$WorkingDirectory = $Root,
        [int]$TimeoutSeconds = 300
    )
    $safeName = ($Name -replace '[^A-Za-z0-9_.-]', '_')
    $logPath = if ($null -ne $OutputPath) { Join-Path $OutputPath ($safeName + '.log') } else { $null }
    Write-Event ("COMMAND " + $Name + ": " + $Executable + " " + ($Arguments -join ' '))
    $started = Get-Date
    $argumentString = ($Arguments | ForEach-Object {
        '"' + ([string]$_).Replace('"', '\"') + '"'
    }) -join ' '
    $startInfo = [Diagnostics.ProcessStartInfo]::new()
    $startInfo.FileName = $Executable
    $startInfo.Arguments = $argumentString
    $startInfo.WorkingDirectory = $WorkingDirectory
    $startInfo.UseShellExecute = $false
    $startInfo.CreateNoWindow = $true
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $child = [Diagnostics.Process]::new()
    $child.StartInfo = $startInfo
    if (-not $child.Start()) { throw "Unable to start stage process: $Executable" }
    $stdoutTask = $child.StandardOutput.ReadToEndAsync()
    $stderrTask = $child.StandardError.ReadToEndAsync()
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    $timedOut = $false
    while (-not $child.HasExited) {
        if ((Get-Date) -ge $deadline) {
            $timedOut = $true
            & taskkill.exe /PID $child.Id /T /F *> $null
            break
        }
        Start-Sleep -Milliseconds 200
    }
    $child.WaitForExit()
    $stdout = $stdoutTask.GetAwaiter().GetResult()
    $stderr = $stderrTask.GetAwaiter().GetResult()
    $exitCode = if ($timedOut) { 124 } else { $child.ExitCode }
    $child.Dispose()
    $combined = $stdout
    if ($stderr) {
        $separator = if ($combined) { [Environment]::NewLine } else { '' }
        $combined += ($separator + $stderr)
    }
    if ($null -ne $logPath) {
        [IO.File]::WriteAllText($logPath, $combined, (New-Object System.Text.UTF8Encoding($false)))
    }
    $passed = (-not $timedOut -and $exitCode -eq 0)
    $StageResults.Add([pscustomobject]@{
        name = $Name
        command = $Executable + ' ' + ($Arguments -join ' ')
        working_directory = $WorkingDirectory
        exit_code = $exitCode
        passed = $passed
        duration_seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 3)
        log = $logPath
    }) | Out-Null
    foreach ($line in ($combined -split "`r?`n")) {
        if ($line) { Write-Host $line }
    }
    if ($timedOut) {
        throw "Stage '$Name' timed out after ${TimeoutSeconds}s. See $logPath"
    }
    if (-not $passed) {
        throw "Stage '$Name' failed with exit code $exitCode. See $logPath"
    }
    return @($combined -split "`r?`n")
}

function Test-RequestedVersion {
    $match = [regex]::Match($Version.Trim(), '^(?<a>[0-9]+)\.(?<b>[0-9]+)\.(?<c>[0-9]+)\.(?<d>[0-9]+)$')
    if (-not $match.Success) {
        throw "Version '$Version' is invalid. Use four numeric components such as 140.0.0.0."
    }
    foreach ($name in @('a','b','c','d')) {
        $number = 0
        if (-not [int]::TryParse($match.Groups[$name].Value, [ref]$number) -or $number -gt 65535) {
            throw "Version '$Version' contains a component outside the Windows/MSIX range 0..65535."
        }
    }
}

function Test-SourceSupport {
    if (-not (Test-Path -LiteralPath $SetVersion)) {
        throw "Source version script is missing: $SetVersion"
    }
    if (-not (Test-Path -LiteralPath $CheckVersion)) {
        throw "Source version check script is missing: $CheckVersion"
    }
    if (-not $SkipWindows) {
        if (-not (Test-Path -LiteralPath $WindowsBuild)) {
            throw "Windows stage requested, but this checkout has no windows\\build.ps1. Use -SkipWindows only when intentionally building Docker without Windows artifacts."
        }
        if (-not $SkipMsix) {
            $msixScript = Join-Path $Root 'windows\build-msix.ps1'
            if (-not (Test-Path -LiteralPath $msixScript) -and $null -eq (Get-CommandPath 'MakeAppx.exe')) {
                throw "MSIX stage requested, but neither windows\\build-msix.ps1 nor MakeAppx.exe exists."
            }
        }
    }
}

function Resolve-InnoCompiler {
    $path = Get-CommandPath 'ISCC.exe'
    if ($null -ne $path) { return $path }
    $candidates = @(
        'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
        'C:\Program Files\Inno Setup 6\ISCC.exe'
    )
    if ($env:LOCALAPPDATA) {
        $candidates += Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'
    }
    foreach ($candidate in $candidates) {
        if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
            return $candidate
        }
    }
    return $null
}

function Resolve-MakeAppx {
    $path = Get-CommandPath 'MakeAppx.exe'
    if ($null -ne $path) { return $path }
    $kitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
    if (Test-Path -LiteralPath $kitsRoot) {
        $path = Get-ChildItem -LiteralPath $kitsRoot -Filter 'MakeAppx.exe' -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -match '\\x64\\MakeAppx\.exe$' } |
            Sort-Object FullName -Descending |
            Select-Object -First 1
        if ($null -ne $path) { return $path.FullName }
    }
    return $null
}

function Resolve-SevenZip {
    $path = Get-CommandPath '7z.exe'
    if ($null -ne $path) { return $path }
    $candidates = @(
        (Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
        (Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
    )
    if ($env:LOCALAPPDATA) {
        $candidates += Join-Path $env:LOCALAPPDATA 'Programs\7-Zip\7z.exe'
    }
    foreach ($candidate in $candidates) {
        if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
            return $candidate
        }
    }
    return $null
}

function Test-Tools {
    $tools = [ordered]@{}
    if (-not $SkipTests -or -not $SkipWindows) {
        $pythonPath = Require-Command 'python' 'Python tests and compile checks'
        $pythonMajorMinor = (& $pythonPath -c 'import sys; print(str(sys.version_info.major) + chr(46) + str(sys.version_info.minor))' | Out-String).Trim()
        if ($LASTEXITCODE -ne 0 -or $pythonMajorMinor -notin @('3.11', '3.12', '3.13')) {
            throw "Python 3.11, 3.12, or 3.13 is required; found $pythonMajorMinor at $pythonPath."
        }
        $nodePath = Require-Command 'node' 'frontend build'
        $npmPath = Require-Command 'npm' 'frontend dependency installation/build'
        $tools.python = [ordered]@{path = $pythonPath; version = (Get-ToolVersion $pythonPath)}
        $tools.node = [ordered]@{path = $nodePath; version = (Get-ToolVersion $nodePath)}
        $tools.npm = [ordered]@{path = $npmPath; version = (Get-ToolVersion $npmPath)}
        if (-not $SkipTests) {
            $projectPython = Join-Path $Root '.venv\Scripts\python.exe'
            $testPython = if (Test-Path -LiteralPath $projectPython -PathType Leaf) { $projectPython } else { $pythonPath }
            & $testPython -c 'import aiofiles, apscheduler, celery, fastapi, httpx, orjson, psutil, pydantic, redis' 2>$null
            if ($LASTEXITCODE -ne 0) {
                throw ("Python test dependencies are missing. Prepare the project environment first: " +
                    "python -m venv .venv; .\.venv\Scripts\python.exe -m pip install -r requirements.txt httpx==0.27.2")
            }
            $tools.test_python = [ordered]@{path = $testPython; version = (Get-ToolVersion $testPython)}
        }
    }
    if (-not $SkipDocker) {
        $tools.docker = Require-Command 'docker' 'Docker build/Compose validation'
        $composeVersion = Invoke-Capture -Name 'tool-docker-compose-version' -Executable 'docker' -Arguments @('compose', 'version')
        $tools.docker = [ordered]@{path = $tools.docker; version = (Get-ToolVersion $tools.docker)}
        $tools.compose = $composeVersion.Trim()
    }
    if (-not $SkipDocker) {
        $curlPath = Require-Command 'curl.exe' 'Docker API upload/download smoke test'
        $tarPath = Require-Command 'tar' 'Docker archive structural verification'
        $tools.curl = [ordered]@{path = $curlPath; version = (Get-ToolVersion $curlPath)}
        $tools.tar = [ordered]@{path = $tarPath; version = (Get-ToolVersion $tarPath)}
    }
    if (-not $SkipWindows) {
        $inno = Resolve-InnoCompiler
        if ($null -eq $inno) {
            throw "Required tool 'ISCC.exe' is missing (Windows installer build)."
        }
        $tools.inno = [ordered]@{path = $inno; version = (Get-ToolVersion $inno)}
        $bundledFfmpeg = Join-Path $Root 'windows\ffmpeg\bin\ffmpeg.exe'
        $bundledFfprobe = Join-Path $Root 'windows\ffmpeg\bin\ffprobe.exe'
        $hasUsableBundle = $false
        if ((Test-Path -LiteralPath $bundledFfmpeg -PathType Leaf) -and (Test-Path -LiteralPath $bundledFfprobe -PathType Leaf)) {
            $encoderText = (& $bundledFfmpeg -hide_banner -encoders 2>&1 | Out-String)
            $hasUsableBundle = $encoderText -match '\blibsvtav1\b'
        }
        if (-not $hasUsableBundle) {
            $sevenZip = Resolve-SevenZip
            if ($null -eq $sevenZip) {
                throw "Required tool '7z.exe' is missing (Windows build needs to download the bundled FFmpeg archive)."
            }
            $tools.sevenzip = [ordered]@{path = $sevenZip; version = (Get-ToolVersion $sevenZip)}
        }
        $makeAppx = Resolve-MakeAppx
        if (-not $SkipMsix -and $null -eq $makeAppx) {
            $msixBuilder = Join-Path $Root 'windows\build-msix.ps1'
            if (Test-Path -LiteralPath $msixBuilder) {
                $tools.makeappx = [ordered]@{
                    path = 'deferred'
                    version = 'windows\build-msix.ps1 downloads Microsoft.Windows.SDK.BuildTools when needed'
                }
            } else {
                throw "Required tool 'MakeAppx.exe' is missing (MSIX build), and no build-msix.ps1 fallback exists."
            }
        } elseif (-not $SkipMsix) {
            $tools.makeappx = [ordered]@{path = $makeAppx; version = (Get-ToolVersion $makeAppx)}
        }
    }
    $script:ToolVersions = $tools
    return $tools
}

function Get-GitValue {
    param([string[]]$Arguments)
    $value = (& git -C $Root @Arguments 2>$null | Out-String).Trim()
    if ($LASTEXITCODE -ne 0) {
        return 'unavailable'
    }
    return $value
}

function Read-Version {
    return ([System.IO.File]::ReadAllText((Join-Path $Root 'VERSION'))).Trim()
}

function Run-Tests {
    $packagePath = Join-Path $Root 'frontend\package.json'
    $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json
    $npm = Get-CommandPath 'npm'
    Invoke-Stage -Name 'frontend-npm-ci' -Executable $npm -Arguments @('ci', '--loglevel=error', '--no-audit', '--no-fund') -WorkingDirectory (Join-Path $Root 'frontend') | Out-Null
    Invoke-Stage -Name 'frontend-npm-audit' -Executable $npm -Arguments @('audit', '--omit=dev', '--audit-level=low') -WorkingDirectory (Join-Path $Root 'frontend') | Out-Null
    $frontendDependencyCheck = Join-Path $Root 'scripts\check-frontend-dependencies.ps1'
    if (-not (Test-Path -LiteralPath $frontendDependencyCheck -PathType Leaf)) {
        throw "Frontend dependency security check is missing: $frontendDependencyCheck"
    }
    Invoke-Stage -Name 'frontend-dependency-security' -Executable 'powershell.exe' -Arguments @(
        '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $frontendDependencyCheck,
        '-RepositoryRoot', $Root
    ) | Out-Null
    $npx = Get-CommandPath 'npx'
    if ($null -eq $npx) {
        throw 'The npx command is required to run the frontend Svelte check.'
    }
    Invoke-Stage -Name 'frontend-svelte-kit-sync' -Executable $npx -Arguments @('--no-install', 'svelte-kit', 'sync') -WorkingDirectory (Join-Path $Root 'frontend') | Out-Null
    Invoke-Stage -Name 'frontend-svelte-check' -Executable $npx -Arguments @('--no-install', 'svelte-check', '--tsconfig', './tsconfig.json') -WorkingDirectory (Join-Path $Root 'frontend') | Out-Null
    foreach ($scriptName in @('check', 'lint', 'build')) {
        $hasScript = $false
        if ($null -ne $package.scripts) {
            $hasScript = @($package.scripts.PSObject.Properties.Name) -contains $scriptName
        }
        if ($hasScript) {
            Invoke-Stage -Name ('frontend-npm-' + $scriptName) -Executable $npm -Arguments @('run', $scriptName) -WorkingDirectory (Join-Path $Root 'frontend') | Out-Null
        } else {
            Write-Event ('SKIPPED frontend-npm-' + $scriptName + ': package.json has no script')
            $StageResults.Add([pscustomobject]@{name = 'frontend-npm-' + $scriptName; skipped = $true; reason = 'script missing'; passed = $null}) | Out-Null
        }
    }

    $projectPython = Join-Path $Root '.venv\Scripts\python.exe'
    $python = if (Test-Path -LiteralPath $projectPython -PathType Leaf) {
        $projectPython
    } else {
        Get-CommandPath 'python'
    }
    try {
        Invoke-Stage -Name 'python-test-dependencies' -Executable $python -Arguments @(
            '-c',
            'import aiofiles, apscheduler, celery, fastapi, httpx, orjson, psutil, pydantic, redis'
        ) | Out-Null
    } catch {
        throw ("Python test dependencies are missing. Prepare the project environment first: " +
            "python -m venv .venv; .\.venv\Scripts\python.exe -m pip install -r requirements.txt httpx==0.27.2")
    }
    $oldPythonPath = $env:PYTHONPATH
    try {
        $env:PYTHONPATH = $Root
        $compileDirs = @('backend-api', 'worker', 'shared', 'tests') | ForEach-Object {
            $path = Join-Path $Root $_
            if (Test-Path -LiteralPath $path) { $_ }
        }
        Invoke-Stage -Name 'python-compileall' -Executable $python -Arguments (@('-m', 'compileall', '-q') + $compileDirs) | Out-Null
        $testSuites = @(
            [pscustomobject]@{Directory = 'tests'; PythonPath = $Root},
            [pscustomobject]@{Directory = 'backend-api/tests'; PythonPath = ((Join-Path $Root 'backend-api'), (Join-Path $Root 'worker'), $Root) -join ';'},
            [pscustomobject]@{Directory = 'worker/tests'; PythonPath = $Root},
            [pscustomobject]@{Directory = 'shared/tests'; PythonPath = $Root}
        )
        foreach ($suite in $testSuites) {
            $testDir = $suite.Directory
            $fullTestDir = Join-Path $Root $testDir
            $testFiles = @(Get-ChildItem -LiteralPath $fullTestDir -Recurse -File -Filter 'test*.py' -ErrorAction SilentlyContinue)
            if ($testFiles.Count -gt 0) {
                $env:PYTHONPATH = $suite.PythonPath
                Invoke-Stage -Name ('python-unittest-' + ($testDir -replace '[\\/]', '-')) -Executable $python -Arguments @('-m', 'unittest', 'discover', '-s', $testDir, '-p', 'test*.py') | Out-Null
            } else {
                Write-Event ('NOT APPLICABLE python tests: no test*.py files under ' + $testDir)
                $StageResults.Add([pscustomobject]@{name = 'python-unittest-' + $testDir; skipped = $false; not_applicable = $true; reason = 'no matching tests in repository'; passed = $true}) | Out-Null
            }
        }
    } finally {
        $env:PYTHONPATH = $oldPythonPath
    }

    Invoke-Stage -Name 'version-consistency' -Executable 'powershell.exe' -Arguments @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $CheckVersion, '-RepositoryRoot', $Root) | Out-Null
    if (-not $SkipDocker) {
        $oldComposeVersion = $env:APP_VERSION
        $env:APP_VERSION = $Version
        try {
            foreach ($composeFile in @('docker-compose.yml', 'docker-compose.cpu.yml', 'docker-compose.vaapi.yml')) {
                Invoke-Stage -Name ('docker-compose-config-' + ($composeFile -replace '[^A-Za-z0-9]', '-')) -Executable 'docker' -Arguments @('compose', '-f', $composeFile, 'config') | Out-Null
            }
        } finally {
            $env:APP_VERSION = $oldComposeVersion
        }
    } else {
        Write-Event 'SKIPPED docker-compose-config by -SkipDocker'
        $StageResults.Add([pscustomobject]@{name = 'docker-compose-config'; skipped = $true; reason = '-SkipDocker'; passed = $null}) | Out-Null
    }
}

function Copy-Artifact {
    param([string]$Source, [string]$DestinationName)
    if (-not (Test-Path -LiteralPath $Source)) {
        throw "Expected artifact does not exist: $Source"
    }
    $destination = Join-Path $OutputPath $DestinationName
    if ([IO.Path]::GetFullPath($Source) -ne [IO.Path]::GetFullPath($destination)) {
        Copy-Item -LiteralPath $Source -Destination $destination -Force
    }
    $item = Get-Item -LiteralPath $destination
    if ($item.Length -le 0) {
        throw "Artifact is empty: $destination"
    }
    return $destination
}

function Run-WindowsBuild {
    $scriptText = Get-Content -LiteralPath $WindowsBuild -Raw
    $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $WindowsBuild)
    if ($scriptText -match '(?im)\$Version') {
        $args += @('-Version', $Version)
    }
    if ($scriptText -match '(?im)\$OutputDir') {
        $args += @('-OutputDir', $OutputPath)
    }
    $oldAppVersion = $env:APP_VERSION
    $env:APP_VERSION = $Version
    try {
        Invoke-StageProcess -Name 'windows-build' -TimeoutSeconds 3600 -Executable 'powershell.exe' -Arguments $args | Out-Null
    } finally {
        $env:APP_VERSION = $oldAppVersion
    }

    $portablePath = Copy-Artifact (Join-Path $OutputPath '8mblocal.exe') '8mblocal.exe'
    $installerPath = Copy-Artifact (Join-Path $OutputPath '8mblocal-Setup.exe') '8mblocal-Setup.exe'
    $portableInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($portablePath)
    $installerInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($installerPath)
    foreach ($info in @($portableInfo, $installerInfo)) {
        $fileVersion = Get-NormalizedFourPartVersion $info.FileVersion
        $productVersion = Get-NormalizedFourPartVersion $info.ProductVersion
        if ($fileVersion -ne $Version -or $productVersion -ne $Version) {
            throw "Windows artifact metadata versions FileVersion='$($info.FileVersion)' ProductVersion='$($info.ProductVersion)' do not exactly match $Version."
        }
    }
    $Artifacts.Add([pscustomobject]@{name = 'portable EXE'; path = $portablePath; size = (Get-Item $portablePath).Length; version = $portableInfo.FileVersion}) | Out-Null
    $Artifacts.Add([pscustomobject]@{name = 'installer EXE'; path = $installerPath; size = (Get-Item $installerPath).Length; version = $installerInfo.FileVersion}) | Out-Null

    if (-not $SkipTests) {
        $smokeScript = Join-Path $Root 'windows\test-release.ps1'
        if (-not (Test-Path -LiteralPath $smokeScript -PathType Leaf)) {
            throw "Windows smoke-test helper is missing: $smokeScript"
        }
        $portableSmokeData = Join-Path $OutputPath 'portable-smoke-data'
        $oldSmokeAuthEnabled = $env:AUTH_ENABLED
        $oldConfiguredAuthUser = $env:AUTH_USER
        $oldConfiguredAuthPass = $env:AUTH_PASS
        $oldSmokeAuthUser = $env:RELEASE_SMOKE_AUTH_USER
        $oldSmokeAuthPass = $env:RELEASE_SMOKE_AUTH_PASS
        $smokePassword = [guid]::NewGuid().ToString('N')
        $env:AUTH_ENABLED = 'true'
        $env:AUTH_USER = 'release-smoke'
        $env:AUTH_PASS = $smokePassword
        $env:RELEASE_SMOKE_AUTH_USER = 'release-smoke'
        # Use a per-run synthetic password only in the child-process environment;
        # it is never written to a command, file, or test log.
        $env:RELEASE_SMOKE_AUTH_PASS = $smokePassword
        try {
            Invoke-StageProcess -Name 'windows-portable-smoke' -TimeoutSeconds 300 -Executable 'powershell.exe' -Arguments @(
                '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $smokeScript,
                '-ExePath', $portablePath, '-Port', (Get-FreePort), '-DataDir', $portableSmokeData,
                '-ExpectedVersion', $Version, '-TestAuth', '-TestSettings'
            ) | Out-Null
            $installerSmokeData = Join-Path $OutputPath 'installer-smoke-data'
            Invoke-StageProcess -Name 'windows-installer-smoke' -TimeoutSeconds 300 -Executable 'powershell.exe' -Arguments @(
                '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $smokeScript,
                '-Install', '-InstallMode', 'current-user', '-InstallerPath', $installerPath,
                '-Port', (Get-FreePort), '-DataDir', $installerSmokeData, '-ExpectedVersion', $Version, '-TestAuth', '-TestSettings'
            ) | Out-Null
        } finally {
            $env:AUTH_ENABLED = $oldSmokeAuthEnabled
            $env:AUTH_USER = $oldConfiguredAuthUser
            $env:AUTH_PASS = $oldConfiguredAuthPass
            $env:RELEASE_SMOKE_AUTH_USER = $oldSmokeAuthUser
            $env:RELEASE_SMOKE_AUTH_PASS = $oldSmokeAuthPass
        }
    } else {
        foreach ($name in @('windows-portable-smoke', 'windows-installer-smoke')) {
            Write-Event ('SKIPPED ' + $name + ' by -SkipTests')
            $StageResults.Add([pscustomobject]@{name = $name; skipped = $true; reason = '-SkipTests'; passed = $null}) | Out-Null
        }
    }

    if (-not $SkipMsix) {
        $msixPath = Join-Path $OutputPath ('8mblocal_' + $Version + '_x64.msix')
        if (-not (Test-Path -LiteralPath $msixPath -PathType Leaf)) {
            $msixScript = Join-Path $Root 'windows\build-msix.ps1'
            if (Test-Path -LiteralPath $msixScript) {
                Invoke-StageProcess -Name 'windows-msix-build' -TimeoutSeconds 1800 -Executable 'powershell.exe' -Arguments @(
                    '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $msixScript,
                    '-Version', $Version, '-ExePath', $portablePath, '-OutputPath', $msixPath,
                    '-PackageIdentityName', $StorePackageIdentityName,
                    '-Publisher', $StorePublisher,
                    '-PublisherDisplayName', $StorePublisherDisplayName,
                    '-StoreSubmission'
                ) | Out-Null
            }
        }
        $msixPath = Copy-Artifact $msixPath ('8mblocal_' + $Version + '_x64.msix')
        Test-MsixArtifact -Path $msixPath
        $Artifacts.Add([pscustomobject]@{name = 'MSIX'; path = $msixPath; size = (Get-Item $msixPath).Length}) | Out-Null
    } else {
        Write-Event 'SKIPPED MSIX by -SkipMsix'
        $StageResults.Add([pscustomobject]@{name = 'windows-msix'; skipped = $true; reason = '-SkipMsix'; passed = $null}) | Out-Null
    }
}

function Test-MsixArtifact {
    param([Parameter(Mandatory = $true)][string]$Path)

    $inspectDir = Join-Path $OutputPath ('.msix-inspect-' + [guid]::NewGuid().ToString('N'))
    $archivePath = Join-Path $OutputPath ('.msix-inspect-' + [guid]::NewGuid().ToString('N') + '.zip')
    New-Item -ItemType Directory -Force -Path $inspectDir | Out-Null
    try {
        # Windows PowerShell's Expand-Archive only accepts the .zip extension,
        # while an MSIX package is a ZIP container. Copy to a unique temporary
        # .zip name for inspection without changing the release artifact.
        Copy-Item -LiteralPath $Path -Destination $archivePath -Force
        Expand-Archive -LiteralPath $archivePath -DestinationPath $inspectDir -Force
        $manifestFile = Get-ChildItem -LiteralPath $inspectDir -Recurse -File -Filter 'AppxManifest.xml' | Select-Object -First 1
        if ($null -eq $manifestFile) {
            throw 'MSIX does not contain AppxManifest.xml.'
        }
        $manifestText = [System.IO.File]::ReadAllText($manifestFile.FullName)
        try {
            [xml]$manifestXml = $manifestText
        } catch {
            throw "MSIX manifest is not valid XML: $($_.Exception.Message)"
        }
        $namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable)
        $namespaceManager.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
        $namespaceManager.AddNamespace('uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10')
        $namespaceManager.AddNamespace('rescap', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities')
        $identity = $manifestXml.SelectSingleNode('/f:Package/f:Identity', $namespaceManager)
        if ($null -eq $identity) { throw 'MSIX manifest is missing its package Identity.' }
        if ($identity.Name -ne $StorePackageIdentityName) { throw "MSIX package identity '$($identity.Name)' does not match '$StorePackageIdentityName'." }
        if ($identity.Publisher -ne $StorePublisher) { throw "MSIX publisher '$($identity.Publisher)' does not match the configured Store publisher." }
        if ($identity.ProcessorArchitecture -ne 'x64') { throw "MSIX architecture '$($identity.ProcessorArchitecture)' is not x64." }
        if ($identity.Version -ne $Version) { throw "MSIX manifest version '$($identity.Version)' does not match $Version." }
        $publisherDisplay = $manifestXml.SelectSingleNode('/f:Package/f:Properties/f:PublisherDisplayName', $namespaceManager)
        if ($null -eq $publisherDisplay -or $publisherDisplay.InnerText -ne $StorePublisherDisplayName) {
            throw 'MSIX publisher display name does not match the configured Store publisher display name.'
        }
        $runFullTrust = $manifestXml.SelectNodes('/f:Package/f:Capabilities/rescap:Capability[@Name="runFullTrust"]', $namespaceManager)
        if ($null -eq $runFullTrust -or $runFullTrust.Count -ne 1) { throw 'MSIX must contain exactly one runFullTrust capability.' }
        if ($manifestText -match '(?i)unvirtualizedResources|FileSystemWriteVirtualization') {
            throw 'MSIX manifest contains forbidden unvirtualizedResources or FileSystemWriteVirtualization capability.'
        }
        Write-Event ('MSIX manifest validated: identity=' + $identity.Name + '; publisher=' + $identity.Publisher + '; architecture=x64; version=' + $Version + '; runFullTrust=1; forbidden-capabilities=absent')
    } finally {
        if (Test-Path -LiteralPath $inspectDir) {
            Remove-Item -LiteralPath $inspectDir -Recurse -Force -ErrorAction SilentlyContinue
        }
        if (Test-Path -LiteralPath $archivePath) {
            Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
        }
    }
}

function Get-FreePort {
    $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0)
    $listener.Start()
    $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
    $listener.Stop()
    return $port
}

function Run-DockerBuild {
    $docker = Require-Command 'docker' 'Docker build'
    $tar = Require-Command 'tar' 'Docker archive structural verification'
    $commit = Get-GitValue @('rev-parse', 'HEAD')
    $imageTag = 'jms1717/8mblocal:local-' + $Version
    $buildArgs = @(
        'build',
        '--build-arg', ('BUILD_VERSION=' + $Version),
        '--build-arg', ('BUILD_COMMIT=' + $commit),
        '--build-arg', ('BUILD_TIMESTAMP=' + $Timestamp),
        '--build-arg', 'BUILD_REPOSITORY=https://github.com/JMS1717/8mb.local',
        '--label', ('org.opencontainers.image.version=' + $Version),
        '--label', ('org.opencontainers.image.revision=' + $commit),
        '--label', ('org.opencontainers.image.created=' + $Timestamp),
        '--label', 'org.opencontainers.image.source=https://github.com/JMS1717/8mb.local',
        '-t', $imageTag,
        '.'
    )
    Invoke-StageProcess -Name 'docker-build' -TimeoutSeconds 3600 -Executable $docker -Arguments $buildArgs | Out-Null

    $labelText = Invoke-Capture -Name 'docker-metadata-labels' -Executable $docker -Arguments @('image', 'inspect', $imageTag, '--format', '{{json .Config.Labels}}')
    $labels = $labelText.Trim() | ConvertFrom-Json
    if ($labels.'org.opencontainers.image.version' -ne $Version) {
        throw "Docker image version label '$($labels.'org.opencontainers.image.version')' does not match $Version."
    }
    if ($labels.'org.opencontainers.image.revision' -ne $commit) {
        throw "Docker image revision label does not match commit $commit."
    }
    if ($labels.'org.opencontainers.image.source' -ne 'https://github.com/JMS1717/8mb.local') {
        throw 'Docker image source label does not match the repository URL.'
    }
    $envText = Invoke-Capture -Name 'docker-metadata-env' -Executable $docker -Arguments @('image', 'inspect', $imageTag, '--format', '{{json .Config.Env}}')
    $imageEnv = $envText.Trim() | ConvertFrom-Json
    $expectedVersionEnv = 'APP_VERSION=' + $Version
    if (-not @($imageEnv | Where-Object { $_ -eq $expectedVersionEnv })) {
        throw "Docker image APP_VERSION environment value does not match $Version."
    }

    $dockerArchive = Join-Path $OutputPath '8mblocal-docker.tar'
    Invoke-StageProcess -Name 'docker-save' -TimeoutSeconds 1800 -Executable $docker -Arguments @('save', '-o', $dockerArchive, $imageTag) | Out-Null
    if ((Get-Item $dockerArchive).Length -le 0) { throw 'docker save produced an empty archive.' }
    # The image was already inspected and will be run below. Validate the saved
    # archive structurally instead of loading it back into the same daemon,
    # which is redundant and has caused Docker Desktop API failures.
    $archiveListText = Invoke-Capture -Name 'docker-archive-list' -Executable $tar -Arguments @('-tf', $dockerArchive)
    $archiveEntries = @($archiveListText -split '\r?\n' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    foreach ($requiredEntry in @('manifest.json', 'index.json', 'oci-layout')) {
        if ($archiveEntries -notcontains $requiredEntry) {
            throw "Docker archive is missing required entry '$requiredEntry'."
        }
    }
    $manifestText = Invoke-Capture -Name 'docker-archive-manifest' -Executable $tar -Arguments @('-xOf', $dockerArchive, 'manifest.json')
    try {
        $manifestEntries = @($manifestText.Trim() | ConvertFrom-Json)
    } catch {
        throw "Docker archive manifest.json is invalid JSON: $($_.Exception.Message)"
    }
    $matchingManifest = $manifestEntries | Where-Object { @($_.RepoTags) -contains $imageTag } | Select-Object -First 1
    if ($null -eq $matchingManifest) {
        throw "Docker archive manifest does not contain image tag $imageTag."
    }
    $referencedEntries = @($matchingManifest.Config) + @($matchingManifest.Layers)
    if ([string]::IsNullOrWhiteSpace([string]$matchingManifest.Config) -or @($matchingManifest.Layers).Count -eq 0) {
        throw 'Docker archive manifest is missing its config or layers.'
    }
    foreach ($entry in $referencedEntries) {
        $normalizedEntry = ([string]$entry).Replace('\', '/')
        if ($archiveEntries -notcontains $normalizedEntry) {
            throw "Docker archive manifest references missing entry '$normalizedEntry'."
        }
    }

    $script:DockerTemp = Join-Path $OutputPath 'docker-test'
    $configDir = Join-Path $DockerTemp 'config'
    New-Item -ItemType Directory -Force -Path (Join-Path $DockerTemp 'uploads'),(Join-Path $DockerTemp 'outputs'),$configDir | Out-Null
    # Match the supported Compose layout: mount one test-only configuration
    # directory and let the application recreate missing files inside it.
    $settingsFile = Join-Path $configDir 'settings.json'
    $historyFile = Join-Path $configDir 'history.json'
    $envFile = Join-Path $configDir '.env'
    [System.IO.File]::WriteAllText($settingsFile, '{}', (New-Object System.Text.UTF8Encoding($false)))
    [System.IO.File]::WriteAllText($historyFile, '[]', (New-Object System.Text.UTF8Encoding($false)))
    [System.IO.File]::WriteAllText($envFile, "AUTH_ENABLED=false`n", (New-Object System.Text.UTF8Encoding($false)))
    $port = Get-FreePort
    $script:DockerContainer = '8mblocal-local-' + ([guid]::NewGuid().ToString('N').Substring(0, 10))
    $runArguments = @(
        'run', '-d', '--rm', '--name', $DockerContainer,
        '--publish', ('127.0.0.1:' + $port + ':8001'),
        '--mount', ('type=bind,source=' + (Join-Path $DockerTemp 'uploads') + ',target=/app/uploads'),
        '--mount', ('type=bind,source=' + (Join-Path $DockerTemp 'outputs') + ',target=/app/outputs'),
        '--mount', ('type=bind,source=' + $configDir + ',target=/app/config'),
        '--env', 'AUTH_ENABLED=false',
        '--env', 'ENV_FILE=/app/config/.env',
        '--env', 'SETTINGS_FILE=/app/config/settings.json',
        '--env', 'HISTORY_FILE=/app/config/history.json',
        '--env', ('APP_VERSION=' + $Version),
        '--env', 'TMPDIR=/app/uploads/.tmp',
        $imageTag
    )
    Invoke-Stage -Name 'docker-run' -Executable $docker -Arguments $runArguments | Out-Null
    $script:DockerContainerStarted = $true
    try {
        $healthy = $false
        for ($i = 0; $i -lt 60; $i++) {
            try {
                $health = Invoke-WebRequest -UseBasicParsing -Uri ('http://127.0.0.1:' + $port + '/healthz') -TimeoutSec 3
                if ($health.StatusCode -eq 200) { $healthy = $true; break }
            } catch {}
            Start-Sleep -Seconds 1
        }
        if (-not $healthy) {
            Invoke-Stage -Name 'docker-logs-on-health-failure' -Executable $docker -Arguments @('logs', $DockerContainer) -AllowFailure | Out-Null
            throw 'Docker container did not become healthy within 60 seconds.'
        }
        $versionJson = Invoke-WebRequest -UseBasicParsing -Uri ('http://127.0.0.1:' + $port + '/api/version') -TimeoutSec 10
        $reported = ($versionJson.Content | ConvertFrom-Json).version
        if ($reported -ne $Version) { throw "Docker API reported $reported instead of $Version." }
        $frontend = Invoke-WebRequest -UseBasicParsing -Uri ('http://127.0.0.1:' + $port + '/') -TimeoutSec 10
        if ($frontend.StatusCode -ne 200) { throw 'Docker frontend request failed.' }
        $inputFile = Join-Path $DockerTemp 'input.mp4'
        Invoke-StageProcess -Name 'docker-synthetic-input' -TimeoutSeconds 120 -Executable $docker -Arguments @(
            'run', '--rm', '--entrypoint', 'ffmpeg',
            '--mount', ('type=bind,source=' + $DockerTemp + ',target=/work'),
            $imageTag, '-hide_banner', '-loglevel', 'error', '-y',
            '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10',
            '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=44100',
            '-t', '1', '-c:v', 'libx264', '-c:a', 'aac', '/work/input.mp4'
        ) | Out-Null
        $uploadText = Invoke-Capture -Name 'docker-upload' -Executable 'curl.exe' -Arguments @('-sS', '-f', '-F', ('file=@' + $inputFile), ('http://127.0.0.1:' + $port + '/api/upload?target_size_mb=1&audio_bitrate_kbps=64'))
        $upload = $uploadText | ConvertFrom-Json
        $body = @{
            job_id = $upload.job_id
            filename = $upload.filename
            target_size_mb = 1
            video_codec = 'libx264'
            audio_codec = 'aac'
            audio_bitrate_kbps = 64
            preset = 'p3'
            container = 'mp4'
            tune = 'hq'
        } | ConvertTo-Json -Compress
        $bodyPath = Join-Path $DockerTemp 'compress.json'
        [System.IO.File]::WriteAllText($bodyPath, $body, (New-Object System.Text.UTF8Encoding($false)))
        # Passing JSON inline through Windows PowerShell can strip the JSON
        # quotes during native argument marshalling. Send a file instead.
        $compressText = Invoke-Capture -Name 'docker-compress' -Executable 'curl.exe' -Arguments @('-sS', '-f', '-H', 'Content-Type: application/json', '--data-binary', ('@' + $bodyPath), ('http://127.0.0.1:' + $port + '/api/compress'))
        $task = $compressText | ConvertFrom-Json
        $complete = $false
        for ($i = 0; $i -lt 120; $i++) {
            Start-Sleep -Milliseconds 500
            try {
                $status = (Invoke-WebRequest -UseBasicParsing -Uri ('http://127.0.0.1:' + $port + '/api/jobs/' + $task.task_id + '/status') -TimeoutSec 5).Content | ConvertFrom-Json
                if ($status.state -eq 'SUCCESS' -or $status.state -eq 'completed') { $complete = $true; break }
                if ($status.state -eq 'FAILURE' -or $status.state -eq 'failed') { throw ('Docker compression failed: ' + ($status | ConvertTo-Json -Compress)) }
            } catch {
                if ($_.Exception.Message -like 'Docker compression failed:*') { throw }
            }
        }
        if (-not $complete) { throw 'Docker compression did not complete within 60 seconds.' }
        $downloadFile = Join-Path $DockerTemp 'output.mp4'
        Invoke-Stage -Name 'docker-download' -Executable 'curl.exe' -Arguments @('-sS', '-f', ('http://127.0.0.1:' + $port + '/api/jobs/' + $task.task_id + '/download'), '-o', $downloadFile) | Out-Null
        $probeLines = Invoke-StageProcess -Name 'docker-ffprobe' -TimeoutSeconds 120 -Executable $docker -Arguments @(
            'run', '--rm', '--entrypoint', 'ffprobe',
            '--mount', ('type=bind,source=' + $DockerTemp + ',target=/work'),
            $imageTag, '-v', 'error', '-show_entries', 'format=duration:stream=codec_type',
            '-of', 'json', '/work/output.mp4'
        )
        try {
            $probe = (($probeLines -join [Environment]::NewLine) | ConvertFrom-Json)
        } catch {
            throw "Docker FFprobe returned invalid JSON: $($_.Exception.Message)"
        }
        $duration = 0.0
        [double]::TryParse([string]$probe.format.duration, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$duration) | Out-Null
        $hasVideo = @($probe.streams | Where-Object { $_.codec_type -eq 'video' }).Count -gt 0
        if ($duration -le 0 -or -not $hasVideo) {
            throw "Docker output failed FFprobe integrity validation (duration=$duration, video_stream=$hasVideo)."
        }
    } finally {
        Invoke-Stage -Name 'docker-stop' -Executable $docker -Arguments @('rm', '-f', $DockerContainer) -AllowFailure | Out-Null
        $script:DockerContainerStarted = $false
    }
    $Artifacts.Add([pscustomobject]@{name = 'Docker image tar'; path = $dockerArchive; size = (Get-Item $dockerArchive).Length; image_tag = $imageTag}) | Out-Null
}

function Write-Outputs {
    $skippedCount = @($StageResults | Where-Object { $_.skipped }).Count
    if ($null -ne $Failure) {
        $result = 'FAILED'
        $releaseReady = $false
    } elseif ($skippedCount -gt 0) {
        $result = 'INCOMPLETE'
        $releaseReady = $false
    } else {
        $result = 'PASS'
        $releaseReady = $true
    }

    $artifactRecords = @(
        foreach ($artifact in $Artifacts.ToArray()) {
            $artifactItem = Get-Item -LiteralPath $artifact.path
            $record = [ordered]@{
                name = $artifact.name
                path = $artifact.path
                size = $artifactItem.Length
                sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactItem.FullName).Hash
            }
            if ($artifact.PSObject.Properties['image_tag']) {
                $record.image_tag = $artifact.image_tag
            }
            if ($artifact.PSObject.Properties['version']) {
                $record.version = $artifact.version
            }
            [pscustomobject]$record
        }
    )

    $manifest = [ordered]@{
        source_path = $Root
        branch = Get-GitValue @('branch', '--show-current')
        commit = Get-GitValue @('rev-parse', 'HEAD')
        dirty = ((& git -C $Root status --porcelain 2>$null | Out-String).Trim().Length -gt 0)
        requested_version = $Version
        version_file = Read-Version
        result = $result
        release_ready = $releaseReady
        started_utc = $Timestamp
        completed_utc = (Get-Date).ToUniversalTime().ToString('o')
        tools = $script:ToolVersions
        options = [ordered]@{
            skip_tests = [bool]$SkipTests
            skip_docker = [bool]$SkipDocker
            skip_windows = [bool]$SkipWindows
            skip_msix = [bool]$SkipMsix
            keep_temp = [bool]$KeepTemp
        }
        # Windows PowerShell 5.1 cannot JSON-serialize a generic List nested
        # inside an ordered dictionary. Convert the lists to real arrays.
        stages = @($StageResults.ToArray())
        artifacts = $artifactRecords
        failure = $Failure
    }
    $manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $OutputPath 'BUILD-MANIFEST.json') -Encoding UTF8
    $hashLines = New-Object System.Collections.Generic.List[string]
    foreach ($artifact in ($artifactRecords | Sort-Object path)) {
        $hashLines.Add(($artifact.sha256 + '  ' + [IO.Path]::GetFileName($artifact.path))) | Out-Null
    }
    $hashLines | Set-Content -LiteralPath (Join-Path $OutputPath 'SHA256SUMS.txt') -Encoding ASCII
    $report = New-Object System.Collections.Generic.List[string]
    $report.Add('# Local release test results') | Out-Null
    $report.Add('') | Out-Null
    $report.Add(('Version: ' + $Version)) | Out-Null
    $report.Add(('Source: ' + $Root)) | Out-Null
    $report.Add(('Commit: ' + (Get-GitValue @('rev-parse', 'HEAD')))) | Out-Null
    $report.Add(('Dirty at report time: ' + $manifest.dirty)) | Out-Null
    $report.Add('') | Out-Null
    if ($null -ne $Failure) {
        $report.Add(('Result: FAILED - ' + $Failure)) | Out-Null
    } else {
        $report.Add(('Result: ' + $result)) | Out-Null
    }
    $report.Add(('Release-ready: ' + $releaseReady)) | Out-Null
    $report.Add('') | Out-Null
    $report.Add('## Stages') | Out-Null
    foreach ($stage in $StageResults) {
        if ($stage.not_applicable) { $report.Add(('- ' + $stage.name + ': NOT APPLICABLE (' + $stage.reason + ')')) | Out-Null }
        elseif ($stage.skipped) { $report.Add(('- ' + $stage.name + ': SKIPPED (' + $stage.reason + ')')) | Out-Null }
        else { $report.Add(('- ' + $stage.name + ': ' + ($(if ($stage.passed) { 'PASS' } else { 'FAIL' })))) | Out-Null }
    }
    $report.Add('') | Out-Null
    $report.Add('## Artifacts') | Out-Null
    foreach ($artifact in $artifactRecords) {
        $report.Add(('- ' + $artifact.name + ': ' + $artifact.path + ' (' + $artifact.size + ' bytes; SHA-256 ' + $artifact.sha256 + ')')) | Out-Null
    }
    $report.Add('') | Out-Null
    if ($null -ne $Failure) {
        $report.Add('The release build failed before all required stages completed; inspect the failure and stage logs above.') | Out-Null
    } elseif ($skippedCount -gt 0) {
        $report.Add('Required stages were skipped; this output is not release-ready.') | Out-Null
    } else {
        $report.Add('All required stages completed.') | Out-Null
    }
    $report | Set-Content -LiteralPath (Join-Path $OutputPath 'TEST-RESULTS.md') -Encoding UTF8
}

try {
    Test-RequestedVersion
    Test-SourceSupport
    if ($DryRun) {
        $toolVersions = Test-Tools
        Write-Event ('DRY RUN version=' + $Version)
        Write-Event ('PLAN: synchronize VERSION and generated app data')
        if ($SkipTests) { Write-Event 'PLAN: skip automated tests' } else { Write-Event 'PLAN: run automated tests' }
        if ($SkipWindows) { Write-Event 'PLAN: skip Windows artifacts' } else { Write-Event 'PLAN: build portable EXE, installer, and MSIX' }
        if ($SkipDocker) { Write-Event 'PLAN: skip Docker build/test/tar' } else { Write-Event 'PLAN: build/test/save Docker image' }
        Write-Event 'DRY RUN PASSED'
        exit 0
    }

    if ([string]::IsNullOrWhiteSpace($OutputDir)) {
        $OutputDir = Join-Path $Root ('dist\release\' + $Version)
    }
    $OutputPath = [System.IO.Path]::GetFullPath($OutputDir)
    # Apply containment checks before checking whether the destination exists;
    # a new path inside the checkout must be just as protected as an existing
    # one.
    $rootWithSeparator = $Root.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
    $generatedRoot = (Join-Path $Root 'dist').TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
    $outputRoot = [IO.Path]::GetPathRoot($OutputPath)
    if ($OutputPath -eq $Root -or
        $Root.StartsWith($OutputPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -or
        $OutputPath -eq $outputRoot) {
        throw "Refusing to overwrite or create a source/ancestor directory: $OutputPath"
    }
    if ($OutputPath.StartsWith($rootWithSeparator, [StringComparison]::OrdinalIgnoreCase) -and
        -not $OutputPath.StartsWith($generatedRoot, [StringComparison]::OrdinalIgnoreCase)) {
        throw "Refusing to write release artifacts into a source subdirectory: $OutputPath. Use a path outside the checkout or under dist."
    }
    if (Test-Path -LiteralPath $OutputPath) {
        if (-not $Overwrite) {
            throw "Output directory already exists: $OutputPath. Use -Overwrite explicitly to reuse it."
        }
        if (Test-Path -LiteralPath (Join-Path $OutputPath '.git')) {
            throw "Refusing to overwrite a Git checkout: $OutputPath"
        }
        $markerPath = Join-Path $OutputPath $ReleaseMarkerName
        if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf) -or
            ([IO.File]::ReadAllText($markerPath)).Trim() -ne $ReleaseMarkerValue) {
            throw "Refusing to overwrite an unmarked directory: $OutputPath. Choose a new output path; only directories created by this script can be reused with -Overwrite."
        }
        Remove-Item -LiteralPath $OutputPath -Recurse -Force
    }
    New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
    [IO.File]::WriteAllText((Join-Path $OutputPath $ReleaseMarkerName), $ReleaseMarkerValue, (New-Object System.Text.UTF8Encoding($false)))
    Write-Event ('OUTPUT=' + $OutputPath)

    $toolVersions = Test-Tools

    Invoke-Stage -Name 'set-version' -Executable 'powershell.exe' -Arguments @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $SetVersion, '-Version', $Version, '-RepositoryRoot', $Root) | Out-Null
    Invoke-Stage -Name 'check-version' -Executable 'powershell.exe' -Arguments @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $CheckVersion, '-RepositoryRoot', $Root) | Out-Null
    if (-not $SkipTests) {
        Run-Tests
    } else {
        Write-Event 'SKIPPED all automated tests by -SkipTests'
        $StageResults.Add([pscustomobject]@{name = 'automated-tests'; skipped = $true; reason = '-SkipTests'; passed = $null}) | Out-Null
    }
    if (-not $SkipWindows) {
        Run-WindowsBuild
    } else {
        Write-Event 'SKIPPED Windows artifacts by -SkipWindows'
        $StageResults.Add([pscustomobject]@{name = 'windows-artifacts'; skipped = $true; reason = '-SkipWindows'; passed = $null}) | Out-Null
    }
    if (-not $SkipDocker) {
        Run-DockerBuild
    } else {
        Write-Event 'SKIPPED Docker by -SkipDocker'
        $StageResults.Add([pscustomobject]@{name = 'docker-artifacts'; skipped = $true; reason = '-SkipDocker'; passed = $null}) | Out-Null
    }
    if (@($StageResults | Where-Object { $_.skipped }).Count -gt 0) {
        Write-Event 'LOCAL RELEASE BUILD COMPLETED WITH SKIPPED STAGES (NOT RELEASE-READY)'
    } else {
        Write-Event 'LOCAL RELEASE BUILD PASSED'
    }
} catch {
    $Failure = $_.Exception.Message
    Write-Event ('LOCAL RELEASE BUILD FAILED: ' + $Failure)
    if ($null -ne $DockerContainer -and $DockerContainerStarted) {
        & docker rm -f $DockerContainer 2>$null | Out-Null
        $script:DockerContainerStarted = $false
    }
    if ($null -ne $OutputPath) {
        Write-Outputs
    }
    exit 1
} finally {
    if ($null -ne $OutputPath -and $null -eq $Failure) {
        Write-Outputs
    }
    if ($null -ne $DockerTemp -and -not $KeepTemp -and (Test-Path -LiteralPath $DockerTemp)) {
        Remove-Item -LiteralPath $DockerTemp -Recurse -Force -ErrorAction SilentlyContinue
    }
}