diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..dcd14b8
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,188 @@
+# 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.'
diff --git a/.signpath/artifact-configurations/exes.xml b/.signpath/artifact-configurations/exes.xml
new file mode 100644
index 0000000..5587b0f
--- /dev/null
+++ b/.signpath/artifact-configurations/exes.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.signpath/artifact-configurations/installer.xml b/.signpath/artifact-configurations/installer.xml
new file mode 100644
index 0000000..5ed732f
--- /dev/null
+++ b/.signpath/artifact-configurations/installer.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index fc4c534..d4a17bd 100644
--- a/README.md
+++ b/README.md
@@ -113,3 +113,21 @@ saved identity can take.
## Third-party
- [ViGEmClient](https://github.com/nefarius/ViGEmClient) — MIT License, built from source as a static library
+
+## Code signing policy
+
+Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org).
+
+Releases are built and signed by the [Release workflow](.github/workflows/release.yml) from this repository's source. Only this project's own executables and installer are signed. The bundled ViGEmBus installer is signed by its own publisher and passes through unchanged.
+
+Team roles:
+
+- Committers and reviewers: [Dylan Deverill](https://github.com/ddeverill)
+- Approvers: [Dylan Deverill](https://github.com/ddeverill)
+
+### Privacy
+
+SteamlessController sends no data about you or your system anywhere. It makes two kinds of network request:
+
+- The controller remapping window loads its fonts from Google Fonts, which receives the usual details of a web request, such as your IP address.
+- If the ViGEmBus driver is missing, clicking the notification that says so opens its download page in your browser.