@@ -2,8 +2,9 @@ param(
|
||||
[ValidateSet("PlanOnly", "Capture")]
|
||||
[string]$Mode = "PlanOnly",
|
||||
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
|
||||
[string]$ProxiFyreRoot = "C:\Tools\ProxiFyre",
|
||||
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box",
|
||||
[string]$AppRoot = "C:\Program Files\ProxyWarden",
|
||||
[string]$ProxiFyreRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
|
||||
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\components\sing-box",
|
||||
[string]$ForeignServiceName = "",
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
@@ -110,10 +111,35 @@ function Get-SecretFindingCategories {
|
||||
return $findings
|
||||
}
|
||||
|
||||
function Get-InternalStateEvidence {
|
||||
param([string]$Root)
|
||||
|
||||
$categories = [ordered]@{
|
||||
cutoverJournal = ".proxywarden-cutover"
|
||||
cutoverQuarantine = ".proxywarden-quarantine"
|
||||
packageStaging = ".proxywarden-package-staging"
|
||||
privilegedJobs = ".proxywarden-privileged-jobs"
|
||||
serviceLogs = ".proxywarden-service-logs"
|
||||
singBoxCleanupTombstone = ".proxywarden-sing-box-cleanup"
|
||||
}
|
||||
|
||||
$result = @()
|
||||
foreach ($entry in $categories.GetEnumerator()) {
|
||||
$path = Join-Path $Root $entry.Value
|
||||
$item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
|
||||
$result += [ordered]@{
|
||||
category = $entry.Key
|
||||
present = $null -ne $item
|
||||
itemType = if ($null -eq $item) { $null } elseif ($item.PSIsContainer) { "directory" } else { "file" }
|
||||
}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
try {
|
||||
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service'
|
||||
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" -service'
|
||||
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
|
||||
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) {
|
||||
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) {
|
||||
throw "Quoted service PathName ownership self-test failed."
|
||||
}
|
||||
|
||||
@@ -122,11 +148,12 @@ try {
|
||||
serviceNames = @("ProxiFyreService", "ProxyWardenSingBox")
|
||||
foreignServiceName = $ForeignServiceName
|
||||
roots = [ordered]@{
|
||||
app = [IO.Path]::GetFullPath($AppRoot)
|
||||
data = [IO.Path]::GetFullPath($DataRoot)
|
||||
proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot)
|
||||
singbox = [IO.Path]::GetFullPath($SingBoxRoot)
|
||||
}
|
||||
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan")
|
||||
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan", "internal-state-presence-only")
|
||||
}
|
||||
|
||||
if ($Mode -eq "PlanOnly") {
|
||||
@@ -167,6 +194,7 @@ try {
|
||||
ownership = $ownership
|
||||
files = @(Get-FileEvidence -Root $DataRoot)
|
||||
secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot)
|
||||
internalState = @(Get-InternalStateEvidence -Root $AppRoot)
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8
|
||||
|
||||
@@ -175,6 +203,7 @@ try {
|
||||
serviceCount = @($services | Where-Object found).Count
|
||||
fileCount = @($report.files).Count
|
||||
secretFindingCount = @($report.secretFindingCategories).Count
|
||||
internalStateCategoryCount = @($report.internalState).Count
|
||||
}
|
||||
} catch {
|
||||
New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
$AllowedPowerShellFiles = @(
|
||||
"scripts/audit-windows-smoke.ps1",
|
||||
"scripts/check-runtime-powershell-boundary.ps1",
|
||||
"scripts/prepare-release.ps1",
|
||||
"scripts/update-component-bundle.ps1"
|
||||
)
|
||||
$ExpectedNsisFlags = @(
|
||||
"--nsis-uninstall-managed",
|
||||
"--nsis-verify-upgrade"
|
||||
)
|
||||
$IgnoredPathPattern = '^(?:\.git|node_modules|dist|releases|src-tauri/target)(?:/|$)'
|
||||
|
||||
function Get-RelativeRepoPath {
|
||||
param([string]$Path)
|
||||
|
||||
$rootUri = [Uri]($RepoRoot.TrimEnd("\", "/") + [IO.Path]::DirectorySeparatorChar)
|
||||
$pathUri = [Uri][IO.Path]::GetFullPath($Path)
|
||||
[Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace("\", "/")
|
||||
}
|
||||
|
||||
function New-Violation {
|
||||
param(
|
||||
[string]$Rule,
|
||||
[string]$Path,
|
||||
[string]$Message,
|
||||
[int]$Line = 0
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
rule = $Rule
|
||||
path = $Path
|
||||
line = $Line
|
||||
message = $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ProductionLines {
|
||||
param([string]$Path)
|
||||
|
||||
$lines = @(Get-Content -LiteralPath $Path)
|
||||
for ($index = 0; $index -lt $lines.Count; $index++) {
|
||||
if ($lines[$index] -match '^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]') {
|
||||
if ($index -eq 0) { return @() }
|
||||
return @($lines[0..($index - 1)])
|
||||
}
|
||||
}
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Write-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Message,
|
||||
[object[]]$Violations,
|
||||
[int]$PowerShellFileCount,
|
||||
[int]$ProductionFileCount,
|
||||
[string[]]$ObservedNsisFlags
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
action = "runtime-powershell-boundary.check"
|
||||
changed = $false
|
||||
message = $Message
|
||||
details = [ordered]@{
|
||||
allowlistedPowerShellFiles = $AllowedPowerShellFiles
|
||||
scannedPowerShellFileCount = $PowerShellFileCount
|
||||
scannedProductionFileCount = $ProductionFileCount
|
||||
expectedNsisFlags = $ExpectedNsisFlags
|
||||
observedNsisFlags = $ObservedNsisFlags
|
||||
violations = $Violations
|
||||
}
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
|
||||
$violations = New-Object System.Collections.Generic.List[object]
|
||||
$powerShellFileCount = 0
|
||||
$productionFileCount = 0
|
||||
$observedNsisFlags = @()
|
||||
|
||||
try {
|
||||
if (-not $CheckOnly) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "check-only-required" `
|
||||
-Path "scripts/check-runtime-powershell-boundary.ps1" `
|
||||
-Message "Invoke this read-only boundary as -CheckOnly."))
|
||||
}
|
||||
|
||||
$powerShellFiles = @(
|
||||
Get-ChildItem -LiteralPath $RepoRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in @(".ps1", ".psm1", ".psd1") } |
|
||||
ForEach-Object {
|
||||
[ordered]@{
|
||||
fullPath = $_.FullName
|
||||
relativePath = Get-RelativeRepoPath -Path $_.FullName
|
||||
}
|
||||
} |
|
||||
Where-Object { $_.relativePath -notmatch $IgnoredPathPattern } |
|
||||
Sort-Object relativePath
|
||||
)
|
||||
$powerShellFileCount = $powerShellFiles.Count
|
||||
|
||||
foreach ($file in $powerShellFiles) {
|
||||
if ($file.relativePath -notin $AllowedPowerShellFiles) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "unexpected-powershell-file" `
|
||||
-Path $file.relativePath `
|
||||
-Message "PowerShell is allowed only for the exact build/release/QA allowlist."))
|
||||
}
|
||||
}
|
||||
foreach ($allowedPath in $AllowedPowerShellFiles) {
|
||||
if ($allowedPath -notin $powerShellFiles.relativePath) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "missing-allowlisted-tool" `
|
||||
-Path $allowedPath `
|
||||
-Message "Required build/release/QA tool is missing."))
|
||||
}
|
||||
}
|
||||
|
||||
$forbiddenRuntimeFiles = @(
|
||||
"src-tauri/src/elevated_scripts.rs",
|
||||
"src-tauri/src/helper.rs",
|
||||
"src-tauri/src/powershell.rs",
|
||||
"src-tauri/src/proxifyre_scripts.rs",
|
||||
"src-tauri/bundled/cleanup/uninstall-managed-components.ps1"
|
||||
)
|
||||
foreach ($relativePath in $forbiddenRuntimeFiles) {
|
||||
if (Test-Path -LiteralPath (Join-Path $RepoRoot $relativePath.Replace("/", "\"))) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "legacy-runtime-file" `
|
||||
-Path $relativePath `
|
||||
-Message "Legacy runtime PowerShell owner must be deleted after the native cutover."))
|
||||
}
|
||||
}
|
||||
|
||||
$tauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
|
||||
if ((Get-Content -LiteralPath $tauriConfigPath -Raw) -match '(?i)bundled[\\/]cleanup') {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "bundled-cleanup-resource" `
|
||||
-Path "src-tauri/tauri.conf.json" `
|
||||
-Message "The installer must not package the displaced PowerShell cleanup resource."))
|
||||
}
|
||||
|
||||
$productionFiles = @(
|
||||
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\src") -Recurse -File -Filter "*.rs"
|
||||
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\bundled\installer-hooks") -Recurse -File | Where-Object { $_.Extension -in @(".nsh", ".nsi") }
|
||||
)
|
||||
$productionFileCount = $productionFiles.Count
|
||||
$rules = @(
|
||||
[ordered]@{ name = "powershell-process"; pattern = '(?i)(?:command_no_window|Command::new).*\b(?:powershell|pwsh)(?:\.exe)?\b' },
|
||||
[ordered]@{ name = "powershell-command-line"; pattern = '(?i)\b(?:powershell|pwsh)(?:\.exe)?\b\s+-[A-Za-z]' },
|
||||
[ordered]@{ name = "powershell-policy-bypass"; pattern = '(?i)-ExecutionPolicy\b' },
|
||||
[ordered]@{ name = "powershell-script-path"; pattern = '(?i)\.ps1\b' },
|
||||
[ordered]@{ name = "powershell-runtime-helper"; pattern = '(?i)\b(?:run|write)_powershell_(?:command|file|script)\b' },
|
||||
[ordered]@{ name = "legacy-module-declaration"; pattern = '(?i)\b(?:pub\s+)?mod\s+(?:elevated_scripts|helper|powershell|proxifyre_scripts)\s*;' },
|
||||
[ordered]@{ name = "legacy-module-reexport"; pattern = '(?i)\bpub\s+use\s+crate::(?:elevated_scripts|helper|powershell|proxifyre_scripts)\b' }
|
||||
)
|
||||
|
||||
$productionTextParts = New-Object System.Collections.Generic.List[string]
|
||||
$rustTextParts = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $productionFiles) {
|
||||
$relativePath = Get-RelativeRepoPath -Path $file.FullName
|
||||
$lines = @(Get-ProductionLines -Path $file.FullName)
|
||||
for ($index = 0; $index -lt $lines.Count; $index++) {
|
||||
$line = [string]$lines[$index]
|
||||
[void]$productionTextParts.Add($line)
|
||||
if ($file.Extension -ieq ".rs") {
|
||||
[void]$rustTextParts.Add($line)
|
||||
}
|
||||
foreach ($rule in $rules) {
|
||||
if ($line -match $rule.pattern) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule $rule.name `
|
||||
-Path $relativePath `
|
||||
-Line ($index + 1) `
|
||||
-Message "Production code still contains a PowerShell runtime boundary."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$productionText = $productionTextParts -join "`n"
|
||||
$rustText = $rustTextParts -join "`n"
|
||||
$observedNsisFlags = @(
|
||||
[regex]::Matches($productionText, '--nsis-[a-z0-9-]+', [Text.RegularExpressions.RegexOptions]::IgnoreCase) |
|
||||
ForEach-Object { $_.Value.ToLowerInvariant() } |
|
||||
Sort-Object -Unique
|
||||
)
|
||||
foreach ($flag in $ExpectedNsisFlags) {
|
||||
if (-not $rustText.Contains($flag)) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "missing-nsis-runtime-mode" `
|
||||
-Path "src-tauri/src" `
|
||||
-Message "Rust early-mode parser is missing fixed NSIS mode: $flag"))
|
||||
}
|
||||
}
|
||||
foreach ($flag in $observedNsisFlags) {
|
||||
if ($flag -notin $ExpectedNsisFlags) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "unexpected-nsis-mode" `
|
||||
-Path "src-tauri" `
|
||||
-Message "Unexpected reserved NSIS early mode: $flag"))
|
||||
}
|
||||
}
|
||||
|
||||
$hookPath = Join-Path $RepoRoot "src-tauri\bundled\installer-hooks\proxywarden-hooks.nsh"
|
||||
$hookText = Get-Content -LiteralPath $hookPath -Raw
|
||||
foreach ($flag in $ExpectedNsisFlags) {
|
||||
if (-not $hookText.Contains($flag)) {
|
||||
[void]$violations.Add((New-Violation `
|
||||
-Rule "missing-nsis-hook-mode" `
|
||||
-Path "src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh" `
|
||||
-Message "Installer hook does not call fixed early mode: $flag"))
|
||||
}
|
||||
}
|
||||
|
||||
$success = $violations.Count -eq 0
|
||||
$message = if ($success) {
|
||||
"Runtime PowerShell boundary is clean."
|
||||
} else {
|
||||
"Runtime PowerShell boundary has $($violations.Count) violation(s)."
|
||||
}
|
||||
Write-Result `
|
||||
-Success $success `
|
||||
-Message $message `
|
||||
-Violations $violations.ToArray() `
|
||||
-PowerShellFileCount $powerShellFileCount `
|
||||
-ProductionFileCount $productionFileCount `
|
||||
-ObservedNsisFlags $observedNsisFlags
|
||||
if (-not $success) { exit 1 }
|
||||
} catch {
|
||||
$failure = New-Violation `
|
||||
-Rule "checker-error" `
|
||||
-Path "scripts/check-runtime-powershell-boundary.ps1" `
|
||||
-Message $_.Exception.Message
|
||||
Write-Result `
|
||||
-Success $false `
|
||||
-Message "Runtime PowerShell boundary check could not complete." `
|
||||
-Violations @($failure) `
|
||||
-PowerShellFileCount $powerShellFileCount `
|
||||
-ProductionFileCount $productionFileCount `
|
||||
-ObservedNsisFlags $observedNsisFlags
|
||||
exit 1
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
param(
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp",
|
||||
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function New-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Action,
|
||||
[bool]$Changed,
|
||||
[string]$Message,
|
||||
[hashtable]$Details = @{}
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
action = $Action
|
||||
changed = $Changed
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 6
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$details = @{
|
||||
installRoot = $InstallRoot
|
||||
dataRoot = $DataRoot
|
||||
planOnly = [bool]$PlanOnly
|
||||
}
|
||||
|
||||
if ($PlanOnly) {
|
||||
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-IsAdministrator)) {
|
||||
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changed = $false
|
||||
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
|
||||
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
|
||||
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
|
||||
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
|
||||
|
||||
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
|
||||
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
|
||||
ConvertTo-Json -Depth 4 |
|
||||
Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||
$changed = $true
|
||||
}
|
||||
|
||||
$details.markerPath = $markerPath
|
||||
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
|
||||
} catch {
|
||||
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
param(
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
|
||||
[string]$PackagePath = "",
|
||||
[string]$ServiceName = "ProxiFyreService",
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function New-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Action,
|
||||
[bool]$Changed,
|
||||
[string]$Message,
|
||||
[hashtable]$Details = @{}
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
action = $Action
|
||||
changed = $Changed
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 6
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Backup-File {
|
||||
param([string]$Path)
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
$backup = "$Path.bak"
|
||||
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||
return $backup
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$details = @{
|
||||
installRoot = $InstallRoot
|
||||
packagePath = $PackagePath
|
||||
serviceName = $ServiceName
|
||||
planOnly = [bool]$PlanOnly
|
||||
}
|
||||
|
||||
if ($PlanOnly) {
|
||||
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-IsAdministrator)) {
|
||||
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
|
||||
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
|
||||
exit 2
|
||||
}
|
||||
|
||||
$changed = $false
|
||||
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
$changed = $true
|
||||
}
|
||||
|
||||
$configPath = Join-Path $InstallRoot "app-config.json"
|
||||
$backupPath = Backup-File -Path $configPath
|
||||
if ($backupPath) {
|
||||
$details.backupPath = $backupPath
|
||||
}
|
||||
|
||||
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
|
||||
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||
@{
|
||||
component = "proxyfier"
|
||||
packagePath = $PackagePath
|
||||
serviceName = $ServiceName
|
||||
installedAt = (Get-Date).ToString("o")
|
||||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||
$changed = $true
|
||||
}
|
||||
|
||||
$details.markerPath = $markerPath
|
||||
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
|
||||
} catch {
|
||||
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
param(
|
||||
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box",
|
||||
[string]$ServiceName = "ProxyWardenSingBox",
|
||||
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Force,
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
|
||||
$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest"
|
||||
$WrapperFile = "$ServiceName.exe"
|
||||
$ConfigFile = "config.json"
|
||||
|
||||
function New-Result {
|
||||
param(
|
||||
[bool]$Success,
|
||||
[string]$Action,
|
||||
[bool]$Changed,
|
||||
[string]$Message,
|
||||
[hashtable]$Details = @{}
|
||||
)
|
||||
|
||||
[ordered]@{
|
||||
success = $Success
|
||||
action = $Action
|
||||
changed = $Changed
|
||||
message = $Message
|
||||
details = $Details
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-NativeArchitecture {
|
||||
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
if ($null -ne $processor -and $processor.Architecture -eq 12) { return "arm64" }
|
||||
if ([Environment]::Is64BitOperatingSystem) { return "amd64" }
|
||||
return "386"
|
||||
}
|
||||
|
||||
function Get-WinSwArchitecture {
|
||||
param([string]$Arch)
|
||||
if ($Arch -eq "arm64") { return "arm64" }
|
||||
if ($Arch -eq "386") { return "x86" }
|
||||
return "x64"
|
||||
}
|
||||
|
||||
function Invoke-Download {
|
||||
param([string]$Uri, [string]$Path)
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" }
|
||||
}
|
||||
|
||||
function Select-Asset {
|
||||
param(
|
||||
[object[]]$Assets,
|
||||
[string]$Pattern,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
|
||||
if ($null -eq $asset) {
|
||||
throw "Не найден release asset для $Label по шаблону $Pattern."
|
||||
}
|
||||
return $asset
|
||||
}
|
||||
|
||||
function Test-SafeInstallRoot {
|
||||
param([string]$Path)
|
||||
$full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
|
||||
$leaf = Split-Path -Leaf $full
|
||||
$parent = Split-Path -Parent $full
|
||||
if ($leaf -ne "sing-box") { return $false }
|
||||
return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$"
|
||||
}
|
||||
|
||||
function Backup-File {
|
||||
param([string]$Path)
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
$backup = "$Path.bak"
|
||||
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||
return $backup
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-Utf8NoBomFile {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$encoding = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
|
||||
}
|
||||
|
||||
function Write-WinSwConfig {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$xmlPath = Join-Path $Root "$Name.xml"
|
||||
$logDir = Join-Path $Root "logs"
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
$xml = @"
|
||||
<service>
|
||||
<id>$Name</id>
|
||||
<name>ProxyWarden Local sing-box</name>
|
||||
<description>Local sing-box runtime managed by ProxyWarden.</description>
|
||||
<executable>%BASE%\sing-box.exe</executable>
|
||||
<arguments>run -c "%BASE%\config.json"</arguments>
|
||||
<logpath>%BASE%\logs</logpath>
|
||||
<log mode="roll-by-size">
|
||||
<sizeThreshold>10485760</sizeThreshold>
|
||||
<keepFiles>4</keepFiles>
|
||||
</log>
|
||||
<onfailure action="restart" delay="5 sec"/>
|
||||
</service>
|
||||
"@
|
||||
Write-Utf8NoBomFile -Path $xmlPath -Value $xml
|
||||
return $xmlPath
|
||||
}
|
||||
|
||||
function Stop-And-Uninstall-Service {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$wrapper = Join-Path $Root "$Name.exe"
|
||||
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service -and $service.Status -ne "Stopped") {
|
||||
Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
|
||||
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) {
|
||||
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $wrapper) {
|
||||
Push-Location $Root
|
||||
try { & $wrapper uninstall | Out-Null } finally { Pop-Location }
|
||||
}
|
||||
|
||||
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) {
|
||||
sc.exe delete $Name | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
|
||||
$details = @{
|
||||
installRoot = $installRootFull
|
||||
serviceName = $ServiceName
|
||||
configSource = $ConfigSource
|
||||
singboxReleaseApi = $SingBoxReleaseApi
|
||||
winswReleaseApi = $WinSwReleaseApi
|
||||
planOnly = [bool]$PlanOnly
|
||||
uninstall = [bool]$Uninstall
|
||||
}
|
||||
|
||||
if ($PlanOnly) {
|
||||
$details.items = @(
|
||||
@{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") },
|
||||
@{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) },
|
||||
@{ id = "windows-service"; name = $ServiceName; target = "Windows Service" },
|
||||
@{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) }
|
||||
)
|
||||
New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-IsAdministrator)) {
|
||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
|
||||
New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details
|
||||
exit 2
|
||||
}
|
||||
|
||||
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
|
||||
if (Test-Path -LiteralPath $installRootFull) {
|
||||
Remove-Item -LiteralPath $installRootFull -Recurse -Force
|
||||
}
|
||||
New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details
|
||||
exit 0
|
||||
}
|
||||
|
||||
$changed = $false
|
||||
New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null
|
||||
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N"))
|
||||
$extractDir = Join-Path $workDir "extract"
|
||||
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
$arch = Get-NativeArchitecture
|
||||
$winswArch = Get-WinSwArchitecture -Arch $arch
|
||||
$details.architecture = $arch
|
||||
$details.winswArchitecture = $winswArch
|
||||
|
||||
$singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
|
||||
$singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box"
|
||||
$singboxZip = Join-Path $workDir $singboxAsset.name
|
||||
Invoke-Download $singboxAsset.browser_download_url $singboxZip
|
||||
Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force
|
||||
$singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1
|
||||
if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." }
|
||||
Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force
|
||||
$changed = $true
|
||||
|
||||
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
|
||||
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
|
||||
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
|
||||
$changed = $true
|
||||
|
||||
$configTarget = Join-Path $installRootFull $ConfigFile
|
||||
$backupPath = Backup-File -Path $configTarget
|
||||
if ($backupPath) { $details.backupPath = $backupPath }
|
||||
if (Test-Path -LiteralPath $ConfigSource) {
|
||||
Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force
|
||||
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
|
||||
Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}'
|
||||
}
|
||||
|
||||
$xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName
|
||||
$details.configPath = $configTarget
|
||||
$details.wrapperConfigPath = $xmlPath
|
||||
|
||||
if ($Force) {
|
||||
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
|
||||
}
|
||||
|
||||
Push-Location $installRootFull
|
||||
try {
|
||||
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {
|
||||
& ".\$WrapperFile" install
|
||||
if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." }
|
||||
$changed = $true
|
||||
}
|
||||
& ".\$WrapperFile" start
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Start-Service -Name $ServiceName -ErrorAction Stop
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $workDir) {
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
|
||||
} catch {
|
||||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "prepare-release.ps1"),
|
||||
"utf8",
|
||||
).replace(/^\ufeff/, "");
|
||||
const entry = source.lastIndexOf("try {\n Push-Location $RepoRoot");
|
||||
const crlfEntry = source.lastIndexOf("try {\r\n Push-Location $RepoRoot");
|
||||
const entryOffset = Math.max(entry, crlfEntry);
|
||||
assert.ok(entryOffset > 0);
|
||||
|
||||
function run(cwd, command, args, ok = true) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (ok)
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${command}: ${result.stdout}\n${result.stderr}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function fixture(t, build = "") {
|
||||
const root = mkdtempSync(join(tmpdir(), "proxywarden-release-test-"));
|
||||
t.after(() => {
|
||||
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep));
|
||||
assert.ok(root.includes("proxywarden-release-test-"));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const repo = join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const write = (path, text) => {
|
||||
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
||||
writeFileSync(join(repo, path), text);
|
||||
};
|
||||
const git = (...args) => run(repo, "git", args).stdout.trim();
|
||||
write("package.json", '{"name":"proxywarden","version":"1.2.0"}\n');
|
||||
write(
|
||||
"package-lock.json",
|
||||
'{"name":"proxywarden","version":"1.2.0","packages":{"":{"name":"proxywarden","version":"1.2.0"}}}\n',
|
||||
);
|
||||
write("src-tauri/tauri.conf.json", '{"version":"1.2.0"}\n');
|
||||
write(
|
||||
"src-tauri/Cargo.toml",
|
||||
'[package]\nname = "proxywarden"\nversion = "1.2.0"\n',
|
||||
);
|
||||
write(
|
||||
"src-tauri/Cargo.lock",
|
||||
'[[package]]\nname = "proxywarden"\nversion = "1.2.0"\n',
|
||||
);
|
||||
write(".gitignore", "node_modules/\nsrc-tauri/target/\nreleases/\n");
|
||||
for (const cli of [
|
||||
"typescript/bin/tsc",
|
||||
"vite/bin/vite.js",
|
||||
"@tauri-apps/cli/tauri.js",
|
||||
"prettier/bin/prettier.cjs",
|
||||
"eslint/bin/eslint.js",
|
||||
"vitest/vitest.mjs",
|
||||
])
|
||||
write(`node_modules/${cli}`, "fixture");
|
||||
// Replace only expensive checks/build in this isolated copy. Git/version/artifact/push code is real.
|
||||
const stub = `
|
||||
function Invoke-ReleaseChecks {}
|
||||
function Invoke-ReleaseBuild {
|
||||
${build}
|
||||
$output = Join-Path $BundleRoot 'nsis'
|
||||
New-Item -ItemType Directory -Path $output -Force | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $output "ProxyWarden_$($targetVersion)_x64-setup.exe"), 'test artifact')
|
||||
}
|
||||
`;
|
||||
write(
|
||||
"scripts/prepare-release.ps1",
|
||||
"\ufeff" + source.slice(0, entryOffset) + stub + source.slice(entryOffset),
|
||||
);
|
||||
git("init", "-b", "master");
|
||||
git("config", "user.name", "Release Test");
|
||||
git("config", "user.email", "release-test@example.invalid");
|
||||
git("config", "core.autocrlf", "false");
|
||||
git("add", ".");
|
||||
git("commit", "-m", "initial");
|
||||
const remote = join(root, "origin.git");
|
||||
run(root, "git", ["init", "--bare", remote]);
|
||||
git("remote", "add", "origin", remote);
|
||||
git("push", "-u", "origin", "master");
|
||||
const release = (...args) =>
|
||||
run(
|
||||
repo,
|
||||
"pwsh",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
"scripts/prepare-release.ps1",
|
||||
"-Publish",
|
||||
...args,
|
||||
],
|
||||
false,
|
||||
);
|
||||
const manifest = () =>
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(repo, "releases/proxywarden-v1.2.1/release-manifest.json"),
|
||||
"utf8",
|
||||
).replace(/^\ufeff/, ""),
|
||||
);
|
||||
return { root, repo, remote, git, write, release, manifest };
|
||||
}
|
||||
|
||||
test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => {
|
||||
const f = fixture(t);
|
||||
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
|
||||
const before = f.git("status", "--porcelain");
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
const result = f.release("-PlanOnly");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const plan = JSON.parse(result.stdout);
|
||||
assert.equal(plan.changed, false);
|
||||
assert.equal(plan.details.targetVersion, "1.2.1");
|
||||
assert.equal(f.git("status", "--porcelain"), before);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
});
|
||||
|
||||
test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => {
|
||||
const f = fixture(t);
|
||||
f.write("feature.txt", "new feature");
|
||||
const result = f.release("-Version", "1.2.1");
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
assert.equal(f.git("rev-parse", "v1.2.1^{commit}"), head);
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/heads/master").split(/\s/)[0],
|
||||
head,
|
||||
);
|
||||
assert.equal(f.git("status", "--porcelain"), "");
|
||||
assert.equal(f.manifest().gitCommit, head);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
assert.equal(f.manifest().artifacts.length, 1);
|
||||
assert.match(
|
||||
readFileSync(join(f.repo, "src-tauri/Cargo.lock"), "utf8"),
|
||||
/version = "1.2.1"/,
|
||||
);
|
||||
const repeat = f.release("-Version", "1.2.1");
|
||||
assert.notEqual(repeat.status, 0);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
});
|
||||
|
||||
test("failed build creates no commit/tag/push and preserves existing staging", (t) => {
|
||||
const f = fixture(t, "throw 'Synthetic build failure'");
|
||||
f.write("staged.txt", "staged");
|
||||
f.git("add", "staged.txt");
|
||||
const index = f.git("write-tree"),
|
||||
head = f.git("rev-parse", "HEAD");
|
||||
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
|
||||
assert.equal(f.git("write-tree"), index);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
assert.equal(f.git("tag", "--list"), "");
|
||||
});
|
||||
|
||||
test("source edit during build refuses to tag an artifact from another tree", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
"[IO.File]::WriteAllText((Join-Path $RepoRoot 'concurrent.txt'), 'changed during build')",
|
||||
);
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
const result = f.release("-Version", "1.2.1");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /changed during the build/);
|
||||
assert.match(result.stderr, /concurrent\.txt/);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
assert.equal(f.git("tag", "--list"), "");
|
||||
});
|
||||
|
||||
test("failed atomic push keeps artifacts and resumes without rebuilding", (t) => {
|
||||
const f = fixture(t);
|
||||
const hook = join(f.remote, "hooks/pre-receive");
|
||||
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
|
||||
const before = f.git("ls-remote", "origin", "refs/heads/master");
|
||||
const failed = f.release("-Version", "1.2.1");
|
||||
assert.notEqual(failed.status, 0);
|
||||
assert.equal(f.manifest().gitRelease.status, "pending-push");
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), before);
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), "");
|
||||
const commit = f.git("rev-parse", "HEAD");
|
||||
rmSync(hook);
|
||||
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), commit);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
f.write(
|
||||
"releases/proxywarden-v1.2.1/artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
|
||||
"tampered",
|
||||
);
|
||||
assert.notEqual(f.release("-Version", "1.2.1", "-Resume").status, 0);
|
||||
});
|
||||
|
||||
test("remote-only version tag and diverged branch are refused before version edits", (t) => {
|
||||
const f = fixture(t);
|
||||
f.git("tag", "v1.2.1");
|
||||
f.git("push", "origin", "refs/tags/v1.2.1");
|
||||
f.git("tag", "-d", "v1.2.1");
|
||||
const version = readFileSync(join(f.repo, "package.json"), "utf8");
|
||||
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
|
||||
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
||||
const clone = join(f.root, "other");
|
||||
run(f.root, "git", ["clone", "--branch", "master", f.remote, clone]);
|
||||
run(clone, "git", ["config", "user.name", "Other"]);
|
||||
run(clone, "git", ["config", "user.email", "other@example.invalid"]);
|
||||
writeFileSync(join(clone, "remote-change.txt"), "remote");
|
||||
run(clone, "git", ["add", "."]);
|
||||
run(clone, "git", ["commit", "-m", "remote change"]);
|
||||
run(clone, "git", ["push"]);
|
||||
const result = f.release("-Version", "1.2.2");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Integrate them before releasing/);
|
||||
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
||||
});
|
||||
|
||||
test("invalid Windows versions and mismatched Cargo.lock fail without mutations", (t) => {
|
||||
const f = fixture(t);
|
||||
for (const version of ["01.2.3", "1.2.65536", "1.2.3-rc.1"])
|
||||
assert.notEqual(f.release("-Version", version, "-PlanOnly").status, 0);
|
||||
f.write(
|
||||
"src-tauri/Cargo.lock",
|
||||
'[[package]]\nname = "proxywarden"\nversion = "0.0.0"\n',
|
||||
);
|
||||
const result = f.release("-PlanOnly");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Version mismatch/);
|
||||
});
|
||||
|
||||
test("unreachable origin reports the Git cause and diagnostic command without a PowerShell stack", (t) => {
|
||||
const f = fixture(t);
|
||||
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
const version = readFileSync(join(f.repo, "package.json"), "utf8");
|
||||
const result = f.release("-Version", "1.2.1");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /git ls-remote origin/);
|
||||
assert.match(result.stderr, /does not appear to be a git repository/);
|
||||
assert.doesNotMatch(
|
||||
result.stderr,
|
||||
/prepare-release\.ps1:\d|ScriptStackTrace|Line \|/,
|
||||
);
|
||||
assert.equal(f.git("rev-parse", "HEAD"), head);
|
||||
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
||||
});
|
||||
+301
-21
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
param(
|
||||
[string]$OutputDir = (Join-Path $PSScriptRoot '..\src-tauri\bundled\proxifyre'),
|
||||
[ValidateSet('x64', 'x86', 'ARM64')]
|
||||
[string[]]$Architectures = @('x64'),
|
||||
[switch]$SkipVcRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Invoke-JsonApi([string]$Uri) {
|
||||
Invoke-RestMethod -Uri $Uri -Headers @{
|
||||
'User-Agent' = 'proxywarden-bundle-updater'
|
||||
'Accept' = 'application/vnd.github+json'
|
||||
} -TimeoutSec 60 -MaximumRedirection 10
|
||||
}
|
||||
|
||||
function Invoke-FileDownload([string]$Uri, [string]$Path) {
|
||||
$partialPath = "$Path.part"
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $partialPath -Headers @{
|
||||
'User-Agent' = 'proxywarden-bundle-updater'
|
||||
'Accept' = 'application/octet-stream,*/*'
|
||||
} -TimeoutSec 240 -MaximumRedirection 10
|
||||
} catch {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $partialPath
|
||||
if ($item.Length -le 0) {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
throw "Downloaded file is empty: $Uri"
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $partialPath -Destination $Path -Force
|
||||
}
|
||||
|
||||
function Select-ReleaseAsset($Release, [string]$Pattern, [string]$Label) {
|
||||
$asset = $Release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
|
||||
if ($null -eq $asset) {
|
||||
throw "No asset found for $Label using pattern $Pattern"
|
||||
}
|
||||
|
||||
$asset
|
||||
}
|
||||
|
||||
function Save-Asset([string]$Id, [string]$Name, [string]$Url, [string]$ExpectedDigest = '') {
|
||||
$path = Join-Path $OutputDir $Name
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$existing = Get-Item -LiteralPath $path
|
||||
if ($existing.Length -gt 0) {
|
||||
$existingHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$expectedHash = ''
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
|
||||
$expectedHash = $Matches[1].ToLowerInvariant()
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($expectedHash) -or $existingHash -eq $expectedHash) {
|
||||
Write-Host "Using existing $Name"
|
||||
return [PSCustomObject]@{
|
||||
id = $Id
|
||||
name = $Name
|
||||
sha256 = $existingHash
|
||||
size = $existing.Length
|
||||
sourceUrl = $Url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Downloading $Name"
|
||||
Invoke-FileDownload $Url $path
|
||||
|
||||
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
|
||||
$expected = $Matches[1].ToLowerInvariant()
|
||||
if ($hash -ne $expected) {
|
||||
throw "SHA256 mismatch for $Name. Expected $expected, got $hash."
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
id = $Id
|
||||
name = $Name
|
||||
sha256 = $hash
|
||||
size = (Get-Item -LiteralPath $path).Length
|
||||
sourceUrl = $Url
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||
New-Item -ItemType Directory -Force -Path $resolvedOutputDir | Out-Null
|
||||
$OutputDir = $resolvedOutputDir
|
||||
|
||||
$selectedArchitectures = $Architectures |
|
||||
ForEach-Object {
|
||||
if ($_ -eq 'ARM64') { 'ARM64' } elseif ($_ -eq 'x86') { 'x86' } else { 'x64' }
|
||||
} |
|
||||
Select-Object -Unique
|
||||
|
||||
$proxifyreRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/proxifyre/releases/latest'
|
||||
$ndisapiRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/ndisapi/releases/latest'
|
||||
|
||||
$files = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($arch in $selectedArchitectures) {
|
||||
$proxifyreAsset = Select-ReleaseAsset $proxifyreRelease "ProxiFyre-.*-$arch-signed\.zip$" "ProxiFyre $arch"
|
||||
$files.Add((Save-Asset "proxifyre-$($arch.ToLowerInvariant())" $proxifyreAsset.name $proxifyreAsset.browser_download_url $proxifyreAsset.digest))
|
||||
|
||||
$ndisAsset = Select-ReleaseAsset $ndisapiRelease "Windows\.Packet\.Filter\..*\.$arch\.msi$" "Windows Packet Filter $arch"
|
||||
$files.Add((Save-Asset "packet-filter-$($arch.ToLowerInvariant())" $ndisAsset.name $ndisAsset.browser_download_url $ndisAsset.digest))
|
||||
}
|
||||
|
||||
if (-not $SkipVcRuntime) {
|
||||
if ($selectedArchitectures | Where-Object { $_ -ne 'x86' }) {
|
||||
$files.Add((Save-Asset 'vc-runtime-x64' 'vc_redist.x64.exe' 'https://aka.ms/vc14/vc_redist.x64.exe'))
|
||||
}
|
||||
if ($selectedArchitectures -contains 'x86') {
|
||||
$files.Add((Save-Asset 'vc-runtime-x86' 'vc_redist.x86.exe' 'https://aka.ms/vc14/vc_redist.x86.exe'))
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = [PSCustomObject]@{
|
||||
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
architectures = @($selectedArchitectures)
|
||||
proxifyreRelease = $proxifyreRelease.tag_name
|
||||
windowsPacketFilterRelease = $ndisapiRelease.tag_name
|
||||
files = $files
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $OutputDir 'manifest.json'
|
||||
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||
|
||||
$keepNames = @($files | ForEach-Object { $_.name }) + 'manifest.json'
|
||||
Get-ChildItem -LiteralPath $OutputDir -File |
|
||||
Where-Object { $keepNames -notcontains $_.Name } |
|
||||
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
|
||||
|
||||
Write-Host "Bundle updated: $OutputDir"
|
||||
Reference in New Issue
Block a user