ci: sign the Windows release executable with SignPath (#394)

SignPath's OSS tier requires every job leading up to a signing request to run
on a GitHub-hosted agent, but the Windows release runs entirely on the
self-hosted runner. Split the workflow instead of moving the whole build:

- New `sign-exe` job on `windows-latest` builds only StemDeck.exe, uploads it
  as a workflow artifact, submits it to SignPath, and republishes the signed
  binary as an artifact. Version files are stamped before the build because
  SignPath restricts Foundation projects on PE product name and version.
- `build-and-upload` now depends on it, downloads the signed executable, and
  packages both variants around it via a new `-PrebuiltExe` flag on
  make-portable.ps1. That also removes the redundant second Rust build the CPU
  package used to trigger.
- make-portable.ps1 rejects an unsigned prebuilt binary before it reaches the
  zip, and the scan step reports the Authenticode status of what was packaged.
- workflow_dispatch entry point plus a release-only guard on the upload step so
  the integration can be exercised against a test-signing policy without
  cutting a tag.

Requires repo secret SIGNPATH_API_TOKEN and repo variable
SIGNPATH_ORGANIZATION_ID.

Adds the code signing policy and attribution required by the SignPath
Foundation terms.
This commit is contained in:
Tha.Les
2026-08-19 10:43:31 +01:00
committed by GitHub
parent 2dcebfc997
commit 7eba6340ef
5 changed files with 210 additions and 29 deletions
+109 -11
View File
@@ -3,6 +3,14 @@ name: Windows Release
on:
release:
types: [published]
# Dry-run entry point: builds and signs without publishing anything, so the SignPath
# integration can be exercised against the test-signing policy before a real tag is cut.
workflow_dispatch:
inputs:
signing_policy:
description: SignPath signing policy slug to submit against
required: false
default: test-signing
permissions: {}
@@ -10,33 +18,111 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Source the tag from the github context (evaluated by Actions) rather than
# $env:GITHUB_REF_NAME, which is only injected by runner >= 2.290. Keeps the
# build working on older self-hosted runners. (#212 follow-up)
# workflow_dispatch runs off a branch, so there is no vX.Y.Z tag to derive a version from.
PACKAGE_VERSION: ${{ github.event_name == 'release' && github.ref_name || '0.0.0-dev' }}
jobs:
# SignPath's OSS tier requires every job leading up to a signing request to run on a
# GitHub-hosted agent, so the executable is built here rather than on the self-hosted runner
# that packages it. This job builds nothing but StemDeck.exe; the heavy Python/torch bundling
# stays downstream.
sign-exe:
runs-on: windows-latest
timeout-minutes: 45
permissions:
contents: read
actions: read # the SignPath action reads job details and downloads the artifact
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Must run before the build: SignPath restricts Foundation projects on PE metadata, and
# Tauri stamps ProductName/FileVersion into the executable from these files.
- name: write version files
run: |
$version = $env:PACKAGE_VERSION -replace '^v', ''
if (-not $version) { throw "PACKAGE_VERSION is not set" }
(Get-Content "desktop/src-tauri/Cargo.toml") -replace '^version = ".*"', "version = `"$version`"" |
Set-Content "desktop/src-tauri/Cargo.toml"
(Get-Content "desktop/src-tauri/tauri.conf.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" |
Set-Content "desktop/src-tauri/tauri.conf.json"
Write-Host "Building StemDeck.exe version $version"
- name: build executable
working-directory: desktop
env:
CI: "true" # Tauri only accepts true/false here
run: |
rustup default stable
npm ci --include=dev
node node_modules/@tauri-apps/cli/tauri.js build
# The SignPath connector only accepts artifacts stored on the GitHub server.
- name: stage unsigned executable
run: |
New-Item -ItemType Directory -Force unsigned | Out-Null
Copy-Item -Force desktop/src-tauri/target/release/stemdeck.exe unsigned/
- name: upload unsigned executable
id: upload-unsigned
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stemdeck-exe-unsigned
path: unsigned/stemdeck.exe
- name: submit signing request
uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2.3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: stemdeck
signing-policy-slug: ${{ github.event_name == 'release' && 'release-signing' || inputs.signing_policy }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
# Release signing needs a human approval in the SignPath console; the 600s default
# expires long before an approver is likely to see the mail.
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: signed
- name: upload signed executable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stemdeck-exe-signed
path: signed/stemdeck.exe
build-and-upload:
needs: sign-exe
# Runner must be windows/x64 with PowerShell, Docker, and rustup.
runs-on: [self-hosted, windows, x64]
timeout-minutes: 90
permissions:
contents: write
# Source the tag from the github context (evaluated by Actions) rather than
# $env:GITHUB_REF_NAME, which is only injected by runner >= 2.290. Keeps the
# build working on older self-hosted runners. (#212 follow-up)
env:
REF_NAME: ${{ github.ref_name }}
defaults:
run:
shell: powershell
steps:
- name: clean workspace
run: |
Remove-Item -Recurse -Force .build, dist -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force .build, dist, signed -ErrorAction SilentlyContinue
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: download signed executable
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: stemdeck-exe-signed
path: signed
- name: write version files
run: |
$tag = $env:REF_NAME
if (-not $tag) { throw "REF_NAME is not set" }
$version = $tag -replace '^v', ''
$version = $env:PACKAGE_VERSION -replace '^v', ''
if (-not $version) { throw "PACKAGE_VERSION is not set" }
$json = "{`"version`": `"$version`"}"
Set-Content -Path "static/version.json" -Value $json -Encoding UTF8
(Get-Content "desktop/src-tauri/Cargo.toml") -replace '^version = ".*"', "version = `"$version`"" |
@@ -49,18 +135,22 @@ jobs:
Set-Content "desktop/package.json"
Write-Host "Wrote version $version to all version files"
# Both variants package the one signed executable from sign-exe, so the Rust build is not
# repeated here.
- name: build Windows NVIDIA
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 `
-PackageName StemDeck-Windows-x64.NVIDIA `
-PackageVersion "$env:REF_NAME" `
-PackageVersion "$env:PACKAGE_VERSION" `
-PrebuiltExe "$PWD\signed\stemdeck.exe" `
-StripVenv
- name: build Windows CPU
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 `
-PackageName StemDeck-Windows-x64 `
-PackageVersion "$env:REF_NAME" `
-PackageVersion "$env:PACKAGE_VERSION" `
-PrebuiltExe "$PWD\signed\stemdeck.exe" `
-CpuOnly `
-StripVenv
@@ -82,6 +172,13 @@ jobs:
Write-Host " $($hash.Hash) $($_.Name)"
}
Write-Host "Authenticode signatures on the packaged executables:"
Get-ChildItem -Path "dist" -Filter "StemDeck.exe" -Recurse -File |
ForEach-Object {
$sig = Get-AuthenticodeSignature -LiteralPath $_.FullName
Write-Host " $($_.FullName): $($sig.Status) $($sig.SignerCertificate.Subject)"
}
Write-Host "Pulling latest ClamAV scanner image..."
docker pull clamav/clamav:latest
@@ -94,6 +191,7 @@ jobs:
Write-Host "ClamAV scan completed successfully. No infected files reported."
- name: upload artifacts
if: github.event_name == 'release'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: |
@@ -0,0 +1,9 @@
# Build policy enforced by SignPath on every signing request submitted against the
# "release-signing" policy of the "stemdeck" project.
# Reference: https://docs.signpath.io/trusted-build-systems/github
github-policies:
build:
# A failed build must not be re-run just to obtain a signature.
disallow_reruns: true
# Branch rulesets are intentionally left out until the repository has matching rulesets
# configured; SignPath rejects requests whose repository does not satisfy them.
+33
View File
@@ -391,6 +391,39 @@ The author(s) of StemDeck provide this software "as is", without warranty of any
---
## Code signing policy
Windows releases of StemDeck are Authenticode-signed. Free code signing is provided by
[SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
The signed binary is `StemDeck.exe` inside `StemDeck-Windows-x64.zip` and
`StemDeck-Windows-x64.NVIDIA.zip`. The zip archives themselves are not signed; verify them with
the `.sha256` file published alongside each release asset. macOS and Linux builds are not
currently signed.
**Team roles**
| Role | Members |
|---|---|
| Committers | Thales Pereira ([@thcp](https://github.com/thcp)) |
| Reviewers | Thales Pereira ([@thcp](https://github.com/thcp)) |
| Approvers | Thales Pereira ([@thcp](https://github.com/thcp)) |
**How signing works**
The executable is built from this repository by the
[Windows Release workflow](.github/workflows/windows-release.yml) on a GitHub-hosted runner,
uploaded as a workflow artifact, and submitted to SignPath for signing. SignPath verifies the
build's origin (repository, branch, commit, and build job) before the certificate is applied.
Every release signing request is approved manually by an approver listed above.
**Privacy**
StemDeck does not transmit any personal data. All audio processing happens on the user's own
machine. See the [Disclaimer](#disclaimer) for details.
---
## Community
| Platform | Link |
+7
View File
@@ -8,6 +8,13 @@ Run
2. Double-click StemDeck.exe.
3. Let first-run setup prepare local runtime assets.
Code signing
------------
StemDeck.exe is Authenticode-signed. Free code signing provided by SignPath.io,
certificate by SignPath Foundation. The zip itself is not signed; verify it with
the .sha256 file published next to it on the release page.
Notes
-----
+52 -18
View File
@@ -4,6 +4,7 @@ param(
[string]$PackageName = "StemDeck-Windows-x64",
[string]$PackageVersion,
[switch]$SkipTauriBuild,
[string]$PrebuiltExe,
[switch]$CpuOnly,
[switch]$StripVenv
)
@@ -27,6 +28,19 @@ $DesktopDir = Join-Path $Root "desktop"
$TauriDir = Join-Path $DesktopDir "src-tauri"
$TargetExe = Join-Path $TauriDir "target\$Configuration\stemdeck.exe"
# -PrebuiltExe packages an executable produced (and signed) by an earlier job instead of
# building one here. The release pipeline uses it so both the CPU and NVIDIA packages ship the
# one SignPath-signed binary rather than rebuilding Rust twice.
if ($PrebuiltExe) {
if ($SkipTauriBuild) {
throw "-PrebuiltExe and -SkipTauriBuild are mutually exclusive."
}
if (-not (Test-Path -LiteralPath $PrebuiltExe)) {
throw "Prebuilt executable not found: $PrebuiltExe"
}
$TargetExe = (Resolve-Path -LiteralPath $PrebuiltExe).Path
}
function Require-Command([string]$Name) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
@@ -144,9 +158,11 @@ Remove -SkipTauriBuild or run the NVIDIA package build first so the CPU package
}
}
Require-Command "node"
Require-Command "npm"
Require-Command "cargo"
if (-not $PrebuiltExe) {
Require-Command "node"
Require-Command "npm"
Require-Command "cargo"
}
if (-not (Get-Command "py" -ErrorAction SilentlyContinue) -and -not (Get-Command "python" -ErrorAction SilentlyContinue)) {
throw "Python launcher not found. Install Python 3.12 on the Windows build agent."
@@ -237,29 +253,47 @@ if ($StripVenv) {
Remove-Item -Force
}
Push-Location $DesktopDir
try {
if (Test-Path "package-lock.json") {
npm ci --include=dev
} else {
npm install --include=dev
}
if (-not $PrebuiltExe) {
Push-Location $DesktopDir
try {
if (Test-Path "package-lock.json") {
npm ci --include=dev
} else {
npm install --include=dev
}
if (-not $SkipTauriBuild) {
$env:CI = "true" # Woodpecker sets CI=woodpecker; Tauri only accepts true/false
rustup default stable
Invoke-TauriBuild
} else {
Assert-Fresh-TauriBuild
if (-not $SkipTauriBuild) {
$env:CI = "true" # Woodpecker sets CI=woodpecker; Tauri only accepts true/false
rustup default stable
Invoke-TauriBuild
} else {
Assert-Fresh-TauriBuild
}
} finally {
Pop-Location
}
} finally {
Pop-Location
}
if (-not (Test-Path $TargetExe)) {
throw "Tauri executable not found at $TargetExe"
}
if ($PrebuiltExe) {
# Fail loudly if the signing job handed back an unsigned binary. A test-signing policy uses a
# self-signed certificate, so anything other than NotSigned is accepted here and only warned
# about; the release certificate yields Valid.
$Signature = Get-AuthenticodeSignature -LiteralPath $TargetExe
if ($Signature.Status -eq "NotSigned") {
throw "Prebuilt executable carries no Authenticode signature: $TargetExe"
}
if ($Signature.Status -ne "Valid") {
Write-Warning "Authenticode status is $($Signature.Status) (expected for a test-signing certificate)."
}
if ($Signature.SignerCertificate) {
Write-Host "Signed by : $($Signature.SignerCertificate.Subject)"
}
}
Copy-Item -Force $TargetExe (Join-Path $Stage "StemDeck.exe")
Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force