param( [string]$Version = "", [ValidateSet("", "patch", "minor", "major")] [string]$Bump = "", [string]$OutputRoot = "releases", [switch]$SkipTests, [switch]$SkipBuild, [switch]$PlanOnly, [switch]$Publish, [switch]$Resume, [switch]$Replace, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) $PackageJsonPath = Join-Path $RepoRoot "package.json" $PackageLockPath = Join-Path $RepoRoot "package-lock.json" $TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json" $CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml" $CargoLockPath = Join-Path $RepoRoot "src-tauri\Cargo.lock" $BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle" $RuntimeBoundaryCheckPath = Join-Path $RepoRoot "scripts\check-runtime-powershell-boundary.ps1" $ComponentBundleScriptPath = Join-Path $RepoRoot "scripts\update-component-bundle.ps1" $WindowsAuditScriptPath = Join-Path $RepoRoot "scripts\audit-windows-smoke.ps1" function Write-Utf8NoBomFile { param( [string]$Path, [string]$Value ) $encoding = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($Path, $Value, $encoding) } function Read-JsonFile { param([string]$Path) Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json } function Write-JsonFile { param( [string]$Path, [object]$Value ) $json = $Value | ConvertTo-Json -Depth 100 Write-Utf8NoBomFile -Path $Path -Value ($json + [Environment]::NewLine) } function Replace-RegexGroup { param( [string]$Content, [string]$Pattern, [string]$GroupName, [string]$Value, [string]$Label ) $match = [regex]::Match($Content, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) if (-not $match.Success) { throw "Cannot find $Label." } $group = $match.Groups[$GroupName] if (-not $group.Success) { throw "Cannot find $Label value." } $Content.Remove($group.Index, $group.Length).Insert($group.Index, $Value) } function Get-FirstJsonVersion { param( [string]$Path, [string]$Label ) $content = Get-Content -Raw -LiteralPath $Path $match = [regex]::Match($content, '"version"\s*:\s*"(?[^"]+)"') if (-not $match.Success) { throw "Cannot find version in $Label." } $match.Groups["value"].Value } function Set-FirstJsonVersion { param( [string]$Path, [string]$TargetVersion, [string]$Label ) $content = Get-Content -Raw -LiteralPath $Path $updated = Replace-RegexGroup ` -Content $content ` -Pattern '"version"\s*:\s*"(?[^"]+)"' ` -GroupName "value" ` -Value $TargetVersion ` -Label "version in $Label" Write-Utf8NoBomFile -Path $Path -Value $updated } function Get-PackageLockVersions { $content = Get-Content -Raw -LiteralPath $PackageLockPath $topMatch = [regex]::Match( $content, '^\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?[^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Singleline ) if (-not $topMatch.Success) { throw "Cannot find top-level version in package-lock.json." } $rootMatch = [regex]::Match( $content, '"packages"\s*:\s*\{\s*""\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?[^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Singleline ) if (-not $rootMatch.Success) { throw "Cannot find root package version in package-lock.json." } [ordered]@{ packageLock = $topMatch.Groups["value"].Value packageLockRoot = $rootMatch.Groups["value"].Value } } function Set-PackageLockVersions { param([string]$TargetVersion) $content = Get-Content -Raw -LiteralPath $PackageLockPath $updated = Replace-RegexGroup ` -Content $content ` -Pattern '^\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?[^"]+)"' ` -GroupName "value" ` -Value $TargetVersion ` -Label "top-level version in package-lock.json" $updated = Replace-RegexGroup ` -Content $updated ` -Pattern '"packages"\s*:\s*\{\s*""\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*"(?[^"]+)"' ` -GroupName "value" ` -Value $TargetVersion ` -Label "root package version in package-lock.json" Write-Utf8NoBomFile -Path $PackageLockPath -Value $updated } function Assert-Semver { param([string]$Value) if ($Value -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') { throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0." } foreach ($part in $Value.Split('.')) { if ([long]$part -gt 65535) { throw "Version components must be between 0 and 65535 for Windows." } } } function ConvertTo-VersionParts { param([string]$Value) Assert-Semver -Value $Value $parts = $Value.Split(".") [ordered]@{ major = [int]$parts[0] minor = [int]$parts[1] patch = [int]$parts[2] } } function Compare-Semver { param( [string]$Left, [string]$Right ) $leftParts = ConvertTo-VersionParts -Value $Left $rightParts = ConvertTo-VersionParts -Value $Right foreach ($part in @("major", "minor", "patch")) { if ($leftParts[$part] -gt $rightParts[$part]) { return 1 } if ($leftParts[$part] -lt $rightParts[$part]) { return -1 } } return 0 } function Get-NextVersion { param( [string]$Current, [string]$Kind ) $parts = ConvertTo-VersionParts -Value $Current switch ($Kind) { "major" { return "$($parts.major + 1).0.0" } "minor" { return "$($parts.major).$($parts.minor + 1).0" } "patch" { return "$($parts.major).$($parts.minor).$($parts.patch + 1)" } default { throw "Unknown bump kind '$Kind'." } } } function Get-CargoPackageVersion { $content = Get-Content -Raw -LiteralPath $CargoTomlPath $packageMatch = [regex]::Match($content, "(?ms)^\[package\]\s*(.*?)(?=^\[|\z)") if (-not $packageMatch.Success) { throw "Cannot find [package] block in $CargoTomlPath." } $versionMatch = [regex]::Match($packageMatch.Value, '(?m)^version\s*=\s*"([^"]+)"\s*$') if (-not $versionMatch.Success) { throw "Cannot find package version in $CargoTomlPath." } $versionMatch.Groups[1].Value } function Get-VersionState { $packageLock = Get-PackageLockVersions $cargoLock = Get-Content -Raw -LiteralPath $CargoLockPath $cargoMatch = [regex]::Match($cargoLock, '(?m)^name = "proxywarden"\r?\nversion = "([^"]+)"') if (-not $cargoMatch.Success) { throw 'Cannot find ProxyWarden in Cargo.lock.' } [ordered]@{ packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json") packageLock = [string]$packageLock.packageLock packageLockRoot = [string]$packageLock.packageLockRoot tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json") cargoToml = [string](Get-CargoPackageVersion) cargoLock = $cargoMatch.Groups[1].Value } } function Get-CurrentVersion { $state = Get-VersionState $versions = @(@( $state.packageJson, $state.packageLock, $state.packageLockRoot, $state.tauriConfig, $state.cargoToml, $state.cargoLock ) | Select-Object -Unique) if ($versions.Count -ne 1) { $details = $state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" } throw "Version mismatch: $($details -join ', ')." } Assert-Semver -Value $versions[0] $versions[0] } function Resolve-TargetVersion { param([string]$Current) if (-not [string]::IsNullOrWhiteSpace($Version)) { Assert-Semver -Value $Version return $Version } if (-not [string]::IsNullOrWhiteSpace($Bump)) { return Get-NextVersion -Current $Current -Kind $Bump } if ($PlanOnly) { return Get-NextVersion -Current $Current -Kind "patch" } if (-not [Environment]::UserInteractive) { throw "Specify -Version or -Bump in non-interactive mode." } $patch = Get-NextVersion -Current $Current -Kind "patch" $minor = Get-NextVersion -Current $Current -Kind "minor" $major = Get-NextVersion -Current $Current -Kind "major" Write-Host "" Write-Host "Текущая версия: $Current" Write-Host "Выбери номер или введи версию, например $patch :" Write-Host " 1) patch $patch" Write-Host " 2) minor $minor" Write-Host " 3) major $major" Write-Host " 4) другая версия" Write-Host " 5) текущая $Current (если ещё не выпущена)" $choice = Read-Host "Версия [1]" if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" } switch ($choice.Trim()) { "1" { return $patch } "2" { return $minor } "3" { return $major } "4" { $custom = Read-Host "Enter version" Assert-Semver -Value $custom return $custom } "5" { return $Current } default { Assert-Semver -Value $choice.Trim(); return $choice.Trim() } } } function Set-CargoPackageVersion { param([string]$TargetVersion) $content = Get-Content -Raw -LiteralPath $CargoTomlPath $packageMatch = [regex]::Match($content, "(?ms)^\[package\]\s*(.*?)(?=^\[|\z)") if (-not $packageMatch.Success) { throw "Cannot find [package] block in $CargoTomlPath." } $packageBlock = $packageMatch.Value $versionMatch = [regex]::Match($packageBlock, '(?m)^version\s*=\s*"(?[^"]+)"\s*$') if (-not $versionMatch.Success) { throw "Cannot update package version in $CargoTomlPath." } if ($versionMatch.Groups["value"].Value -eq $TargetVersion) { return } $valueGroup = $versionMatch.Groups["value"] $updatedBlock = $packageBlock.Remove($valueGroup.Index, $valueGroup.Length).Insert($valueGroup.Index, $TargetVersion) $updatedContent = $content.Remove($packageMatch.Index, $packageMatch.Length).Insert($packageMatch.Index, $updatedBlock) Write-Utf8NoBomFile -Path $CargoTomlPath -Value $updatedContent } function Set-ManifestVersions { param([string]$TargetVersion) Set-FirstJsonVersion -Path $PackageJsonPath -TargetVersion $TargetVersion -Label "package.json" Set-PackageLockVersions -TargetVersion $TargetVersion Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json" Set-CargoPackageVersion -TargetVersion $TargetVersion $lock = Get-Content -Raw -LiteralPath $CargoLockPath $lock = Replace-RegexGroup -Content $lock -Pattern '(?m)^name = "proxywarden"\r?\nversion = "(?[^"]+)"' -GroupName "value" -Value $TargetVersion -Label "ProxyWarden version in Cargo.lock" Write-Utf8NoBomFile -Path $CargoLockPath -Value $lock } function Get-FullPath { param([string]$Path) [System.IO.Path]::GetFullPath($Path) } function Test-IsSubPath { param( [string]$Parent, [string]$Child ) $parentFull = (Get-FullPath -Path $Parent).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar $childFull = (Get-FullPath -Path $Child).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar $childFull.StartsWith($parentFull, [System.StringComparison]::OrdinalIgnoreCase) } function Get-RelativePath { param( [string]$BasePath, [string]$Path ) $baseUri = [Uri]((Get-FullPath -Path $BasePath).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar) $pathUri = [Uri](Get-FullPath -Path $Path) [Uri]::UnescapeDataString($baseUri.MakeRelativeUri($pathUri).ToString()).Replace("/", "\") } function New-ReleaseDirectory { param([string]$TargetVersion) if ([System.IO.Path]::IsPathRooted($OutputRoot)) { $root = Get-FullPath -Path $OutputRoot } else { $root = Get-FullPath -Path (Join-Path $RepoRoot $OutputRoot) } $releaseDir = Join-Path $root "proxywarden-v$TargetVersion" if (Test-Path -LiteralPath $releaseDir) { if (-not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releaseDir. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." } if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) { throw "Refusing to replace release directory outside OutputRoot: $releaseDir" } if ($Replace) { $backupDir = "$releaseDir-replaced-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$([guid]::NewGuid().ToString('N').Substring(0, 8))" if (-not (Test-IsSubPath -Parent $root -Child $backupDir)) { throw 'Release backup must stay inside OutputRoot.' } Move-Item -LiteralPath $releaseDir -Destination $backupDir Write-Host "Предыдущая сборка сохранена: $backupDir" } else { Write-Host "Replacing existing release directory: $releaseDir" Remove-Item -LiteralPath $releaseDir -Recurse -Force } } New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null $releaseDir } function Invoke-NativeCommand { param( [string]$Name, [string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory = $RepoRoot ) Write-Host "" Write-Host "==> $Name" Push-Location $WorkingDirectory try { & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE." } } finally { Pop-Location } } function Clear-ReleaseBundleOutput { if (-not (Test-Path -LiteralPath $BundleRoot)) { return } $targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target") $bundleFull = Get-FullPath -Path $BundleRoot if ( $bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or -not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull) ) { throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull" } Write-Host "" Write-Host "Cleaning stale Tauri bundle output: $bundleFull" Remove-Item -LiteralPath $bundleFull -Recurse -Force } function Invoke-ReleaseBuild { if ($SkipBuild) { Write-Host "" Write-Host "Skipping build because -SkipBuild was provided." return } Invoke-NativeCommand -Name "Frontend types" -FilePath "node" -Arguments @("node_modules/typescript/bin/tsc", "--noEmit") if (-not $SkipTests) { Invoke-NativeCommand -Name "Frontend formatting" -FilePath "node" -Arguments @("node_modules/prettier/bin/prettier.cjs", "--check", "src/**/*.{ts,tsx,css}") Invoke-NativeCommand -Name "Frontend lint" -FilePath "node" -Arguments @("node_modules/eslint/bin/eslint.js", "src") Invoke-NativeCommand -Name "Frontend tests" -FilePath "node" -Arguments @("node_modules/vitest/vitest.mjs", "run") Invoke-NativeCommand -Name "Rust formatting" -FilePath "cargo" -Arguments @("fmt", "--all", "--", "--check") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") Invoke-NativeCommand -Name "Rust lint" -FilePath "cargo" -Arguments @("clippy", "--locked", "--all-targets", "--all-features", "--", "-D", "warnings") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test", "--locked", "--all-targets") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") } else { Write-Host "" Write-Host "Skipping Rust tests because -SkipTests was provided." } Invoke-NativeCommand -Name "Frontend build" -FilePath "node" -Arguments @("node_modules/vite/bin/vite.js", "build") Clear-ReleaseBundleOutput # Use a temporary config file: JSON command-line quoting differs between Windows PowerShell and pwsh. $config = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-build-" + [guid]::NewGuid().ToString('N') + '.json') try { Write-Utf8NoBomFile -Path $config -Value '{"build":{"beforeBuildCommand":""}}' Invoke-NativeCommand -Name "Tauri release build" -FilePath "node" -Arguments @("node_modules/@tauri-apps/cli/tauri.js", "build", "--config", $config, "--bundles", "nsis") } finally { if (Test-Path -LiteralPath $config) { Remove-Item -LiteralPath $config } } } function Invoke-ScriptCheck { param( [string]$Name, [string]$ScriptPath, [hashtable]$Parameters ) Write-Host "" Write-Host "==> $Name" $output = & $ScriptPath @Parameters $succeeded = $? $output | Write-Output if (-not $succeeded) { throw "$Name failed." } } function Invoke-ReleaseChecks { Invoke-ScriptCheck -Name "Runtime PowerShell boundary" -ScriptPath $RuntimeBoundaryCheckPath -Parameters @{ CheckOnly = $true } Invoke-ScriptCheck -Name "Offline component bundle" -ScriptPath $ComponentBundleScriptPath -Parameters @{ CheckOnly = $true } Invoke-ScriptCheck -Name "Windows smoke evidence plan" -ScriptPath $WindowsAuditScriptPath -Parameters @{ Mode = "PlanOnly" } } function Get-ArtifactVersionPattern { param([string]$TargetVersion) "(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)" } function Copy-ReleaseArtifacts { param( [string]$ReleaseDir, [string]$TargetVersion ) if (-not (Test-Path -LiteralPath $BundleRoot)) { throw "Tauri bundle output was not found: $BundleRoot" } $artifactDir = Join-Path $ReleaseDir "artifacts" $allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } | Sort-Object FullName) if ($allFiles.Count -eq 0) { throw "No release artifacts were found under $BundleRoot." } $versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion $files = @($allFiles | Where-Object { $_.Name -match $versionPattern }) $ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern }) if ($files.Count -eq 0) { $found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", " throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found" } if ($ignoredFiles.Count -gt 0) { Write-Host "" Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:" foreach ($ignored in $ignoredFiles) { Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName)) } } $copied = @() foreach ($file in $files) { $relative = Get-RelativePath -BasePath $BundleRoot -Path $file.FullName $destination = Join-Path $artifactDir $relative New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null Copy-Item -LiteralPath $file.FullName -Destination $destination -Force $copied += Get-Item -LiteralPath $destination } $copied } function Write-Checksums { param( [string]$ReleaseDir, [object[]]$Files ) if ($Files.Count -eq 0) { return $null } $artifactDir = Join-Path $ReleaseDir "artifacts" $lines = foreach ($file in $Files) { $hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 $relative = Get-RelativePath -BasePath $artifactDir -Path $file.FullName "$($hash.Hash.ToLowerInvariant()) $relative" } $checksumPath = Join-Path $ReleaseDir "SHA256SUMS.txt" Write-Utf8NoBomFile -Path $checksumPath -Value (($lines -join [Environment]::NewLine) + [Environment]::NewLine) $checksumPath } function Get-GitValue { param([string[]]$Arguments) try { $value = & git --no-optional-locks @Arguments 2>$null if ($LASTEXITCODE -eq 0) { return ($value -join [Environment]::NewLine).Trim() } } catch {} return "" } function Get-GitFailureMessage { param([string]$Operation, [int]$ExitCode, [string]$Diagnostic) $reason = if ($Diagnostic -match 'Too many authentication failures') { 'SSH-сервер отклонил слишком много попыток входа. Укажи правильный ключ и IdentitiesOnly yes для этого Git-сервера.' } elseif ($Diagnostic -match 'Permission denied \(publickey|Authentication failed|could not read Username|terminal prompts disabled') { 'Сервер Git отклонил вход. Проверь SSH-ключ или HTTPS-аутентификацию и доступ к репозиторию.' } elseif ($Diagnostic -match 'Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED') { 'Не подтверждён SSH-ключ сервера. Проверь его отпечаток перед повторным подключением.' } elseif ($Diagnostic -match 'Could not resolve|Connection timed out|Connection refused|Network is unreachable|connect to host.*Permission denied|Failed to connect') { 'Не удалось подключиться к Git-серверу. Проверь сеть/VPN, адрес и порт origin.' } elseif ($Diagnostic -match 'not found|does not appear to be a git repository') { 'Репозиторий недоступен по адресу origin. Проверь URL и права доступа.' } else { "Git не выполнил операцию $Operation (код $ExitCode)." } $details = "$Diagnostic".Trim() -replace '(https?://)[^/\s@]+@', '$1[redacted]@' -replace '(https?://[^\s?#]+)[?#][^\s]*', '$1' if ($details.Length -gt 2500) { $details = $details.Substring(0, 2500) + '...' } $next = if ($Operation -in @('ls-remote', 'fetch')) { 'Проверка origin завершилась до изменения версии, сборки, commit, tag и push. Для диагностики запусти: git ls-remote origin' } else { 'Подробности ответа Git приведены ниже.' } return "$reason`n$next`n`nОтвет Git:`n$details" } function Invoke-Git { param([string[]]$Arguments) $stderrPath = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-git-" + [guid]::NewGuid().ToString('N') + '.log') $previousPreference = $ErrorActionPreference try { # Windows PowerShell wraps redirected stderr as NativeCommandError; preserve it, # then classify by the actual exit code instead of losing the original cause. $ErrorActionPreference = 'Continue' $output = & git @Arguments 2>$stderrPath $exitCode = $LASTEXITCODE $ErrorActionPreference = $previousPreference [string]$diagnostic = '' if (Test-Path -LiteralPath $stderrPath) { $diagnostic = [string](Get-Content -Raw -LiteralPath $stderrPath) } if ($exitCode -ne 0) { throw (Get-GitFailureMessage -Operation $Arguments[0] -ExitCode $exitCode -Diagnostic $diagnostic) } if (-not [string]::IsNullOrWhiteSpace($diagnostic)) { Write-Host $diagnostic.Trim() } return ([string]($output -join "`n")).Trim() } finally { $ErrorActionPreference = $previousPreference if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath } } } function Get-SourceTree { # Snapshot tracked + non-ignored new files without touching the user's staging area. $previousIndex = $env:GIT_INDEX_FILE $index = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-index-" + [guid]::NewGuid().ToString('N')) try { $env:GIT_INDEX_FILE = $index Invoke-Git @('read-tree', 'HEAD') | Out-Null Invoke-Git @('add', '-A', '--', '.') | Out-Null return Invoke-Git @('write-tree') } finally { $env:GIT_INDEX_FILE = $previousIndex foreach ($path in @($index, "$index.lock")) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path } } } } function Get-ReleasePath { param([string]$TargetVersion) $root = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $RepoRoot $OutputRoot } return [IO.Path]::GetFullPath((Join-Path $root "proxywarden-v$TargetVersion")) } function Test-GitTag { param([string]$Tag) & git show-ref --verify --quiet "refs/tags/$Tag" if ($LASTEXITCODE -eq 0) { return $true } if ($LASTEXITCODE -ne 1) { throw "Cannot inspect local tag $Tag." } return $false } function Get-ReleaseGitContext { param([string]$TargetVersion) $branch = Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD') $headCommit = Invoke-Git @('rev-parse', 'HEAD') foreach ($marker in @('MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply')) { $path = Invoke-Git @('rev-parse', '--git-path', $marker) if (Test-Path -LiteralPath $path) { throw "Finish the active Git operation before releasing ($marker)." } } if (Invoke-Git @('diff', '--name-only', '--diff-filter=U')) { throw 'Resolve Git conflicts before releasing.' } Invoke-Git @('var', 'GIT_AUTHOR_IDENT') | Out-Null Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null $remote = Invoke-Git @('remote', 'get-url', '--push', 'origin') $tag = "v$TargetVersion" $localTag = if (Test-GitTag $tag) { Invoke-Git @('rev-parse', "refs/tags/$tag") } else { '' } if (-not $Resume -and -not $Replace -and $localTag) { throw "Tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, -Resume to retry its push, or choose another version." } $remoteTag = Invoke-Git @('ls-remote', '--refs', '--tags', 'origin', "refs/tags/$tag") if (-not $Resume -and -not $Replace -and $remoteTag) { throw "Remote tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or choose another version." } $remoteTagId = if ($remoteTag) { ($remoteTag -split '\s+')[0] } else { '' } $remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch") if ($remoteBranch) { Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null & git merge-base --is-ancestor FETCH_HEAD HEAD if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." } } return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag; replace = [bool]$Replace; previousLocalTag = $localTag; previousRemoteTag = $remoteTagId } } function Complete-ReleaseGit { param([hashtable]$Context, [string]$SourceTree, [string]$TargetVersion) $currentTree = Get-SourceTree if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Context.head -or (Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or $currentTree -ne $SourceTree) { $changed = Invoke-Git @('-c', 'core.quotepath=false', 'diff', '--name-only', $SourceTree, $currentTree) throw "Source files or HEAD changed during the build. No release commit/tag was created.`nИсходники изменились во время сборки. Повтори сборку после завершения правок.`nИзменённые файлы:`n$changed" } if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree) { Invoke-Git @('add', '-A', '--', '.') | Out-Null if ((Invoke-Git @('write-tree')) -ne $SourceTree) { throw 'Staged source changed. Rebuild before releasing.' } Invoke-Git @('commit', '-m', "Release v$TargetVersion") | Write-Host } if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree -or (Get-SourceTree) -ne $SourceTree) { throw 'A Git hook changed source files. Rebuild before tagging.' } return Invoke-Git @('rev-parse', 'HEAD') } function Push-Release { param([hashtable]$Context, [string]$Commit) if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Commit -or (Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or (Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) { throw 'HEAD, branch or origin changed before push.' } $localTag = if (Test-GitTag $Context.tag) { Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") } else { '' } if ($Context.replace -and $localTag -ne $Context.previousLocalTag -and (-not $Resume -or -not $localTag -or (Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit)) { throw 'Local version tag changed during the release. Replacement refused.' } if ($localTag) { if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { if (-not $Context.replace) { throw 'Existing tag points to another commit.' } Invoke-Git @('tag', '-a', '-f', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null } } else { Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null } $tagObject = Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") $pushArgs = @('push', '--atomic') if ($Context.replace) { # Lease only this tag, never the branch. Keep the original expectation across Resume. $pushArgs += "--force-with-lease=refs/tags/$($Context.tag):$($Context.previousRemoteTag)" } $pushArgs += @('origin', "${Commit}:refs/heads/$($Context.branch)", "${tagObject}:refs/tags/$($Context.tag)") Invoke-Git $pushArgs | Write-Host } function Resume-Release { param([string]$TargetVersion, [hashtable]$Context) $releaseDir = Get-ReleasePath $TargetVersion $manifestPath = Join-Path $releaseDir 'release-manifest.json' $manifest = Read-JsonFile $manifestPath if (-not $manifest.PSObject.Properties['gitRelease'] -or -not $manifest.gitRelease) { throw 'This folder has no completed release commit. Resume only retries a failed push; choose a new version and rebuild.' } if ($manifest.version -ne $TargetVersion -or $manifest.gitRelease.branch -ne $Context.branch -or $manifest.gitRelease.remote -ne $Context.remote -or $manifest.gitRelease.tag -ne $Context.tag -or $manifest.gitRelease.status -notin @('pending-push', 'pushed') -or $manifest.gitCommit -ne $Context.head -or (Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $manifest.gitRelease.sourceTree -or (Get-SourceTree) -ne $manifest.gitRelease.sourceTree) { throw 'This release no longer matches HEAD/source/origin. Resume refused; use a new version.' } if (@($manifest.artifacts).Count -eq 0) { throw 'No artifacts to resume.' } foreach ($artifact in $manifest.artifacts) { $path = [IO.Path]::GetFullPath((Join-Path $releaseDir $artifact.path)) if (-not (Test-IsSubPath $releaseDir $path) -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' } } if ($manifest.gitRelease.PSObject.Properties['replace'] -and $manifest.gitRelease.replace) { $Context.replace = $true $Context.previousLocalTag = $manifest.gitRelease.previousLocalTag $Context.previousRemoteTag = $manifest.gitRelease.previousRemoteTag } Push-Release -Context $Context -Commit $manifest.gitCommit $manifest.gitRelease.status = 'pushed' Write-JsonFile -Path $manifestPath -Value $manifest Write-Host "Релиз отправлен. Файлы для сайта: $releaseDir" } function Write-ReleaseMetadata { param( [string]$ReleaseDir, [string]$TargetVersion, [object[]]$Artifacts, [object]$GitRelease = $null ) $artifactDir = Join-Path $ReleaseDir "artifacts" $artifactItems = foreach ($artifact in $Artifacts) { [ordered]@{ path = Get-RelativePath -BasePath $ReleaseDir -Path $artifact.FullName sizeBytes = $artifact.Length sha256 = (Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } } $manifest = [ordered]@{ product = "ProxyWarden" version = $TargetVersion builtAt = (Get-Date).ToString("o") source = "local" gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD") gitStatus = Get-GitValue -Arguments @("status", "--short") gitRelease = $GitRelease windowsAcceptance = "not-verified-by-this-command" artifacts = @($artifactItems) } Write-JsonFile -Path (Join-Path $ReleaseDir "release-manifest.json") -Value $manifest $artifactList = if ($Artifacts.Count -gt 0) { ($Artifacts | ForEach-Object { "- " + (Get-RelativePath -BasePath $artifactDir -Path $_.FullName) }) -join [Environment]::NewLine } else { "- Build was skipped; no artifacts were copied." } $notes = @" # ProxyWarden v$TargetVersion ## Artifacts $artifactList ## Checksums See `SHA256SUMS.txt`. ## Release boundary The ProxyWarden installer contains pinned offline payloads for ProxiFyre, Windows Packet Filter, VC++ Runtime, sing-box, WinSW, and WebView2. Installing, updating, starting, stopping, or removing routing components remains an explicit user action. "@ Write-Utf8NoBomFile -Path (Join-Path $ReleaseDir "release-notes.md") -Value $notes } function New-PlanResult { param( [string]$Current, [string]$Target ) $outputRootFull = if ([System.IO.Path]::IsPathRooted($OutputRoot)) { Get-FullPath -Path $OutputRoot } else { Get-FullPath -Path (Join-Path $RepoRoot $OutputRoot) } [ordered]@{ success = $true action = "prepare-release.plan" changed = $false message = "Release plan is ready." details = [ordered]@{ currentVersion = $Current targetVersion = $Target releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target") skipTests = [bool]$SkipTests skipBuild = [bool]$SkipBuild publish = [bool]$Publish resume = [bool]$Resume replace = [bool]$Replace git = [ordered]@{ branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD') remote = 'origin' tag = "v$Target" includedChanges = Get-GitValue @('status', '--short') commitAfterSuccessfulBuild = [bool]$Publish atomicPush = [bool]$Publish replaceOnlyVersionTagWithLease = [bool]$Replace preservePreviousReleaseDirectory = [bool]$Replace } manifests = @( $PackageJsonPath, $PackageLockPath, $TauriConfigPath, $CargoTomlPath, $CargoLockPath ) commands = @( ".\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly", ".\scripts\update-component-bundle.ps1 -CheckOnly", ".\scripts\audit-windows-smoke.ps1 -Mode PlanOnly", "node: TypeScript, Prettier, ESLint, Vitest, Vite", "cargo fmt / clippy --locked / test --locked --all-targets", "node: Tauri build --bundles nsis", "if -Publish: commit source, annotated version tag, atomic branch+tag push to origin" ) } } | ConvertTo-Json -Depth 8 } try { Push-Location $RepoRoot if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' } if ($Replace -and (-not $Publish -or -not $Version -or $Bump -or $Resume)) { throw '-Replace requires -Publish -Version X.Y.Z and cannot be combined with -Resume or -Bump.' } if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' } if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' } if ($Publish -and -not $PlanOnly -and -not $Resume) { Write-Host 'В релиз войдут все изменения Git ниже (кроме игнорируемых файлов).' Write-Host 'После успешной сборки: commit, тег версии и push текущей ветки в origin.' Write-Host 'Файлы установщика останутся локально для загрузки на сайт.' Write-Host (Invoke-Git @('status', '--short')) } $currentVersion = Get-CurrentVersion $targetVersion = Resolve-TargetVersion -Current $currentVersion Assert-Semver -Value $targetVersion if ((Compare-Semver -Left $targetVersion -Right $currentVersion) -lt 0) { throw "Target version $targetVersion is lower than current version $currentVersion." } if ($PlanOnly) { New-PlanResult -Current $currentVersion -Target $targetVersion exit 0 } Write-Host "" Write-Host "Preparing ProxyWarden release $targetVersion..." Write-Host "Repository: $RepoRoot" if ($Replace) { Write-Host "Пересборка невыпущенного релиза v$targetVersion с заменой тега. Предыдущая папка будет сохранена рядом." } $gitContext = $null if ($Publish) { $gitContext = Get-ReleaseGitContext $targetVersion if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return } } $releasePath = Get-ReleasePath $targetVersion if ((Test-Path -LiteralPath $releasePath) -and -not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releasePath. Use -Version $targetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." } if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) { & git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json') if ($LASTEXITCODE -ne 0) { throw 'OutputRoot must be ignored by Git, or outside the repository.' } } if (-not $SkipBuild) { Get-Command node, cargo -ErrorAction Stop | Out-Null foreach ($cli in @('typescript/bin/tsc', 'vite/bin/vite.js', '@tauri-apps/cli/tauri.js', 'prettier/bin/prettier.cjs', 'eslint/bin/eslint.js', 'vitest/vitest.mjs')) { if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules/$cli"))) { throw 'Frontend dependencies are missing. Run npm ci once, then retry release.' } } } Invoke-ReleaseChecks Set-ManifestVersions -TargetVersion $targetVersion $afterUpdateVersion = Get-CurrentVersion if ($afterUpdateVersion -ne $targetVersion) { throw "Version update failed. Current version is $afterUpdateVersion." } $sourceTree = if ($Publish) { Get-SourceTree } else { $null } Invoke-ReleaseBuild $releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion $artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion) Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts if ($Publish) { $commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion $gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push'; replace = $gitContext.replace; previousLocalTag = $gitContext.previousLocalTag; previousRemoteTag = $gitContext.previousRemoteTag } Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease try { Push-Release -Context $gitContext -Commit $commit } catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" } $gitRelease.status = 'pushed' Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease } Write-Host "" Write-Host "Release folder is ready:" Write-Host $releaseDir Write-Host "" Write-Host 'Загрузи EXE из artifacts\nsis на сайт. SHA256SUMS.txt содержит контрольную сумму.' } catch { [Console]::Error.WriteLine("`nРелиз не завершён.`n" + $_.Exception.Message) exit 1 } finally { Pop-Location }