ci: optional MSI + exe code-signing in release.yml

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:28 -04:00
parent 57c2922d1c
commit 4be5b39022
2 changed files with 121 additions and 9 deletions

View file

@ -35,6 +35,24 @@ jobs:
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
# Probe for the signing cert up front and expose a step output downstream
# steps can use in their `if:` guards. We can't reference `secrets.*`
# directly from `if:` (Forgejo/GitHub policy), so we set a dummy env
# variable from the secret and check whether it's non-empty here.
- name: Detect signing configuration
id: signcfg
shell: pwsh
env:
PFX_PROBE: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
run: |
if ($env:PFX_PROBE) {
"enabled=true" >> $env:GITHUB_OUTPUT
Write-Host "Code-signing: ENABLED (cert secret detected)."
} else {
"enabled=false" >> $env:GITHUB_OUTPUT
Write-Host "Code-signing: DISABLED (SIGN_CERT_PFX_BASE64 not set). Build will produce unsigned binaries."
}
- name: Restore (Windows solution filter) - name: Restore (Windows solution filter)
run: dotnet restore TeamsISO.Windows.slnf run: dotnet restore TeamsISO.Windows.slnf
@ -66,6 +84,42 @@ jobs:
--output publish/TeamsISO-Console --output publish/TeamsISO-Console
/p:Version=${{ steps.ver.outputs.version }} /p:Version=${{ steps.ver.outputs.version }}
# Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded
# binaries are signed too. Skipped silently when the signing secrets
# aren't configured — that's the default state and keeps unsigned builds
# working unchanged.
#
# To enable signing, set both Forgejo Actions secrets:
# SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
# ( certutil -encode in.pfx out.b64; strip BEGIN/END lines )
# SIGN_CERT_PASSWORD — the PFX password
# Optionally:
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
- name: Sign TeamsISO.exe (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
shell: pwsh
run: |
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
| Where-Object { $_.FullName -match '\\x64\\' } `
| Select-Object -First 1
if (-not $signtool) { throw 'signtool.exe not found on runner' }
& $signtool.FullName sign `
/f $pfxPath `
/p $env:SIGN_CERT_PASSWORD `
/fd SHA256 `
/td SHA256 `
/tr $tsUrl `
'publish/TeamsISO/TeamsISO.exe'
if ($LASTEXITCODE -ne 0) { throw "signtool failed on TeamsISO.exe (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force
- name: Build MSI installer - name: Build MSI installer
run: > run: >
dotnet build installer/TeamsISO.Installer.wixproj dotnet build installer/TeamsISO.Installer.wixproj
@ -82,6 +136,35 @@ jobs:
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT "name=$($msi.Name)" >> $env:GITHUB_OUTPUT
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)" Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
# Sign the produced MSI itself. Same gate as exe signing — runs only if
# the cert secret is set. Splitting the two stages means the inner exe
# is signed before being embedded, AND the wrapping MSI carries its own
# signature for SmartScreen.
- name: Sign MSI (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
MSI_PATH: ${{ steps.msi.outputs.path }}
shell: pwsh
run: |
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
| Where-Object { $_.FullName -match '\\x64\\' } `
| Select-Object -First 1
& $signtool.FullName sign `
/f $pfxPath `
/p $env:SIGN_CERT_PASSWORD `
/fd SHA256 `
/td SHA256 `
/tr $tsUrl `
$env:MSI_PATH
if ($LASTEXITCODE -ne 0) { throw "signtool failed on MSI (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force
- name: Upload MSI as workflow artifact - name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View file

@ -36,15 +36,44 @@ The workflow will:
first if it doesn't exist. Pre-release flag is set automatically when the first if it doesn't exist. Pre-release flag is set automatically when the
tag contains `-alpha`, `-beta`, or `-rc`. tag contains `-alpha`, `-beta`, or `-rc`.
## Code signing (TODO) ## Code signing
The `wixproj` has a `SignOutput` property hook but no actual cert wiring. For a The release workflow has optional signtool integration. It runs only when the
v1.0 release, sign the MSI with an EV cert before publishing: signing-cert secrets are configured on the repository — without them, builds
remain unsigned and produce a SmartScreen warning on first launch.
1. Add a `SIGNING_CERT_BASE64` and `SIGNING_CERT_PASSWORD` to repo Secrets. ### Enabling signing
2. Decode the cert into the runner's cert store at the start of the workflow.
3. Set `/p:SignOutput=true` on the `dotnet build` of the wixproj and configure
`signtool` invocation (the installer project will need a custom target).
Until that lands, downstream users will see the standard Windows SmartScreen Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso`
warning on first launch — annoying but not blocking for early adopters. → Settings → Actions → Secrets:
| Secret | Required | Notes |
| --- | --- | --- |
| `SIGN_CERT_PFX_BASE64` | yes | Base64 of your code-signing PFX file. Generate with `certutil -encode in.pfx out.b64`, then strip the `-----BEGIN/END CERTIFICATE-----` lines. |
| `SIGN_CERT_PASSWORD` | yes | The PFX password. |
| `SIGN_TIMESTAMP_URL` | no | RFC 3161 timestamp server. Defaults to `http://timestamp.digicert.com`. |
When all three are present, the workflow:
1. Decodes the PFX to a temp file on the runner before building.
2. Signs `publish/TeamsISO/TeamsISO.exe` after publish, before MSI build, so the
binary embedded in the MSI is signed too.
3. Signs the produced MSI itself after WiX builds it.
4. Wipes the temp PFX from disk.
Both signing steps use SHA-256 for both the file hash and the timestamp digest,
which is what current Microsoft / SmartScreen guidance requires.
### Cert types
- **OV (Organization Validation, ~$200/yr).** SmartScreen reputation is built
per-publisher over time; brand-new OV certs still trip the warning until
enough downloads accumulate.
- **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted
immediately. Token-based — to use one in CI you'll need to either (a) keep
the runner on a host with the token plugged in, or (b) move to a cloud
signing service like Azure Trusted Signing or DigiCert KeyLocker.
For v1.0 we recommend the Azure Trusted Signing route: replace the PFX block
in `release.yml` with `azure/trusted-signing-action` once an Azure subscription
is set up. The current PFX path is the simplest thing that works for now.