param( [string]$Version = "", [ValidateSet("", "patch", "minor", "major")] [string]$Bump = "", [string]$OutputRoot = "releases", [switch]$SkipTests, [switch]$SkipBuild, [switch]$PlanOnly, [switch]$Force ) $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" $BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle" 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 "^\d+\.\d+\.\d+$") { throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0." } } 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 [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) } } function Get-CurrentVersion { $state = Get-VersionState $versions = @(@( $state.packageJson, $state.packageLock, $state.packageLockRoot, $state.tauriConfig, $state.cargoToml ) | 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 -or -not [Environment]::UserInteractive) { return Get-NextVersion -Current $Current -Kind "patch" } $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 version: $Current" Write-Host "Choose release version:" Write-Host " 1) patch $patch" Write-Host " 2) minor $minor" Write-Host " 3) major $major" Write-Host " 4) custom" Write-Host " 5) keep current $Current" $choice = Read-Host "Selection [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 { throw "Unknown selection '$choice'." } } } 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 } 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 (Test-IsSubPath -Parent $root -Child $releaseDir)) { throw "Refusing to remove release directory outside OutputRoot: $releaseDir" } 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 build" -FilePath "npm" -Arguments @("run", "build") if (-not $SkipTests) { Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") } else { Write-Host "" Write-Host "Skipping Rust tests because -SkipTests was provided." } Clear-ReleaseBundleOutput Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build") } 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 @Arguments 2>$null if ($LASTEXITCODE -eq 0) { return ($value -join [Environment]::NewLine).Trim() } } catch {} return "" } function Write-ReleaseMetadata { param( [string]$ReleaseDir, [string]$TargetVersion, [object[]]$Artifacts ) $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") 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 This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components. "@ 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 manifests = @( $PackageJsonPath, $PackageLockPath, $TauriConfigPath, $CargoTomlPath ) commands = @( "npm run build", "cd src-tauri; cargo test", "npm run tauri -- build" ) } } | ConvertTo-Json -Depth 8 } try { Push-Location $RepoRoot $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" Set-ManifestVersions -TargetVersion $targetVersion $afterUpdateVersion = Get-CurrentVersion if ($afterUpdateVersion -ne $targetVersion) { throw "Version update failed. Current version is $afterUpdateVersion." } 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 Write-Host "" Write-Host "Release folder is ready:" Write-Host $releaseDir Write-Host "" Write-Host "Upload the files from the release folder to GitHub release v$targetVersion." } finally { Pop-Location }