From 4be5b39022b4361de8b925d1b3bf69ec1a096a31 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:28 -0400 Subject: [PATCH] ci: optional MSI + exe code-signing in release.yml --- .forgejo/workflows/release.yml | 83 ++++++++++++++++++++++++++++++++++ docs/RELEASING.md | 47 +++++++++++++++---- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6e3f019..f5708dd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -35,6 +35,24 @@ jobs: with: 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) run: dotnet restore TeamsISO.Windows.slnf @@ -66,6 +84,42 @@ jobs: --output publish/TeamsISO-Console /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 run: > dotnet build installer/TeamsISO.Installer.wixproj @@ -82,6 +136,35 @@ jobs: "name=$($msi.Name)" >> $env:GITHUB_OUTPUT 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 uses: actions/upload-artifact@v3 with: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index dc314da..a1617d4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -36,15 +36,44 @@ The workflow will: first if it doesn't exist. Pre-release flag is set automatically when the 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 -v1.0 release, sign the MSI with an EV cert before publishing: +The release workflow has optional signtool integration. It runs only when the +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. -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). +### Enabling signing -Until that lands, downstream users will see the standard Windows SmartScreen -warning on first launch — annoying but not blocking for early adopters. +Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso` +→ 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.