[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(), '^(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)\.(?[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 } }