Files
Dylan DeverillandClaude Opus 5.5 1a1253a384 Build and sign releases through SignPath on GitHub Actions
SignPath Foundation signs only what a trusted build system built from the
repository, so releases move from local builds to a workflow. A tag push
builds, signs the three executables, compiles the installer around the
signed copies, signs the installer, and opens a draft release. A manual run
does the same under test-signing, without the release.

The README gains the code signing policy the Foundation requires, including
a privacy note for the Google Fonts request the remap window makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:57:33 -07:00

189 lines
7.7 KiB
YAML

# Builds, signs and packages a release.
#
# SignPath Foundation signs only what a trusted build system built from this
# repository, so this workflow is the only way a signed build can exist. A
# local `cmake --build --preset release` + Inno compile still works for
# testing, but what it produces cannot be signed.
#
# Signing happens in two rounds, because the installer contains the
# executables and so has to be compiled around the signed copies:
#
# 1. build -> sign the three .exes (artifact configuration "exes")
# 2. compile the installer around them -> sign Setup.exe ("installer")
#
# Under release-signing, each round waits for a manual approval in SignPath,
# so a release takes two approvals. test-signing needs none.
#
# Triggers:
# * a pushed tag vX.Y -> release-signing, then a DRAFT GitHub release
# with the signed installer attached
# * Run workflow (manual) -> test-signing by default; the installer is
# left as a workflow artifact, no release made
#
# Configuration, under Settings > Secrets and variables > Actions:
# secret SIGNPATH_API_TOKEN API token of the SignPath CI user
# variable SIGNPATH_ORGANIZATION_ID
# variable SIGNPATH_PROJECT_SLUG
#
# Artifact configurations must exist in SignPath with the slugs "exes" and
# "installer"; their contents are versioned in .signpath/.
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
signing-policy:
description: SignPath signing policy
type: choice
options: [test-signing, release-signing]
default: test-signing
permissions:
contents: write # create the draft release
actions: read # lets SignPath fetch the uploaded artifacts
env:
SIGNING_POLICY: ${{ startsWith(github.ref, 'refs/tags/') && 'release-signing' || inputs.signing-policy || 'test-signing' }}
BUILD_DIR: build\release\Release
VIGEM_FILE: ViGEmBus_1.22.0_x64_x86_arm64.exe
VIGEM_URL: https://github.com/nefarius/ViGEmBus/releases/download/v1.22.0/ViGEmBus_1.22.0_x64_x86_arm64.exe
VIGEM_SHA256: 89220a7865076b342892f98865f3499fb7c4cfd673159e89d352c360fd014c6a
jobs:
release:
runs-on: windows-latest
# Covers two waits for a human to approve in SignPath.
timeout-minutes: 300
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v5
# A tag that disagrees with the script would publish an installer under
# one version that installs another. The script already refuses an exe
# whose version differs from its own; this closes the other side.
- name: Check tag matches installer version
if: startsWith(github.ref, 'refs/tags/')
run: |
$iss = Get-Content resources\InnoInstallerScript.iss -Raw
if ($iss -notmatch '#define MyAppVersion "([^"]+)"') { throw 'MyAppVersion not found in the installer script' }
$scriptVersion = $Matches[1]
$tagVersion = '${{ github.ref_name }}'.TrimStart('v')
if ($tagVersion -ne $scriptVersion) {
throw "Tag ${{ github.ref_name }} does not match MyAppVersion $scriptVersion"
}
- name: Build
run: |
cmake --preset release
cmake --build --preset release
# ---- Round one: the executables -------------------------------------
- name: Stage unsigned executables
run: |
New-Item -ItemType Directory staging\exes | Out-Null
foreach ($exe in 'SteamlessController.exe', 'SteamlessDeviceCycle.exe', 'ViGEmBusProbe.exe') {
Copy-Item "$env:BUILD_DIR\$exe" staging\exes\
}
- name: Upload unsigned executables
id: upload-exes
uses: actions/upload-artifact@v7
with:
name: unsigned-exes
path: staging\exes\
- name: Sign executables
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNING_POLICY }}
artifact-configuration-slug: exes
github-artifact-id: ${{ steps.upload-exes.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 7200
output-artifact-directory: signed\exes
# Inno reads from BUILD_DIR, so the signed copies replace the unsigned
# ones there rather than the script learning a second source directory.
- name: Put signed executables in place
run: Copy-Item signed\exes\*.exe $env:BUILD_DIR\ -Force
# ---- Round two: the installer ---------------------------------------
# Not in the repository (*.exe is gitignored). Pinned by hash so the
# installer can only ever carry the exact binary nefarius published.
- name: Fetch ViGEmBus installer
run: |
Invoke-WebRequest $env:VIGEM_URL -OutFile "resources\$env:VIGEM_FILE"
$hash = (Get-FileHash "resources\$env:VIGEM_FILE" -Algorithm SHA256).Hash
if ($hash -ne $env:VIGEM_SHA256) { throw "ViGEmBus hash mismatch: got $hash" }
- name: Compile installer
run: |
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
choco install innosetup -y --no-progress
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup install failed' }
}
& $iscc resources\InnoInstallerScript.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" }
- name: Upload unsigned installer
id: upload-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-installer
path: Output\SteamlessController-Setup.exe
- name: Sign installer
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNING_POLICY }}
artifact-configuration-slug: installer
github-artifact-id: ${{ steps.upload-installer.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 7200
output-artifact-directory: signed\installer
# Test certificates do not chain to a trusted root, so only a
# release-signed build can be held to this.
- name: Verify signatures
if: env.SIGNING_POLICY == 'release-signing'
run: |
$files = @(Get-ChildItem signed\exes\*.exe) + @(Get-Item signed\installer\SteamlessController-Setup.exe)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
"{0}: {1} ({2})" -f $file.Name, $sig.Status, $sig.SignerCertificate.Subject
if ($sig.Status -ne 'Valid') { throw "$($file.Name) is not validly signed" }
}
- name: Upload signed installer
uses: actions/upload-artifact@v7
with:
name: SteamlessController-Setup-${{ env.SIGNING_POLICY }}
path: signed\installer\SteamlessController-Setup.exe
# A draft, so the notes get written and the installer tried before
# anyone can download it.
- name: Create draft release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create '${{ github.ref_name }}' signed\installer\SteamlessController-Setup.exe `
--draft --verify-tag `
--title 'SteamlessController-${{ github.ref_name }}' `
--notes 'Release notes to follow.'