diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index b9ec7fef..409674da 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -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: | diff --git a/.signpath/policies/stemdeck/release-signing.yml b/.signpath/policies/stemdeck/release-signing.yml new file mode 100644 index 00000000..d4e607d0 --- /dev/null +++ b/.signpath/policies/stemdeck/release-signing.yml @@ -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. diff --git a/README.md b/README.md index f38d4cb8..0257ca54 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/packaging/windows/README-WINDOWS.txt b/packaging/windows/README-WINDOWS.txt index 968658be..09098635 100644 --- a/packaging/windows/README-WINDOWS.txt +++ b/packaging/windows/README-WINDOWS.txt @@ -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 ----- diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 0111b472..25c7673e 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -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