963 lines
38 KiB
PowerShell
963 lines
38 KiB
PowerShell
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*"(?<value>[^"]+)"')
|
|
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*"(?<value>[^"]+)"' `
|
|
-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*"(?<value>[^"]+)"',
|
|
[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*"(?<value>[^"]+)"',
|
|
[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*"(?<value>[^"]+)"' `
|
|
-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*"(?<value>[^"]+)"' `
|
|
-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*"(?<value>[^"]+)"\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 = "(?<value>[^"]+)"' -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
|
|
}
|