Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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"
```
147 changes: 147 additions & 0 deletions scripts/finalize-release.ps1
Original file line number Diff line number Diff line change
@@ -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
}
Loading