diff --git a/tools/Build-Installer.ps1 b/tools/Build-Installer.ps1 index ef86526012..e221451f55 100644 --- a/tools/Build-Installer.ps1 +++ b/tools/Build-Installer.ps1 @@ -1,33 +1,29 @@ <# .SYNOPSIS - Builds the SoundSwitch installer from a local build or downloaded release. + Builds and signs the SoundSwitch installer from a pre-populated directory. .DESCRIPTION - This script automates the full installer build process: + This script is focused exclusively on installer compilation and code signing: - 1. If -DownloadRelease is set, downloads the latest release artifact using - tools\Download-Release.ps1 into the Final\ directory. - 2. Otherwise, builds the SoundSwitch binaries from source using dotnet publish. - 3. Generates HTML documentation from Markdown sources (Changelog, README, Terms). - 4. Bundles additional assets (images, licenses). - 5. Optionally signs binaries and the final installer with tools\Sign-Binary.ps1. - 6. Compiles the Inno Setup installer. + 1. Validates that the target directory contains the expected binaries. + 2. Signs the application binaries using tools\Sign-Binary.ps1. + 3. Locates Inno Setup 6 (ISCC.exe) and compiles the installer directly. + 4. Signs the resulting installer using tools\Sign-Binary.ps1. - This is the PowerShell replacement for the local build workflow. It requires - the tools from Install-BuildTools.ps1 (Inno Setup, Python with markdown, and - optionally signtool + Certum SimplySign for code signing). + It does NOT build from source, generate HTML documentation, or bundle + assets — those responsibilities belong to Publish-Release.ps1. - Requires PowerShell 7+ (ships with Windows 11). - -.PARAMETER Configuration - Build configuration: Release (default) or Debug. + The target directory must be the canonical Final\ directory at the + repository root because Inno Setup (setup.iss) references it via a + hardcoded relative path. -.PARAMETER DownloadRelease - When set, downloads a pre-built release instead of building from source. + Requires PowerShell 7+ (ships with Windows 11). -.PARAMETER Channel - Release channel when downloading: 'release' (default) or 'beta'. - Only used when -DownloadRelease is set. +.PARAMETER FinalDir + Path to the directory containing the binaries, documentation, and assets + to package. Defaults to .\Final (relative to the repository root). + Must be the canonical Final\ directory because Inno Setup references it + via a hardcoded relative path in Installer\scripts\app_defines.iss. .PARAMETER SkipSigning Skip code signing even when signtool is available. @@ -41,26 +37,22 @@ .EXAMPLE .\tools\Build-Installer.ps1 - Builds from source (Release config) and creates the installer. + Builds and signs the installer from the default Final\ directory. .EXAMPLE - .\tools\Build-Installer.ps1 -DownloadRelease -Channel beta - Downloads the latest beta release and builds the installer from it. + .\tools\Build-Installer.ps1 -SkipSigning + Builds the installer without code signing. .EXAMPLE - .\tools\Build-Installer.ps1 -SkipSigning -InstallerReleaseState Nightly - Builds from source without signing, using "Nightly" label for installer. + .\tools\Build-Installer.ps1 -InstallerReleaseState Beta + Builds the installer with "Beta" label. #> +#Requires -Version 7.0 + [CmdletBinding()] param( - [ValidateSet('Release', 'Debug')] - [string]$Configuration = 'Release', - - [switch]$DownloadRelease, - - [ValidateSet('release', 'beta')] - [string]$Channel = 'release', + [string]$FinalDir = (Join-Path (Split-Path $PSScriptRoot -Parent) 'Final'), [switch]$SkipSigning, @@ -74,114 +66,74 @@ $ErrorActionPreference = 'Stop' # ── Paths ──────────────────────────────────────────────────────────────────── -$repoRoot = Split-Path $PSScriptRoot -Parent -$finalDir = Join-Path $repoRoot 'Final' +$repoRoot = Split-Path $PSScriptRoot -Parent +$FinalDir = [System.IO.Path]::GetFullPath($FinalDir) +$signScript = Join-Path $PSScriptRoot 'Sign-Binary.ps1' $projectName = 'SoundSwitch' -$cliProject = 'SoundSwitch.CLI' - -# ── Detect target framework ───────────────────────────────────────────────── - -$csprojPath = Join-Path $repoRoot "$projectName\$projectName.csproj" -[xml]$project = Get-Content $csprojPath -$framework = $project.Project.PropertyGroup.TargetFramework | Select-Object -First 1 - -if ([string]::IsNullOrWhiteSpace($framework)) { - throw "Unable to determine TargetFramework from $csprojPath" -} - -Write-Host "Detected target framework: $framework" -ForegroundColor Cyan - -# ── Step 1: Populate Final\ ───────────────────────────────────────────────── - -if ($DownloadRelease) { - Write-Host "`n=== Downloading release artifact ($Channel) ===" -ForegroundColor White - $downloadScript = Join-Path $PSScriptRoot 'Download-Release.ps1' - & $downloadScript -Channel $Channel -OutputDir $finalDir -} -else { - Write-Host "`n=== Building from source ($Configuration) ===" -ForegroundColor White - - # Clean - foreach ($dir in @('bin', 'obj', 'Release', $finalDir)) { - $fullPath = if ([System.IO.Path]::IsPathRooted($dir)) { $dir } else { Join-Path $repoRoot $dir } - if (Test-Path $fullPath) { - Remove-Item $fullPath -Recurse -Force +$cliProject = 'SoundSwitch.CLI' + +# ── Locate Inno Setup (ISCC.exe) ──────────────────────────────────────────── + +function Find-InnoSetup { + <# + .SYNOPSIS + Locates ISCC.exe from the Inno Setup 6 installation. + .DESCRIPTION + Searches the Windows registry for the Inno Setup 6 install location, + matching the logic used by the CI workflow test-installer-build.yml. + .OUTPUTS + The full path to ISCC.exe, or $null if not found. + #> + $registryPaths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1' + ) + + foreach ($regPath in $registryPaths) { + if (Test-Path $regPath) { + $installDir = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).InstallLocation + if ($installDir) { + $isccPath = Join-Path $installDir 'ISCC.exe' + if (Test-Path $isccPath) { + return $isccPath + } + } } } - New-Item -ItemType Directory -Path $finalDir -Force | Out-Null - - # Publish CLI first, then main app (main app wins on shared files) - foreach ($proj in @($cliProject, $projectName)) { - $projPath = Join-Path $repoRoot "$proj\$proj.csproj" - Write-Host " Publishing $proj ..." - dotnet publish -c $Configuration $projPath -o $finalDir - if ($LASTEXITCODE -ne 0) { - throw "dotnet publish failed for $proj with exit code $LASTEXITCODE." - } - } -} - -# ── Step 2: Generate HTML documentation ────────────────────────────────────── - -Write-Host "`n=== Generating HTML documentation ===" -ForegroundColor White - -$markdownTool = Join-Path $PSScriptRoot 'markdown_to_html.py' -$mdFiles = @( - @{ Source = 'CHANGELOG.md'; Output = 'Changelog.html' } - @{ Source = 'README.md'; Output = 'Readme.html' } - @{ Source = 'Terms.md'; Output = 'Terms.html' } - @{ Source = 'README.de.md'; Output = 'Readme.de.html' } -) - -foreach ($md in $mdFiles) { - $srcPath = Join-Path $repoRoot $md.Source - $outPath = Join-Path $finalDir $md.Output - - if (Test-Path $srcPath) { - Write-Host " Converting $($md.Source) -> $($md.Output)" - python $markdownTool $srcPath -o $outPath - if ($LASTEXITCODE -ne 0) { - throw "HTML generation failed for $($md.Source)." - } - } - else { - Write-Host " Skipping $($md.Source) (not found)" -ForegroundColor DarkGray + # Fallback: check PATH + $onPath = Get-Command 'ISCC.exe' -ErrorAction SilentlyContinue + if ($onPath) { + return $onPath.Source } + + return $null } -# ── Step 3: Bundle additional assets ───────────────────────────────────────── +# ── Validate Final\ ───────────────────────────────────────────────────────── -Write-Host "`n=== Bundling assets ===" -ForegroundColor White +Write-Host "SoundSwitch Installer Builder" -ForegroundColor White +Write-Host "============================`n" -$assets = @( - @{ Source = 'img\soundSwitched.png'; Dest = $finalDir } - @{ Source = 'SoundSwitch.CLI\README.md'; Dest = $finalDir } - @{ Source = 'LICENSE.txt'; Dest = $finalDir } - @{ Source = 'Terms.txt'; Dest = $finalDir } -) +if (-not (Test-Path $FinalDir)) { + throw "Directory not found at $FinalDir. Populate it with binaries first (e.g. via Publish-Release.ps1)." +} -foreach ($asset in $assets) { - $srcPath = Join-Path $repoRoot $asset.Source - if (Test-Path $srcPath) { - Copy-Item $srcPath -Destination $asset.Dest -Force - Write-Host " Copied $($asset.Source)" - } - else { - Write-Host " Skipping $($asset.Source) (not found)" -ForegroundColor DarkGray - } +$fileCount = (Get-ChildItem $FinalDir -Recurse -File -ErrorAction SilentlyContinue).Count +if ($fileCount -eq 0) { + throw "Directory at $FinalDir is empty. Populate it with binaries first." } +Write-Host "Using $FinalDir ($fileCount files)" -ForegroundColor Cyan -# ── Step 4: Code signing (binaries) ────────────────────────────────────────── +# ── Step 1: Sign binaries ──────────────────────────────────────────────────── -$signScript = Join-Path $PSScriptRoot 'Sign-Binary.ps1' -$canSign = -not $SkipSigning -and (Get-Command 'signtool.exe' -ErrorAction SilentlyContinue) +$canSign = -not $SkipSigning -and (Get-Command 'signtool.exe' -ErrorAction SilentlyContinue) if ($canSign) { Write-Host "`n=== Signing binaries ===" -ForegroundColor White $binaries = @("$projectName.exe", "$cliProject.exe") | - ForEach-Object { Join-Path $finalDir $_ } | + ForEach-Object { Join-Path $FinalDir $_ } | Where-Object { Test-Path $_ } if ($binaries) { @@ -200,40 +152,60 @@ else { } } -# ── Step 5: Build installer ────────────────────────────────────────────────── +# ── Step 2: Build installer (Inno Setup) ───────────────────────────────────── Write-Host "`n=== Building installer ===" -ForegroundColor White -$makeInstallerBat = Join-Path $repoRoot 'Installer\Make-Installer.bat' -if (-not (Test-Path $makeInstallerBat)) { - throw "Installer\Make-Installer.bat not found at $makeInstallerBat." +$isccExe = Find-InnoSetup +if (-not $isccExe) { + throw "Inno Setup 6 (ISCC.exe) not found. Run tools\Install-BuildTools.ps1 first." +} +Write-Host " Using ISCC: $isccExe" -ForegroundColor DarkGray + +$installerDir = Join-Path $FinalDir 'Installer' +if (-not (Test-Path $installerDir)) { + New-Item -ItemType Directory -Path $installerDir -Force | Out-Null +} + +# Clean previous installer files +Get-ChildItem $installerDir -Filter '*Installer.exe' -ErrorAction SilentlyContinue | + Remove-Item -Force + +$setupIss = Join-Path $repoRoot 'Installer\setup.iss' +if (-not (Test-Path $setupIss)) { + throw "Installer\setup.iss not found at $setupIss." } -& cmd.exe /c "`"$makeInstallerBat`" $InstallerReleaseState" +Write-Host " Compiling: ISCC $setupIss /DReleaseState=$InstallerReleaseState" +& $isccExe $setupIss "/DReleaseState=$InstallerReleaseState" if ($LASTEXITCODE -ne 0) { - throw "Installer build failed with exit code $LASTEXITCODE." + throw "Inno Setup compilation failed with exit code $LASTEXITCODE." } -# ── Done ───────────────────────────────────────────────────────────────────── +# Move installer output from Final\ to Final\Installer\ +$builtInstallers = Get-ChildItem $FinalDir -Filter '*Installer.exe' -File +foreach ($ins in $builtInstallers) { + $dest = Join-Path $installerDir $ins.Name + Move-Item $ins.FullName $dest -Force + Write-Host " Moved $($ins.Name) -> Installer\" +} + +# ── Step 3: Sign installer ─────────────────────────────────────────────────── Write-Host "`n=================================" -ForegroundColor White Write-Host "Installer built successfully!" -ForegroundColor Green -$installerDir = Join-Path $finalDir 'Installer' -if (Test-Path $installerDir) { - $installers = Get-ChildItem $installerDir -Filter '*Installer.exe' - if ($installers) { - # ── Step 6: Sign the installer ─────────────────────────────────────── - if ($canSign) { - Write-Host "`n=== Signing installer ===" -ForegroundColor White - $installerPaths = $installers | ForEach-Object { $_.FullName } - & $signScript -Path $installerPaths -CertificateName $CertificateName - } +$installers = Get-ChildItem $installerDir -Filter '*Installer.exe' +if ($installers) { + if ($canSign) { + Write-Host "`n=== Signing installer ===" -ForegroundColor White + $installerPaths = $installers | ForEach-Object { $_.FullName } + & $signScript -Path $installerPaths -CertificateName $CertificateName + } - Write-Host "`nInstaller(s):" - foreach ($ins in $installers) { - Write-Host " $($ins.FullName)" -ForegroundColor Cyan - } + Write-Host "`nInstaller(s):" + foreach ($ins in $installers) { + Write-Host " $($ins.FullName)" -ForegroundColor Cyan } } diff --git a/tools/Download-Release.ps1 b/tools/Download-Release.ps1 deleted file mode 100644 index 8a824cfda2..0000000000 --- a/tools/Download-Release.ps1 +++ /dev/null @@ -1,166 +0,0 @@ -<# -.SYNOPSIS - Downloads and extracts the latest SoundSwitch release or beta build artifact. - -.DESCRIPTION - Uses the GitHub API to find the latest release (or pre-release / beta) - artifact zip, downloads it, and extracts it into the Final\ directory so - that the local Build-Installer.ps1 can sign binaries and build the installer. - - Requires PowerShell 7+ (ships with Windows 11). - -.PARAMETER Channel - Release channel to download: 'release' (default) or 'beta'. - -.PARAMETER Token - Optional GitHub personal access token for higher API rate limits and - access to private repositories. Can also be set via the GITHUB_TOKEN - environment variable. - -.PARAMETER OutputDir - Directory to extract the artifact into (default: .\Final). - -.PARAMETER Repository - GitHub repository in owner/repo format (default: Belphemur/SoundSwitch). - -.EXAMPLE - .\tools\Download-Release.ps1 - Downloads the latest stable release artifact into .\Final - -.EXAMPLE - .\tools\Download-Release.ps1 -Channel beta - Downloads the latest beta / pre-release artifact into .\Final - -.EXAMPLE - .\tools\Download-Release.ps1 -Channel release -Token ghp_xxxx -OutputDir C:\Build\Final -#> - -[CmdletBinding()] -param( - [ValidateSet('release', 'beta')] - [string]$Channel = 'release', - - [string]$Token = $env:GITHUB_TOKEN, - - [string]$OutputDir = (Join-Path $PSScriptRoot '..\Final'), - - [string]$Repository = 'Belphemur/SoundSwitch' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# ── Helpers ────────────────────────────────────────────────────────────────── - -function Get-GitHubHeaders { - $headers = @{ - 'Accept' = 'application/vnd.github+json' - 'User-Agent' = 'SoundSwitch-Download/1.0' - } - if ($Token) { - $headers['Authorization'] = "Bearer $Token" - } - return $headers -} - -function Find-ReleaseAsset { - param( - [Parameter(Mandatory)] - [string]$ApiUrl, - [Parameter(Mandatory)] - [hashtable]$Headers, - [Parameter(Mandatory)] - [bool]$IncludePreRelease - ) - - Write-Host "Fetching releases from $ApiUrl ..." - $releases = Invoke-RestMethod -Uri $ApiUrl -Headers $Headers -Method Get - - if (-not $releases -or $releases.Count -eq 0) { - throw "No releases found for $Repository." - } - - $selected = $null - foreach ($rel in $releases) { - if ($IncludePreRelease -or -not $rel.prerelease) { - $selected = $rel - break - } - } - - if (-not $selected) { - throw "No matching $Channel release found for $Repository." - } - - Write-Host "Selected release: $($selected.tag_name) (pre-release: $($selected.prerelease))" - - # Find the zip asset (pattern: SoundSwitch-v*.zip) - $asset = $selected.assets | Where-Object { - $_.name -match '^SoundSwitch-v.+\.zip$' - } | Select-Object -First 1 - - if (-not $asset) { - throw "No SoundSwitch zip asset found in release $($selected.tag_name). Available assets: $(($selected.assets | ForEach-Object { $_.name }) -join ', ')" - } - - return @{ - Name = $asset.name - Url = $asset.browser_download_url - Version = $selected.tag_name - PreRelease = $selected.prerelease - } -} - -# ── Main ───────────────────────────────────────────────────────────────────── - -$apiBase = "https://api.github.com/repos/$Repository/releases" -$headers = Get-GitHubHeaders - -# For beta channel, we include pre-releases; the API returns releases sorted -# by creation date descending, so we pick the first matching one. -$includePreRelease = $Channel -eq 'beta' - -$assetInfo = Find-ReleaseAsset -ApiUrl "${apiBase}?per_page=30" -Headers $headers -IncludePreRelease $includePreRelease - -Write-Host "" -Write-Host "Downloading $($assetInfo.Name) from $($assetInfo.Version) ..." - -$tempZip = Join-Path ([System.IO.Path]::GetTempPath()) $assetInfo.Name - -$downloadHeaders = @{ - 'User-Agent' = 'SoundSwitch-Download/1.0' -} -if ($Token) { - $downloadHeaders['Authorization'] = "Bearer $Token" -} - -Invoke-WebRequest -Uri $assetInfo.Url -OutFile $tempZip -Headers $downloadHeaders - -if (-not (Test-Path $tempZip)) { - throw "Download failed: $tempZip was not created." -} - -$zipSize = (Get-Item $tempZip).Length -Write-Host "Downloaded $([math]::Round($zipSize / 1MB, 2)) MB -> $tempZip" - -# Prepare output directory -$OutputDir = [System.IO.Path]::GetFullPath($OutputDir) -if (Test-Path $OutputDir) { - Write-Host "Cleaning existing output directory: $OutputDir" - Remove-Item $OutputDir -Recurse -Force -} -New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - -# Extract -Write-Host "Extracting to $OutputDir ..." -Expand-Archive -Path $tempZip -DestinationPath $OutputDir -Force - -# Clean up temp file -Remove-Item $tempZip -Force -ErrorAction SilentlyContinue - -$fileCount = (Get-ChildItem $OutputDir -Recurse -File).Count -Write-Host "" -Write-Host "Done! Extracted $fileCount files to $OutputDir" -Write-Host "Release: $($assetInfo.Version) ($Channel)" -Write-Host "" -Write-Host "You can now run tools\Build-Installer.ps1 to sign binaries and build the installer." diff --git a/tools/Install-BuildTools.Tests.ps1 b/tools/Install-BuildTools.Tests.ps1 index f95541d003..f57090580e 100644 --- a/tools/Install-BuildTools.Tests.ps1 +++ b/tools/Install-BuildTools.Tests.ps1 @@ -461,4 +461,106 @@ Describe 'Install-BuildTools.ps1 script' { Get-Command Install-SignToolFromGitHub -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty Get-Command Install-SignTool -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty } + + It 'installs GitHub CLI as the first package via Install-WingetPackage' { + # Parse the script AST and find all Install-WingetPackage calls in + # order. Verify 'GitHub.cli' comes before 'JRSoftware.InnoSetup'. + $scriptPath = Join-Path $PSScriptRoot 'Install-BuildTools.ps1' + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, [ref]$tokens, [ref]$errors + ) + + $calls = $ast.FindAll( + { param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Install-WingetPackage' + }, $true + ) + + # Extract the -Id argument from each call + $ids = foreach ($call in $calls) { + $idParam = $call.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $_.Value -match '\.' + } | Select-Object -First 1 + if ($idParam) { $idParam.Value } + } + + $ghIdx = [array]::IndexOf($ids, 'GitHub.cli') + $innoIdx = [array]::IndexOf($ids, 'JRSoftware.InnoSetup') + + $ghIdx | Should -BeGreaterOrEqual 0 + $innoIdx | Should -BeGreaterOrEqual 0 + $ghIdx | Should -BeLessThan $innoIdx + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# Publish-Release.ps1 parse validation +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Publish-Release.ps1 script' { + It 'has no parse errors' { + $scriptPath = Join-Path $PSScriptRoot 'Publish-Release.ps1' + $tokens = $null + $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, [ref]$tokens, [ref]$errors + ) + $errors.Count | Should -Be 0 + } + + It 'contains a #Requires -Version 7.0 directive' { + $scriptPath = Join-Path $PSScriptRoot 'Publish-Release.ps1' + $content = Get-Content $scriptPath -Raw + $content | Should -Match '#Requires\s+-Version\s+7\.0' + } + + It 'derives InstallerReleaseState from Channel when not explicitly set' { + $scriptPath = Join-Path $PSScriptRoot 'Publish-Release.ps1' + $content = Get-Content $scriptPath -Raw + # Verify the dynamic default logic is present + $content | Should -Match 'PSBoundParameters.*InstallerReleaseState' + $content | Should -Match "Channel\s+-eq\s+'beta'" + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build-Installer.ps1 parse validation +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Build-Installer.ps1 script' { + It 'has no parse errors' { + $scriptPath = Join-Path $PSScriptRoot 'Build-Installer.ps1' + $tokens = $null + $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, [ref]$tokens, [ref]$errors + ) + $errors.Count | Should -Be 0 + } + + It 'has -FinalDir parameter' { + $scriptPath = Join-Path $PSScriptRoot 'Build-Installer.ps1' + $content = Get-Content $scriptPath -Raw + $content | Should -Match '\$FinalDir' + } + + It 'does not use Make-Installer.bat (legacy)' { + $scriptPath = Join-Path $PSScriptRoot 'Build-Installer.ps1' + $content = Get-Content $scriptPath -Raw + $content | Should -Not -Match 'Make-Installer\.bat' + } + + It 'invokes ISCC.exe directly' { + $scriptPath = Join-Path $PSScriptRoot 'Build-Installer.ps1' + $content = Get-Content $scriptPath -Raw + $content | Should -Match 'ISCC' + } + + It 'uses Sign-Binary.ps1 for code signing' { + $scriptPath = Join-Path $PSScriptRoot 'Build-Installer.ps1' + $content = Get-Content $scriptPath -Raw + $content | Should -Match 'Sign-Binary\.ps1' + } } diff --git a/tools/Install-BuildTools.ps1 b/tools/Install-BuildTools.ps1 index afe561054c..d5d222f56e 100644 --- a/tools/Install-BuildTools.ps1 +++ b/tools/Install-BuildTools.ps1 @@ -276,20 +276,23 @@ if (-not (Test-CommandExists 'winget')) { # ── Install packages ───────────────────────────────────────────────────────── -# 1. Inno Setup 6 — installer compiler +# 1. GitHub CLI — used by Publish-Release.ps1 to interact with GitHub releases +Install-WingetPackage -Id 'GitHub.cli' -Name 'GitHub CLI' -Scope $Scope + +# 2. Inno Setup 6 — installer compiler Install-WingetPackage -Id 'JRSoftware.InnoSetup' -Name 'Inno Setup 6' -Scope $Scope -# 2. Certum SimplySign Desktop — cloud certificate provider used for code signing +# 3. Certum SimplySign Desktop — cloud certificate provider used for code signing Install-WingetPackage -Id 'Certum.SmartSignSimplySignDesktop' -Name 'Certum SimplySign Desktop' -Scope $Scope -# 3. signtool.exe — used by Sign-Binary.ps1/Build-Installer.ps1 (with the +# 4. signtool.exe — used by Sign-Binary.ps1/Build-Installer.ps1 (with the # Certum certificate) to sign executables and the installer. # Located from an existing Windows Kits installation, downloaded from # Delphier/SignTool on GitHub, or the full Windows SDK is installed # via winget as a last resort. Install-SignTool -# 4. Python 3 — used for markdown-to-HTML documentation generation +# 5. Python 3 — used for markdown-to-HTML documentation generation Install-WingetPackage -Id 'Python.Python.3.14' -Name 'Python 3.14' -Scope $Scope # ── Post-install: Python markdown package ──────────────────────────────────── @@ -322,6 +325,7 @@ Write-Host "`n=================================" -ForegroundColor White Write-Host "All build tools installed successfully!" -ForegroundColor Green Write-Host "" Write-Host "Installed tools:" +Write-Host " - GitHub CLI (used by Publish-Release.ps1 for release management)" Write-Host " - Inno Setup 6 (installer compiler)" Write-Host " - Certum SimplySign Desktop (cloud certificate provider for code signing)" Write-Host " - signtool.exe (used by Sign-Binary.ps1 for signing)" diff --git a/tools/Publish-Release.ps1 b/tools/Publish-Release.ps1 new file mode 100644 index 0000000000..ddac85ad4f --- /dev/null +++ b/tools/Publish-Release.ps1 @@ -0,0 +1,471 @@ +<# +.SYNOPSIS + Full release workflow: prepare binaries, build and sign the installer, + upload to the draft GitHub release, and publish. + +.DESCRIPTION + Orchestrates the complete SoundSwitch release process: + + 1. Populates the Final\ directory with binaries — either by downloading + the build-artifact zip from a draft GitHub release (default) or by + building from source (-BuildFromSource). + 2. When building from source: generates HTML documentation from Markdown + and bundles additional assets. When downloading from a draft release + the CI pipeline has already included these in the zip, so this step + is skipped. + 3. Delegates to Build-Installer.ps1 to sign binaries, compile the Inno + Setup installer, and sign the installer. + 4. Uploads the signed installer(s) to the draft release, replacing any + existing installer assets. + 5. Extracts the latest section from CHANGELOG.md as the release body, + asks the user whether they want to prepend additional notes, and + updates the release body on GitHub. + 6. Asks for confirmation before publishing the release. + + When -BuildFromSource is used, steps 4–6 are skipped (no draft release + to publish to). + + Requires PowerShell 7+ and an authenticated GitHub CLI (`gh auth login`) + when downloading from a draft release. + +.PARAMETER Channel + Release channel: 'release' (default) or 'beta'. + 'release' looks for a non-pre-release draft; 'beta' looks for a + pre-release draft. Only used when downloading from a draft release. + +.PARAMETER Repository + GitHub repository in owner/repo format (default: Belphemur/SoundSwitch). + +.PARAMETER BuildFromSource + Build binaries from source instead of downloading from a draft release. + When set, the GitHub CLI is not required and publish steps are skipped. + +.PARAMETER Configuration + Build configuration when -BuildFromSource is set: Release (default) or + Debug. Ignored when downloading from a draft release. + +.PARAMETER SkipSigning + Skip code signing even when signtool is available. + +.PARAMETER CertificateName + Subject name (CN) of the code-signing certificate passed to + Build-Installer.ps1 / Sign-Binary.ps1. + Defaults to "OpenSource Developer, Antoine Aflalo". + +.PARAMETER InstallerReleaseState + The release state label passed to Inno Setup (e.g. Release, Beta). + +.EXAMPLE + .\tools\Publish-Release.ps1 + Full release workflow for the latest stable draft release. + +.EXAMPLE + .\tools\Publish-Release.ps1 -Channel beta + Full release workflow for the latest beta draft release. + +.EXAMPLE + .\tools\Publish-Release.ps1 -BuildFromSource + Build from source and create the installer locally (no publishing). + +.EXAMPLE + .\tools\Publish-Release.ps1 -SkipSigning + Full release workflow without code signing. +#> + +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [ValidateSet('release', 'beta')] + [string]$Channel = 'release', + + [string]$Repository = 'Belphemur/SoundSwitch', + + [switch]$BuildFromSource, + + [ValidateSet('Release', 'Debug')] + [string]$Configuration = 'Release', + + [switch]$SkipSigning, + + [string]$CertificateName = 'OpenSource Developer, Antoine Aflalo', + + [string]$InstallerReleaseState +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Derive InstallerReleaseState from Channel when not explicitly provided +if (-not $PSBoundParameters.ContainsKey('InstallerReleaseState')) { + $InstallerReleaseState = if ($Channel -eq 'beta') { 'Beta' } else { 'Release' } +} + +# ── Paths ──────────────────────────────────────────────────────────────────── + +$repoRoot = Split-Path $PSScriptRoot -Parent +$finalDir = Join-Path $repoRoot 'Final' +$projectName = 'SoundSwitch' +$cliProject = 'SoundSwitch.CLI' + +# ── Helpers ────────────────────────────────────────────────────────────────── + +function Assert-GitHubCli { + if (-not (Get-Command 'gh' -ErrorAction SilentlyContinue)) { + throw "GitHub CLI (gh) is not installed or not on PATH. Run tools\Install-BuildTools.ps1 first." + } + + # Verify authentication + gh auth status 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "GitHub CLI is not authenticated. Run 'gh auth login' first." + } +} + +function Find-DraftRelease { + <# + .SYNOPSIS + Finds the latest draft release for the given channel using gh CLI. + .OUTPUTS + A PSCustomObject with tagName, name, isDraft, isPrerelease properties, + or $null if no matching draft is found. + #> + param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][bool]$IncludePreRelease + ) + + Write-Host "Fetching releases from $Repository ..." -ForegroundColor Cyan + + # Capture stdout only; let stderr pass through to the console for diagnostics + $json = gh release list --repo $Repository --json tagName,isDraft,isPrerelease,name --limit 30 + if ($LASTEXITCODE -ne 0) { + throw "Failed to list releases (gh exit code $LASTEXITCODE)." + } + + $releases = $json | ConvertFrom-Json + + if (-not $releases -or $releases.Count -eq 0) { + throw "No releases found for $Repository." + } + + # Filter for draft releases matching the channel + $drafts = $releases | Where-Object { $_.isDraft } + + if ($IncludePreRelease) { + $selected = $drafts | Where-Object { $_.isPrerelease } | Select-Object -First 1 + } + else { + $selected = $drafts | Where-Object { -not $_.isPrerelease } | Select-Object -First 1 + } + + return $selected +} + +function Get-LatestChangelogEntry { + <# + .SYNOPSIS + Extracts the most recent version section from CHANGELOG.md. + .DESCRIPTION + Reads the changelog and returns all content from the first '## [' + heading up to (but not including) the next '## [' heading. + .OUTPUTS + The latest changelog section as a string, or $null if not found. + #> + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path $Path)) { + Write-Warning "Changelog not found at $Path" + return $null + } + + $lines = Get-Content $Path + $inSection = $false + $sectionLines = @() + + foreach ($line in $lines) { + if ($line -match '^## \[') { + if ($inSection) { break } + $inSection = $true + } + if ($inSection) { + $sectionLines += $line + } + } + + if ($sectionLines.Count -eq 0) { return $null } + return ($sectionLines -join "`n").Trim() +} + +# ── Pre-flight ─────────────────────────────────────────────────────────────── + +Write-Host "SoundSwitch Release Publisher" -ForegroundColor White +Write-Host "============================`n" + +$tag = $null +$draft = $null + +if (-not $BuildFromSource) { + Assert-GitHubCli +} + +# ── Step 1: Populate Final\ ───────────────────────────────────────────────── + +if ($BuildFromSource) { + # ── Build from source ──────────────────────────────────────────────── + Write-Host "=== Building from source ($Configuration) ===" -ForegroundColor White + + # Clean + foreach ($dir in @('bin', 'obj', 'Release', $finalDir)) { + $fullPath = if ([System.IO.Path]::IsPathRooted($dir)) { $dir } else { Join-Path $repoRoot $dir } + if (Test-Path $fullPath) { + Remove-Item $fullPath -Recurse -Force + } + } + New-Item -ItemType Directory -Path $finalDir -Force | Out-Null + + # Publish CLI first, then main app (main app wins on shared files) + foreach ($proj in @($cliProject, $projectName)) { + $projPath = Join-Path $repoRoot "$proj\$proj.csproj" + Write-Host " Publishing $proj ..." + dotnet publish -c $Configuration $projPath -o $finalDir + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for $proj with exit code $LASTEXITCODE." + } + } +} +else { + # ── Download from draft release ────────────────────────────────────── + $includePreRelease = $Channel -eq 'beta' + $draft = Find-DraftRelease -Repository $Repository -IncludePreRelease $includePreRelease + + if (-not $draft) { + throw "No matching draft $Channel release found for $Repository. Has semantic-release run?" + } + + $tag = $draft.tagName + Write-Host "Found draft release: $($draft.name) ($tag)" -ForegroundColor Green + Write-Host "" + + Write-Host "=== Downloading release artifact ===" -ForegroundColor White + + if (Test-Path $finalDir) { + Write-Host " Cleaning existing output directory: $finalDir" + Remove-Item $finalDir -Recurse -Force + } + New-Item -ItemType Directory -Path $finalDir -Force | Out-Null + + # Download the zip asset to a temp directory + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "soundswitch-release-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + Write-Host " Downloading SoundSwitch zip from $tag ..." + gh release download $tag --repo $Repository --pattern 'SoundSwitch-v*.zip' --dir $tempDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to download release asset from $tag." + } + + $zipFiles = @(Get-ChildItem $tempDir -Filter 'SoundSwitch-v*.zip') + if ($zipFiles.Count -eq 0) { + throw "No SoundSwitch zip asset found in release $tag." + } + if ($zipFiles.Count -gt 1) { + $names = ($zipFiles | ForEach-Object { $_.Name }) -join ', ' + throw "Multiple SoundSwitch zip assets found in release $tag ($names). Expected exactly one." + } + + $zipFile = $zipFiles[0] + $zipSize = $zipFile.Length + Write-Host " Downloaded $([math]::Round($zipSize / 1MB, 2)) MB -> $($zipFile.Name)" + + # Extract + Write-Host " Extracting to $finalDir ..." + Expand-Archive -Path $zipFile.FullName -DestinationPath $finalDir -Force + } + finally { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + + $fileCount = (Get-ChildItem $finalDir -Recurse -File).Count + Write-Host " Extracted $fileCount files to $finalDir" -ForegroundColor Green +} + +Write-Host "" + +# ── Step 2: Generate HTML docs & bundle assets (only for source builds) ────── +# When downloading from a draft release, the CI pipeline has already generated +# HTML documentation and bundled assets into the zip artifact. + +if ($BuildFromSource) { + Write-Host "=== Generating HTML documentation ===" -ForegroundColor White + + $markdownTool = Join-Path $PSScriptRoot 'markdown_to_html.py' + + $mdFiles = @( + @{ Source = 'CHANGELOG.md'; Output = 'Changelog.html' } + @{ Source = 'README.md'; Output = 'Readme.html' } + @{ Source = 'Terms.md'; Output = 'Terms.html' } + @{ Source = 'README.de.md'; Output = 'Readme.de.html' } + ) + + foreach ($md in $mdFiles) { + $srcPath = Join-Path $repoRoot $md.Source + $outPath = Join-Path $finalDir $md.Output + + if (Test-Path $srcPath) { + Write-Host " Converting $($md.Source) -> $($md.Output)" + python $markdownTool $srcPath -o $outPath + if ($LASTEXITCODE -ne 0) { + throw "HTML generation failed for $($md.Source)." + } + } + else { + Write-Host " Skipping $($md.Source) (not found)" -ForegroundColor DarkGray + } + } + + Write-Host "`n=== Bundling assets ===" -ForegroundColor White + + $assets = @( + @{ Source = 'img\soundSwitched.png'; Dest = $finalDir } + @{ Source = 'SoundSwitch.CLI\README.md'; Dest = $finalDir } + @{ Source = 'LICENSE.txt'; Dest = $finalDir } + @{ Source = 'Terms.txt'; Dest = $finalDir } + ) + + foreach ($asset in $assets) { + $srcPath = Join-Path $repoRoot $asset.Source + if (Test-Path $srcPath) { + Copy-Item $srcPath -Destination $asset.Dest -Force + Write-Host " Copied $($asset.Source)" + } + else { + Write-Host " Skipping $($asset.Source) (not found)" -ForegroundColor DarkGray + } + } + + Write-Host "" +} + +# ── Step 3: Build installer ───────────────────────────────────────────────── + +Write-Host "=== Building installer ===" -ForegroundColor White + +$buildScript = Join-Path $PSScriptRoot 'Build-Installer.ps1' +$buildArgs = @{ + FinalDir = $finalDir + InstallerReleaseState = $InstallerReleaseState + CertificateName = $CertificateName +} +if ($SkipSigning) { + $buildArgs['SkipSigning'] = $true +} + +& $buildScript @buildArgs + +Write-Host "" + +# ── Upload and publish (only when downloading from draft) ──────────────────── + +if (-not $tag) { + Write-Host "Build-from-source complete. No draft release to publish." -ForegroundColor Green + Write-Host "" + return +} + +# ── Step 4: Upload installer to draft release ──────────────────────────────── + +Write-Host "=== Uploading installer to release $tag ===" -ForegroundColor White + +$installerDir = Join-Path $finalDir 'Installer' +if (-not (Test-Path $installerDir)) { + throw "Installer directory not found at $installerDir. Did the build succeed?" +} + +$installers = Get-ChildItem $installerDir -Filter '*Installer.exe' +if (-not $installers -or $installers.Count -eq 0) { + throw "No installer files found in $installerDir." +} + +foreach ($ins in $installers) { + Write-Host " Uploading $($ins.Name) (clobber existing) ..." + gh release upload $tag $ins.FullName --repo $Repository --clobber + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload $($ins.Name) to release $tag." + } + Write-Host " Uploaded $($ins.Name)" -ForegroundColor Green +} + +Write-Host "" + +# ── Step 5: Set release body from changelog ────────────────────────────────── + +Write-Host "=== Preparing release body ===" -ForegroundColor White + +$changelogPath = Join-Path $repoRoot 'CHANGELOG.md' +$changelogEntry = Get-LatestChangelogEntry -Path $changelogPath + +if ($changelogEntry) { + Write-Host "`nChangelog entry that will be used as the release body:" -ForegroundColor Cyan + Write-Host "─────────────────────────────────────────────────────" + Write-Host $changelogEntry + Write-Host "─────────────────────────────────────────────────────`n" +} +else { + Write-Warning "Could not extract a changelog entry from $changelogPath." + $changelogEntry = '' +} + +# Ask user for additional notes +$userNotes = Read-Host "Add notes before the changelog (press Enter to skip)" + +if (-not [string]::IsNullOrWhiteSpace($userNotes)) { + $releaseBody = "$userNotes`n`n$changelogEntry" +} +else { + $releaseBody = $changelogEntry +} + +# Update release body +if (-not [string]::IsNullOrWhiteSpace($releaseBody)) { + $bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "release-body-$([guid]::NewGuid().ToString('N').Substring(0,8)).md" + try { + Set-Content -Path $bodyFile -Value $releaseBody -Encoding UTF8 + gh release edit $tag --repo $Repository --notes-file $bodyFile + if ($LASTEXITCODE -ne 0) { + throw "Failed to update release body for $tag." + } + Write-Host " Release body updated." -ForegroundColor Green + } + finally { + Remove-Item $bodyFile -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "" + +# ── Step 6: Publish release ────────────────────────────────────────────────── + +Write-Host "=== Publish release ===" -ForegroundColor White +Write-Host "" +Write-Host "Release: $($draft.name)" -ForegroundColor Cyan +Write-Host "Tag: $tag" -ForegroundColor Cyan +Write-Host "Channel: $Channel" -ForegroundColor Cyan +Write-Host "Installers: $($installers.Count) file(s)" -ForegroundColor Cyan +Write-Host "" + +$confirm = Read-Host "Publish release $tag? (y/N)" +if ($confirm -eq 'y' -or $confirm -eq 'Y') { + gh release edit $tag --repo $Repository --draft=false + if ($LASTEXITCODE -ne 0) { + throw "Failed to publish release $tag." + } + Write-Host "`nRelease $tag published successfully!" -ForegroundColor Green +} +else { + Write-Host "`nRelease NOT published. The draft release has been updated with the installer." -ForegroundColor Yellow + Write-Host "You can publish later with: gh release edit $tag --repo $Repository --draft=false" -ForegroundColor Yellow +} + +Write-Host "" diff --git a/tools/README.md b/tools/README.md index 18398f0be0..21146b9e64 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,8 +4,8 @@ - [`vswhere.exe`](https://github.com/Microsoft/vswhere) - Locate Visual Studio installations. - [`markdown_to_html.py`](markdown_to_html.py) - Convert Markdown files to standalone HTML documents. Requires Python 3 with the `markdown` package (`pip install markdown`). Replaces the previously used `markdown-html` npm package. -- [`Download-Release.ps1`](Download-Release.ps1) - PowerShell 7+ script to download the latest release or beta build artifact from GitHub, extract it to `Final\`, and prepare for local signing and installer creation. -- [`Install-BuildTools.ps1`](Install-BuildTools.ps1) - PowerShell 7+ script that uses winget to install Inno Setup 6, Certum SimplySign Desktop (cloud certificate provider for code signing), and Python 3 (with the markdown package), and obtains `signtool.exe` by downloading a lightweight standalone build from [Delphier/SignTool](https://github.com/Delphier/SignTool/releases) on GitHub (with a smoke test), falling back to the full Windows SDK via winget. Run once on a fresh Windows 11 machine. +- [`Publish-Release.ps1`](Publish-Release.ps1) - PowerShell 7+ script that orchestrates the full release workflow. By default it finds the latest draft release created by semantic-release using the GitHub CLI (`gh`), downloads the build artifact into `Final\`, delegates to Build-Installer.ps1 for installer compilation and code signing, uploads the signed installer (replacing existing assets with `--clobber`), sets the release body from the latest CHANGELOG.md entry (with optional user notes), and publishes after confirmation. With `-BuildFromSource` it builds from source instead (including HTML doc generation and asset bundling) and skips the publish steps. The `-InstallerReleaseState` is automatically derived from `-Channel` (beta → Beta, release → Release) unless explicitly overridden. +- [`Install-BuildTools.ps1`](Install-BuildTools.ps1) - PowerShell 7+ script that uses winget to install GitHub CLI, Inno Setup 6, Certum SimplySign Desktop (cloud certificate provider for code signing), and Python 3 (with the markdown package), and obtains `signtool.exe` by downloading a lightweight standalone build from [Delphier/SignTool](https://github.com/Delphier/SignTool/releases) on GitHub (with a smoke test), falling back to the full Windows SDK via winget. Run once on a fresh Windows 11 machine. - [`Sign-Binary.ps1`](Sign-Binary.ps1) - PowerShell 7+ script for code signing executables using signtool with SHA-256 digest and RFC 3161 timestamping (no SHA-1). Configurable certificate subject name (defaults to "OpenSource Developer, Antoine Aflalo"). Called automatically by Build-Installer.ps1 when signtool is available. -- [`Build-Installer.ps1`](Build-Installer.ps1) - PowerShell 7+ script to build the SoundSwitch installer. Supports building from source or from a downloaded release artifact. Handles HTML documentation generation, asset bundling, code signing (via Sign-Binary.ps1), and Inno Setup compilation. +- [`Build-Installer.ps1`](Build-Installer.ps1) - PowerShell 7+ script focused on installer compilation and code signing. Takes a `-FinalDir` parameter (defaults to `.\Final`) pointing to a pre-populated directory with binaries, docs, and assets. Signs binaries and the installer using Sign-Binary.ps1, and invokes Inno Setup (ISCC.exe) directly. Does not build from source, generate docs, or interact with GitHub releases — use Publish-Release.ps1 for the full workflow. - [`upload_nightly_r2.py`](upload_nightly_r2.py) - Upload nightly build archives to Cloudflare R2 and notify Discord. diff --git a/tools/Sign-Binary.ps1 b/tools/Sign-Binary.ps1 index b71d9ba8d0..6ef845312f 100644 --- a/tools/Sign-Binary.ps1 +++ b/tools/Sign-Binary.ps1 @@ -45,6 +45,8 @@ Signs the installer with a custom certificate name. #> +#Requires -Version 7.0 + [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]