[CmdletBinding()] param( [string]$Project = "src/RgbLedStripController/RgbLedStripController.csproj", [string]$Configuration = "Release", [string]$Runtime = "win-x64", [string]$Channel = "win-x64-stable", [string]$PackId = "Fristy.RgbLedStripController", [string]$PackTitle = "RGB Led Strip Controller", [string]$PackAuthors = "fristy", [string]$GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller", [string]$InstallerIcon = "src/RgbLedStripController/Assets/AppIcon.ico", [string]$PfxPath = "", [string]$SignToolPath = "signtool.exe", [int]$SignParallel = 1, [switch]$SkipSigning, [switch]$SkipCloudCheck ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) $projectPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Project)) $nugetConfigPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "NuGet.Config")) if (-not (Test-Path -LiteralPath $projectPath)) { throw "Project file not found: $projectPath" } [xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath $versionNode = $null foreach ($propertyGroup in @($projectXml.Project.PropertyGroup)) { if ($propertyGroup.PSObject.Properties.Name -contains "Version" -and -not [string]::IsNullOrWhiteSpace($propertyGroup.Version)) { $versionNode = $propertyGroup.Version break } } $version = [string]$versionNode if ([string]::IsNullOrWhiteSpace($version)) { throw "No found in project file: $projectPath" } function Assert-WorkspacePath { param([string]$Path) $fullPath = [System.IO.Path]::GetFullPath($Path) if (-not $fullPath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to use path outside workspace: $fullPath" } return $fullPath } function Resolve-AbsolutePath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { throw "Path value cannot be empty." } if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) } return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) } function Get-GiteaReleaseTags { param([string]$RepoUrl) $uri = [Uri]$RepoUrl $segments = $uri.AbsolutePath.Trim("/").Split("/") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($segments.Count -lt 2) { throw "Could not parse owner/repo from GiteaRepoUrl: $RepoUrl" } $owner = $segments[-2] $repo = $segments[-1] $apiUrl = "{0}://{1}/api/v1/repos/{2}/{3}/releases?draft=false&limit=50" -f $uri.Scheme, $uri.Authority, $owner, $repo try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $releases = Invoke-RestMethod -Uri $apiUrl -Method Get -ErrorAction Stop } catch { throw "Gitea releases could not be read: $apiUrl`n$($_.Exception.Message)" } return @($releases | ForEach-Object { $_.tag_name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } function Get-MaxVersionFromTags { param([string[]]$Tags) $versions = @() foreach ($tag in $Tags) { $clean = $tag.TrimStart("v", "V") $parsed = $null if ([Version]::TryParse($clean, [ref]$parsed)) { $versions += $parsed } } if ($versions.Count -eq 0) { return $null } return ($versions | Sort-Object -Descending | Select-Object -First 1) } function Read-SecretAsPlainText { param([string]$Prompt) $secureString = Read-Host -Prompt $Prompt -AsSecureString if ($secureString.Length -eq 0) { throw "Empty password is not allowed." } $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } function Resolve-SignToolExecutable { param([string]$RequestedPath) if (Test-Path -LiteralPath $RequestedPath) { return [System.IO.Path]::GetFullPath($RequestedPath) } $command = Get-Command -Name $RequestedPath -ErrorAction SilentlyContinue if ($null -ne $command) { return $command.Source } throw "signtool.exe could not be resolved. Pass -SignToolPath with an absolute path if needed." } $publishDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\publish\$Runtime\$version") $releaseDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\velopack\$Channel") $mainExe = "RgbLedStripController.exe" $dotnet = "dotnet" $signingEnabled = (-not $SkipSigning.IsPresent) -and (-not [string]::IsNullOrWhiteSpace($PfxPath)) if ((-not $SkipSigning.IsPresent) -and [string]::IsNullOrWhiteSpace($PfxPath)) { Write-Warning "PfxPath not supplied; Authenticode signing will be skipped. Pass -PfxPath to enable it." } $signToolExecutable = $null $signToolDirectory = $null $plainPassword = $null $resolvedPfxPath = $null $resolvedInstallerIconPath = Resolve-AbsolutePath -Path $InstallerIcon $signParams = $null if (-not (Test-Path -LiteralPath $resolvedInstallerIconPath)) { throw "Installer icon file not found: $resolvedInstallerIconPath" } $env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet" $env:APPDATA = Join-Path $repoRoot ".appdata" $env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages" $env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache" $localVersion = [Version]$version $cloudVersion = $null if (-not $SkipCloudCheck.IsPresent) { $cloudVersion = Get-MaxVersionFromTags -Tags (Get-GiteaReleaseTags -RepoUrl $GiteaRepoUrl) } if ($null -ne $cloudVersion) { if ($localVersion -lt $cloudVersion) { throw "Local version $version is older than Gitea version $cloudVersion. Bump the project version before packaging." } if ($localVersion -eq $cloudVersion) { throw "Gitea already contains version $version. Packaging with the same version is blocked." } } if ($signingEnabled) { $resolvedPfxPath = Resolve-AbsolutePath -Path $PfxPath if (-not (Test-Path -LiteralPath $resolvedPfxPath)) { throw "PFX file not found: $resolvedPfxPath" } if ($SignParallel -lt 1) { throw "SignParallel must be at least 1." } $signToolExecutable = Resolve-SignToolExecutable -RequestedPath $SignToolPath $signToolDirectory = Split-Path -Parent $signToolExecutable if (-not [string]::IsNullOrWhiteSpace($signToolDirectory)) { $env:PATH = $signToolDirectory + [System.IO.Path]::PathSeparator + $env:PATH } $plainPassword = Read-SecretAsPlainText -Prompt "PFX password" $signParams = "/fd SHA256 /f `"$resolvedPfxPath`" /p `"$plainPassword`"" } if (Test-Path -LiteralPath $publishDir) { Remove-Item -LiteralPath $publishDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $publishDir | Out-Null New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null $publishArgs = @( "publish", $projectPath, "-c", $Configuration, "-r", $Runtime, "--self-contained", "true", "-p:PublishSingleFile=true", "-o", $publishDir, "--configfile", $nugetConfigPath ) & $dotnet @publishArgs if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" } $packArgs = @( "tool", "run", "vpk", "--", "pack", "--packId", $PackId, "--packVersion", $version, "--packDir", $publishDir, "--mainExe", $mainExe, "--packTitle", $PackTitle, "--packAuthors", $PackAuthors, "--runtime", $Runtime, "--channel", $Channel, "--icon", $resolvedInstallerIconPath, "--outputDir", $releaseDir ) if ($signingEnabled) { $packArgs += @( "--signParams", $signParams, "--signParallel", $SignParallel.ToString() ) } & $dotnet @packArgs if ($LASTEXITCODE -ne 0) { throw "vpk pack failed with exit code $LASTEXITCODE" } Write-Host "" Write-Host "Velopack package build complete." -ForegroundColor Green Write-Host "Version: $version" Write-Host "Gitea: $(if ($null -ne $cloudVersion) { $cloudVersion.ToString() } else { 'none' })" Write-Host "Signing: $(if ($signingEnabled) { 'enabled' } else { 'skipped' })" if ($signingEnabled -and -not [string]::IsNullOrWhiteSpace($signToolExecutable)) { Write-Host "SignTool: $signToolExecutable" } Write-Host "Icon: $resolvedInstallerIconPath" Write-Host "PublishDir: $publishDir" Write-Host "ReleaseDir: $releaseDir" Write-Host "Gitea repo: $GiteaRepoUrl" Write-Host "Tag: v$version" Write-Host "" Write-Host "Upload with: scripts/release/upload-feed.ps1 (vpk upload gitea)." -ForegroundColor Yellow