Release v2.0.0
CI / Windows baseline (push) Canceled after 0s

This commit is contained in:
2026-09-10 20:59:52 +03:00
parent 9c987df6e9
commit efda8eb98f
142 changed files with 68308 additions and 9333 deletions
+301 -21
View File
@@ -1,4 +1,4 @@
param(
param(
[string]$Version = "",
[ValidateSet("", "patch", "minor", "major")]
[string]$Bump = "",
@@ -6,9 +6,12 @@ param(
[switch]$SkipTests,
[switch]$SkipBuild,
[switch]$PlanOnly,
[switch]$Publish,
[switch]$Resume,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
@@ -16,7 +19,11 @@ $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(
@@ -143,9 +150,12 @@ function Set-PackageLockVersions {
function Assert-Semver {
param([string]$Value)
if ($Value -notmatch "^\d+\.\d+\.\d+$") {
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 {
@@ -206,6 +216,9 @@ function Get-CargoPackageVersion {
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")
@@ -213,6 +226,7 @@ function Get-VersionState {
packageLockRoot = [string]$packageLock.packageLockRoot
tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json")
cargoToml = [string](Get-CargoPackageVersion)
cargoLock = $cargoMatch.Groups[1].Value
}
}
@@ -223,7 +237,8 @@ function Get-CurrentVersion {
$state.packageLock,
$state.packageLockRoot,
$state.tauriConfig,
$state.cargoToml
$state.cargoToml,
$state.cargoLock
) | Select-Object -Unique)
if ($versions.Count -ne 1) {
@@ -247,23 +262,24 @@ function Resolve-TargetVersion {
return Get-NextVersion -Current $Current -Kind $Bump
}
if ($PlanOnly -or -not [Environment]::UserInteractive) {
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 version: $Current"
Write-Host "Choose release version:"
Write-Host "Текущая версия: $Current"
Write-Host "Выбери номер или введи версию, например $patch :"
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]"
Write-Host " 4) другая версия"
Write-Host " 5) текущая $Current (если ещё не выпущена)"
$choice = Read-Host "Версия [1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
@@ -277,7 +293,7 @@ function Resolve-TargetVersion {
return $custom
}
"5" { return $Current }
default { throw "Unknown selection '$choice'." }
default { Assert-Semver -Value $choice.Trim(); return $choice.Trim() }
}
}
@@ -313,6 +329,9 @@ function Set-ManifestVersions {
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 {
@@ -354,6 +373,7 @@ function New-ReleaseDirectory {
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
if (Test-Path -LiteralPath $releaseDir) {
if ($Publish -or -not $Force) { throw "Release directory already exists: $releaseDir. Use -Resume for a failed push, or choose another version." }
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
}
@@ -412,17 +432,51 @@ function Invoke-ReleaseBuild {
return
}
Invoke-NativeCommand -Name "Frontend build" -FilePath "npm" -Arguments @("run", "build")
Invoke-NativeCommand -Name "Frontend types" -FilePath "node" -Arguments @("node_modules/typescript/bin/tsc", "--noEmit")
if (-not $SkipTests) {
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
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
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
# 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 {
@@ -503,7 +557,7 @@ function Get-GitValue {
param([string[]]$Arguments)
try {
$value = & git @Arguments 2>$null
$value = & git --no-optional-locks @Arguments 2>$null
if ($LASTEXITCODE -eq 0) {
return ($value -join [Environment]::NewLine).Trim()
}
@@ -511,11 +565,176 @@ function Get-GitValue {
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"
if (-not $Resume -and (Test-GitTag $tag)) { throw "Tag $tag already exists. Use -Version $TargetVersion -Resume for a failed push, or choose another version." }
$remoteTag = Invoke-Git @('ls-remote', '--tags', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}")
if (-not $Resume -and $remoteTag) { throw "Remote tag $tag already exists. Choose another version." }
$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 }
}
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.'
}
if (Test-GitTag $Context.tag) {
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { throw 'Existing tag points to another commit.' }
} else {
Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
}
# One atomic push; never force or push unrelated tags. A failure leaves a resumable local release.
Invoke-Git @('push', '--atomic', 'origin', "${Commit}:refs/heads/$($Context.branch)", "refs/tags/$($Context.tag):refs/tags/$($Context.tag)") | 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.' }
}
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[]]$Artifacts,
[object]$GitRelease = $null
)
$artifactDir = Join-Path $ReleaseDir "artifacts"
@@ -534,6 +753,8 @@ function Write-ReleaseMetadata {
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)
}
@@ -560,7 +781,7 @@ See `SHA256SUMS.txt`.
## Release boundary
This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components.
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.
"@
@@ -590,16 +811,31 @@ function New-PlanResult {
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
skipTests = [bool]$SkipTests
skipBuild = [bool]$SkipBuild
publish = [bool]$Publish
resume = [bool]$Resume
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
}
manifests = @(
$PackageJsonPath,
$PackageLockPath,
$TauriConfigPath,
$CargoTomlPath
$CargoTomlPath,
$CargoLockPath
)
commands = @(
"npm run build",
"cd src-tauri; cargo test",
"npm run tauri -- build"
".\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
@@ -608,6 +844,15 @@ function New-PlanResult {
try {
Push-Location $RepoRoot
if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' }
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
@@ -625,12 +870,34 @@ try {
Write-Host "Preparing ProxyWarden release $targetVersion..."
Write-Host "Repository: $RepoRoot"
$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 ($Publish -or -not $Force)) {
throw "Release directory already exists: $releasePath. Use -Resume for a failed push, or choose another version."
}
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
@@ -638,11 +905,24 @@ try {
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' }
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 "Upload the files from the release folder to GitHub release v$targetVersion."
Write-Host 'Загрузи EXE из artifacts\nsis на сайт. SHA256SUMS.txt содержит контрольную сумму.'
} catch {
[Console]::Error.WriteLine("`nРелиз не завершён.`n" + $_.Exception.Message)
exit 1
} finally {
Pop-Location
}