[CmdletBinding()] param( [ValidatePattern('^release-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')] [string]$ReleaseName = (Get-Date -Format "'release-'yyyyMMdd-HHmmss"), [string]$WslDistro = 'Ubuntu-24.04', [string]$Python = 'python', [switch]$SkipTests ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProjectRoot = $PSScriptRoot $DistRoot = Join-Path $ProjectRoot 'dist' $ReleaseRoot = Join-Path $DistRoot $ReleaseName $ReleaseId = $ReleaseName.Substring('release-'.Length) $ImageTag = 'truf-remote-worker:linux-x86_64' $DockerMode = $null function Invoke-Checked { param( [Parameter(Mandatory = $true)][string]$Command, [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$Label ) & $Command @Arguments if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" } } function Invoke-DockerChecked { param( [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$Label ) if ($script:DockerMode -eq 'native') { Invoke-Checked -Command 'docker' -Arguments $Arguments -Label $Label return } $prefix = @( '-d', $script:WslDistro, '--', 'sudo', '-n', 'docker', '-H', 'unix:///var/run/docker.sock' ) Invoke-Checked -Command 'wsl.exe' -Arguments ($prefix + $Arguments) -Label $Label } function Get-DockerText { param( [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$Label ) if ($script:DockerMode -eq 'native') { $output = @(& docker @Arguments) } else { $prefix = @( '-d', $script:WslDistro, '--', 'sudo', '-n', 'docker', '-H', 'unix:///var/run/docker.sock' ) $output = @(& wsl.exe @prefix @Arguments) } if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" } return ($output -join "`n") } function Get-WslPath { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path) if ($fullPath -notmatch '^(?[A-Za-z]):\\(?.*)$') { throw "Only absolute Windows drive paths can be translated for WSL: $Path" } $drive = $Matches['drive'].ToLowerInvariant() $tail = $Matches['tail'].Replace('\', '/') return "/mnt/$drive/$tail" } function Get-Sha256 { param([Parameter(Mandatory = $true)][string]$Path) return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Write-AsciiLines { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string[]]$Lines ) [IO.File]::WriteAllText( $Path, (($Lines -join "`n") + "`n"), [Text.Encoding]::ASCII ) } function Write-Sha256Manifest { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][object[]]$Files ) $lines = foreach ($file in $Files) { if ($file -is [string]) { $source = $file $name = [IO.Path]::GetFileName($file) } else { $source = [string]$file.Path $name = [string]$file.Name } "$(Get-Sha256 -Path $source) $name" } Write-AsciiLines -Path $Path -Lines $lines } function Compress-Gzip { param( [Parameter(Mandatory = $true)][string]$InputPath, [Parameter(Mandatory = $true)][string]$OutputPath ) $source = [IO.File]::OpenRead($InputPath) try { $destination = [IO.File]::Open( $OutputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None ) try { $gzip = New-Object IO.Compression.GZipStream( $destination, [IO.Compression.CompressionLevel]::Fastest, $true ) try { $source.CopyTo($gzip) } finally { $gzip.Dispose() } } finally { $destination.Dispose() } } finally { $source.Dispose() } } function New-StoredZip { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][object[]]$Files ) Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $stream = [IO.File]::Open( $Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None ) try { $zip = New-Object IO.Compression.ZipArchive( $stream, [IO.Compression.ZipArchiveMode]::Create, $false ) try { foreach ($file in $Files) { $entry = $zip.CreateEntry( [string]$file.Name, [IO.Compression.CompressionLevel]::NoCompression ) $entry.LastWriteTime = [DateTimeOffset]::Parse( '1980-01-01T00:00:00Z' ) $input = [IO.File]::OpenRead([string]$file.Path) try { $output = $entry.Open() try { $input.CopyTo($output) } finally { $output.Dispose() } } finally { $input.Dispose() } } } finally { $zip.Dispose() } } finally { $stream.Dispose() } } if (-not (Test-Path -LiteralPath $DistRoot -PathType Container)) { throw "Distribution directory is missing: $DistRoot" } if (Test-Path -LiteralPath $ReleaseRoot) { throw "Release destination already exists: $ReleaseRoot" } if (-not (Get-Command $Python -ErrorAction SilentlyContinue)) { throw "Python command is unavailable: $Python" } $nativeDocker = Get-Command docker -ErrorAction SilentlyContinue if ($null -ne $nativeDocker) { & docker version --format '{{.Server.Version}}' *> $null if ($LASTEXITCODE -eq 0) { $DockerMode = 'native' } } if ($null -eq $DockerMode) { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { throw 'Neither native Docker nor WSL is available' } & wsl.exe -d $WslDistro -- sudo -n docker -H unix:///var/run/docker.sock ` version --format '{{.Server.Version}}' *> $null if ($LASTEXITCODE -ne 0) { throw "Docker is unavailable in WSL distribution $WslDistro" } $DockerMode = 'wsl' } Write-Host "Release: $ReleaseRoot" Write-Host "Docker: $DockerMode" if (-not $SkipTests) { Invoke-Checked -Command $Python -Arguments @( '-B', '-m', 'pytest', 'tests/test_worker_package.py', '-q' ) -Label 'Worker package tests' } New-Item -ItemType Directory -Path $ReleaseRoot | Out-Null $windowsRoot = Join-Path $ReleaseRoot 'truf-worker-windows-x86_64' $windowsZip = Join-Path $ReleaseRoot 'truf-worker-windows-x86_64.zip' $cacheRoot = Join-Path $ProjectRoot 'build\worker-cache' Invoke-Checked -Command $Python -Arguments @( '-B', 'app/worker_package_builder.py', 'windows', '--project-root', $ProjectRoot, '--output', $windowsRoot, '--archive', $windowsZip, '--cache', $cacheRoot ) -Label 'Windows worker package build' $windowsManifest = Join-Path $windowsRoot 'worker-package.json' $windowsManifestSnapshot = Join-Path $ReleaseRoot ( "windows-worker-package-$ReleaseId.json" ) Copy-Item -LiteralPath $windowsManifest -Destination $windowsManifestSnapshot $buildContext = if ($DockerMode -eq 'wsl') { Get-WslPath -Path $ProjectRoot } else { $ProjectRoot } Invoke-DockerChecked -Arguments @( 'build', '--provenance=false', '--target', 'worker', '--tag', $ImageTag, $buildContext ) -Label 'Linux worker image build' $linuxManifestSnapshot = Join-Path $ReleaseRoot ( "linux-worker-package-$ReleaseId.json" ) $linuxManifestText = Get-DockerText -Arguments @( 'run', '--rm', '--network', 'none', '--entrypoint', '/bin/cat', $ImageTag, '/opt/truf-worker/worker-package.json' ) -Label 'Linux worker manifest read' $null = $linuxManifestText | ConvertFrom-Json [IO.File]::WriteAllText( $linuxManifestSnapshot, ($linuxManifestText.TrimEnd() + "`n"), (New-Object Text.UTF8Encoding($false)) ) $linuxNativeArchive = Join-Path $ReleaseRoot ( 'truf-worker-native-linux-x86_64.tar.gz' ) $releaseMount = if ($DockerMode -eq 'wsl') { Get-WslPath -Path $ReleaseRoot } else { $ReleaseRoot } Invoke-DockerChecked -Arguments @( 'run', '--rm', '--network', 'none', '--user', '0:0', '--entrypoint', '/bin/sh', '--mount', "type=bind,source=$releaseMount,target=/release", $ImageTag, '-c', 'set -eu; tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner --transform=s,^truf-worker,truf-worker-linux-x86_64, -C /opt -czf /release/truf-worker-native-linux-x86_64.tar.gz truf-worker' ) -Label 'Native Linux worker package export' if (-not (Test-Path -LiteralPath $linuxNativeArchive -PathType Leaf)) { throw "Native Linux worker archive is missing: $linuxNativeArchive" } $linuxTar = Join-Path $ReleaseRoot 'truf-worker-linux-x86_64.tar' $linuxArchive = "$linuxTar.gz" if ($DockerMode -eq 'wsl') { $linuxTarDockerPath = Get-WslPath -Path $linuxTar } else { $linuxTarDockerPath = $linuxTar } Invoke-DockerChecked -Arguments @( 'image', 'save', '--output', $linuxTarDockerPath, $ImageTag ) -Label 'Linux worker image export' if ($DockerMode -eq 'wsl') { $linuxTarWsl = Get-WslPath -Path $linuxTar Invoke-Checked -Command 'wsl.exe' -Arguments @( '-d', $WslDistro, '--', 'gzip', '-1', '-n', '-f', $linuxTarWsl ) -Label 'Linux worker image compression' } else { Compress-Gzip -InputPath $linuxTar -OutputPath $linuxArchive Remove-Item -LiteralPath $linuxTar } if (-not (Test-Path -LiteralPath $linuxArchive -PathType Leaf)) { throw "Linux worker archive is missing: $linuxArchive" } $compose = Join-Path $ReleaseRoot 'compose.yaml' $readme = Join-Path $ReleaseRoot 'README_RU.md' $workerctlShell = Join-Path $ReleaseRoot 'workerctl.sh' $workerctlPowerShell = Join-Path $ReleaseRoot 'workerctl.ps1' Copy-Item -LiteralPath (Join-Path $ProjectRoot 'deploy\worker\compose.yaml') ` -Destination $compose $readmeText = [IO.File]::ReadAllText( (Join-Path $ProjectRoot 'docs\remote-worker-quickstart-ru.md') ).Replace( '](remote-worker-cheatsheet-', '](cheatsheets/remote-worker-cheatsheet-' ) [IO.File]::WriteAllText( $readme, $readmeText, (New-Object Text.UTF8Encoding($false)) ) Copy-Item -LiteralPath (Join-Path $ProjectRoot 'deploy\worker\workerctl.sh') ` -Destination $workerctlShell Copy-Item -LiteralPath (Join-Path $ProjectRoot 'deploy\worker\workerctl.ps1') ` -Destination $workerctlPowerShell $cheatsheetRoot = Join-Path $ReleaseRoot 'cheatsheets' New-Item -ItemType Directory -Path $cheatsheetRoot | Out-Null $cheatsheetEntries = @( foreach ($name in @( 'remote-worker-cheatsheet-windows-ru.md', 'remote-worker-cheatsheet-linux-ru.md', 'remote-worker-cheatsheet-docker-ru.md' )) { $destination = Join-Path $cheatsheetRoot $name Copy-Item -LiteralPath (Join-Path $ProjectRoot "docs\$name") ` -Destination $destination [PSCustomObject]@{ Path = $destination Name = "cheatsheets/$name" } } ) $dockerSums = Join-Path $ReleaseRoot 'DOCKER-SHA256SUMS.txt' $dockerFiles = @( [PSCustomObject]@{ Path = $linuxArchive; Name = [IO.Path]::GetFileName($linuxArchive) }, [PSCustomObject]@{ Path = $compose; Name = 'compose.yaml' }, [PSCustomObject]@{ Path = $readme; Name = 'README_RU.md' }, [PSCustomObject]@{ Path = $workerctlShell; Name = 'workerctl.sh' }, [PSCustomObject]@{ Path = $workerctlPowerShell; Name = 'workerctl.ps1' } ) $dockerFiles += $cheatsheetEntries Write-Sha256Manifest -Path $dockerSums -Files $dockerFiles $dockerBundle = Join-Path $ReleaseRoot 'truf-worker-docker-x86_64-bundle.zip' $dockerBundleFiles = @($dockerFiles) $dockerBundleFiles += [PSCustomObject]@{ Path = $dockerSums Name = 'SHA256SUMS.txt' } New-StoredZip -Path $dockerBundle -Files $dockerBundleFiles $releaseSums = Join-Path $ReleaseRoot 'SHA256SUMS.txt' $releaseFiles = @( $windowsZip, "$windowsZip.json", $windowsManifestSnapshot, $linuxNativeArchive, $linuxArchive, $linuxManifestSnapshot, $compose, $readme, $workerctlShell, $workerctlPowerShell, $dockerSums, $dockerBundle ) $releaseFiles += $cheatsheetEntries Write-Sha256Manifest -Path $releaseSums -Files $releaseFiles Write-Host "Complete release created: $ReleaseRoot" Write-Host "Checksums: $releaseSums"