diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1197e05 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,118 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +env: + SOLUTION_FILE_PATH: Injector.sln + BUILD_CONFIGURATION: Release + +jobs: + build: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + platform: [Win32, x64, ARM64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Build + run: msbuild /m /p:Configuration="${{ env.BUILD_CONFIGURATION }}" /p:Platform=${{ matrix.platform }} ${{ env.SOLUTION_FILE_PATH }} + + - name: Stage binary artifact + shell: pwsh + run: | + $source = Join-Path "${{ github.workspace }}" "bin/${{ matrix.platform }}/Injector.exe" + if (!(Test-Path $source)) { + throw "Expected binary not found: $source" + } + + $targetDir = Join-Path "${{ github.workspace }}" "artifacts/${{ matrix.platform }}" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Copy-Item -Path $source -Destination (Join-Path $targetDir "Injector.exe") -Force + + - name: Upload platform artifact + uses: actions/upload-artifact@v4 + with: + name: Injector-${{ matrix.platform }} + path: artifacts/${{ matrix.platform }}/Injector.exe + if-no-files-found: error + retention-days: 1 + + package-and-release: + runs-on: windows-latest + needs: build + + steps: + - name: Download Win32 artifact + uses: actions/download-artifact@v4 + with: + name: Injector-Win32 + path: downloaded/Win32 + + - name: Download x64 artifact + uses: actions/download-artifact@v4 + with: + name: Injector-x64 + path: downloaded/x64 + + - name: Download ARM64 artifact + uses: actions/download-artifact@v4 + with: + name: Injector-ARM64 + path: downloaded/ARM64 + + - name: Assemble zip structure + shell: pwsh + run: | + $releaseRoot = Join-Path "${{ github.workspace }}" "release-content" + $zipPath = Join-Path "${{ github.workspace }}" "Injector_x86_amd64_arm64_unsigned.zip" + + if (Test-Path $releaseRoot) { + Remove-Item -Path $releaseRoot -Recurse -Force + } + if (Test-Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + + $arm64Dir = Join-Path $releaseRoot "ARM64" + $win32Dir = Join-Path $releaseRoot "Win32" + $x64Dir = Join-Path $releaseRoot "x64" + New-Item -ItemType Directory -Force -Path $arm64Dir, $win32Dir, $x64Dir | Out-Null + + Copy-Item -Path "downloaded/ARM64/Injector.exe" -Destination (Join-Path $arm64Dir "Injector.exe") -Force + Copy-Item -Path "downloaded/Win32/Injector.exe" -Destination (Join-Path $win32Dir "Injector.exe") -Force + Copy-Item -Path "downloaded/x64/Injector.exe" -Destination (Join-Path $x64Dir "Injector.exe") -Force + + Compress-Archive -Path (Join-Path $releaseRoot "*") -DestinationPath $zipPath + + - name: Upload unsigned release bundle artifact + uses: actions/upload-artifact@v4 + with: + name: unsigned-release-bundle-${{ github.ref_name }} + path: Injector_x86_amd64_arm64_unsigned.zip + if-no-files-found: error + retention-days: 14 + + - name: Create draft release awaiting EV signing + uses: softprops/action-gh-release@v2 + with: + draft: true + body: | + Release draft created automatically. + Final asset must be EV-signed on the maintainer local machine. + + Expected final asset name: + - Injector_x86_amd64_arm64.zip + generate_release_notes: true diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..80c309b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,45 @@ +# Release process + +## Overview + +Pushing a tag like `v1.5.0` triggers `.github/workflows/release.yml`, which: + +- Builds `Injector.exe` for `Win32`, `x64`, and `ARM64` +- Creates an unsigned bundle artifact: `Injector_x86_amd64_arm64_unsigned.zip` +- Creates a draft GitHub release for the tag + +Final signing and publish are performed locally on the EV-capable machine. + +## Prerequisites + +1. `gh` CLI installed and authenticated (`gh auth status`) +2. [`wdkwhere`](https://github.com/nefarius/wdkwhere) installed and available in `PATH` +3. EV token/certificate available and unlocked + +## Finalize a tagged release + +Run from repository root: + +```powershell +.\scripts\finalize-release.ps1 -Tag v1.5.0 -CertificateSubjectName "Nefarius Software Solutions e.U." +``` + +The script will: + +- Download `unsigned-release-bundle-v1.5.0` automatically (unless `-UnsignedZipPath` is provided) +- Sign: + - `ARM64/Injector.exe` + - `Win32/Injector.exe` + - `x64/Injector.exe` +- Create `Injector_x86_amd64_arm64.zip` +- Upload it to the draft release and publish it + +## Useful options + +```powershell +# Upload signed zip but keep release as draft +.\scripts\finalize-release.ps1 -Tag v1.5.0 -CertificateSubjectName "Nefarius Software Solutions e.U." -NoPublish + +# Use a manually downloaded unsigned zip +.\scripts\finalize-release.ps1 -Tag v1.5.0 -CertificateSubjectName "Nefarius Software Solutions e.U." -UnsignedZipPath "C:\Temp\Injector_x86_amd64_arm64_unsigned.zip" +``` diff --git a/scripts/finalize-release.ps1 b/scripts/finalize-release.ps1 new file mode 100644 index 0000000..0d1d264 --- /dev/null +++ b/scripts/finalize-release.ps1 @@ -0,0 +1,147 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern("^v\d+\.\d+\.\d+$")] + [string]$Tag, + + [Parameter(Mandatory = $true)] + [string]$CertificateSubjectName, + + [string]$TimestampUrl = "http://timestamp.digicert.com", + [string]$UnsignedZipPath, + [string]$WorkspaceRoot = (Join-Path $PSScriptRoot ".."), + [string]$OutputDir = (Join-Path $PSScriptRoot "../.release-local"), + [switch]$NoPublish +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Ensure-WdkWhere { + $command = Get-Command wdkwhere -ErrorAction SilentlyContinue + if (!$command) { + throw "wdkwhere was not found in PATH. Install it first (dotnet tool install --global Nefarius.Tools.WDKWhere)." + } + + return $command.Source +} + +function Resolve-UnsignedZip { + param( + [string]$TagValue, + [string]$ExplicitZipPath, + [string]$DestinationDir + ) + + if ($ExplicitZipPath) { + if (!(Test-Path $ExplicitZipPath)) { + throw "Unsigned zip path not found: $ExplicitZipPath" + } + + return (Resolve-Path $ExplicitZipPath).Path + } + + $workflowName = "release.yml" + $artifactName = "unsigned-release-bundle-$TagValue" + $runRows = gh run list --workflow $workflowName --limit 100 --json databaseId,headBranch,displayTitle,status,conclusion,event | ConvertFrom-Json + if (!$runRows) { + throw "No workflow runs found for '$workflowName'." + } + + $run = $runRows | + Where-Object { + $_.event -eq "push" -and + $_.status -eq "completed" -and + $_.conclusion -eq "success" -and + ($_.headBranch -eq $TagValue -or $_.displayTitle -eq $TagValue) + } | + Select-Object -First 1 + + if (!$run) { + throw "No successful '$workflowName' run found for tag '$TagValue'." + } + + $downloadDir = Join-Path $DestinationDir "downloaded" + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null + + gh run download $run.databaseId -n $artifactName -D $downloadDir | Out-Null + + $zip = Get-ChildItem -Path $downloadDir -Filter "Injector_x86_amd64_arm64_unsigned.zip" -File -Recurse | Select-Object -First 1 + if (!$zip) { + throw "Downloaded artifact '$artifactName' did not contain Injector_x86_amd64_arm64_unsigned.zip." + } + + return $zip.FullName +} + +function Sign-Binary { + param( + [string]$WdkWherePath, + [string]$CertSubjectName, + [string]$Timestamp, + [string]$FilePath + ) + + if (!(Test-Path $FilePath)) { + throw "Expected binary missing: $FilePath" + } + + & $WdkWherePath run signtool sign /n $CertSubjectName /a /fd SHA256 /td SHA256 /tr $Timestamp $FilePath + if ($LASTEXITCODE -ne 0) { + throw "signtool failed for '$FilePath' with exit code $LASTEXITCODE." + } +} + +Push-Location $WorkspaceRoot +try { + # Validate GH auth early because this script relies on release + artifact APIs. + gh auth status | Out-Null + + $wdkWhere = Ensure-WdkWhere + Write-Host "Using wdkwhere: $wdkWhere" + + $resolvedOutputDir = Resolve-Path (New-Item -ItemType Directory -Path $OutputDir -Force) + $workRoot = Join-Path $resolvedOutputDir ".work-$Tag" + if (Test-Path $workRoot) { + Remove-Item -Path $workRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $workRoot -Force | Out-Null + + $unsignedZip = Resolve-UnsignedZip -TagValue $Tag -ExplicitZipPath $UnsignedZipPath -DestinationDir $workRoot + Write-Host "Using unsigned zip: $unsignedZip" + + $unsignedExtract = Join-Path $workRoot "unsigned" + Expand-Archive -Path $unsignedZip -DestinationPath $unsignedExtract -Force + + $targets = @( + (Join-Path $unsignedExtract "ARM64/Injector.exe"), + (Join-Path $unsignedExtract "Win32/Injector.exe"), + (Join-Path $unsignedExtract "x64/Injector.exe") + ) + + foreach ($file in $targets) { + Write-Host "Signing $file" + Sign-Binary -WdkWherePath $wdkWhere -CertSubjectName $CertificateSubjectName -Timestamp $TimestampUrl -FilePath $file + } + + $finalZip = Join-Path $resolvedOutputDir "Injector_x86_amd64_arm64.zip" + if (Test-Path $finalZip) { + Remove-Item -Path $finalZip -Force + } + Compress-Archive -Path (Join-Path $unsignedExtract "*") -DestinationPath $finalZip + Write-Host "Created signed zip: $finalZip" + + gh release view $Tag --json tagName,isDraft | Out-Null + gh release upload $Tag $finalZip --clobber | Out-Null + Write-Host "Uploaded asset to release '$Tag'." + + if (-not $NoPublish) { + gh release edit $Tag --draft=false | Out-Null + Write-Host "Published release '$Tag'." + } + else { + Write-Host "Draft release left unpublished due to -NoPublish." + } +} +finally { + Pop-Location +}