Code Signing Windows Binaries in GitHub Actions
signtool needs a certificate, a password, and a timestamp authority on a runner that starts with none of them. Here is the tag-only signing job and its cost.
Signing a Windows binary in GitHub Actions means getting three things onto a runner that starts without any of them: a code signing certificate with its private key, the secret that unlocks it, and a reachable timestamp authority. The workflow below does that in a job gated on tag pushes, signs with signtool from the Windows SDK already on the image, verifies the signature and its countersignature before anything is uploaded, and leaves the certificate on a machine that is destroyed when the job ends.
This guide covers the three stages of the signing flow and the failure each one throws, the workflow YAML for a tag-only signing job, the rule that keeps the certificate out of artifacts and logs, and the arithmetic for what signing adds to a monthly Windows bill. The runner labels and rates come from the Windows runner catalog.
Diagnosis
A Windows signing job has three stages, and a failure in any one of them prints a message that names the stage badly. Read the stage first, because the fixes have nothing in common.
| Stage | What the job does | Failure it throws |
|---|---|---|
| Certificate delivery | Decodes a base64 secret into a .pfx under $env:RUNNER_TEMP | The input is not a valid Base-64 string, or a zero-byte file when the secret resolved empty |
| Certificate load | signtool sign /f <pfx> /p <password>, or /n and /sha1 against a certificate store | SignTool Error: The specified PFX password is not correct, SignTool Error: No certificates were found that met all the given criteria |
| Signing | Hashes the file and encrypts the digest with the private key | SignerSign() failed (-2147024891/0x80070005) when the key sits in a store the job user cannot read |
| Timestamping | /tr <url> /td SHA256 fetches a countersignature | The specified timestamp server either could not be reached or returned an invalid response |
| Verification | signtool verify /pa /v | A certificate chain processed, but terminated in a root certificate which is not trusted by the trust provider for an internal CA |
Delivery failures look like signing failures. GitHub resolves a missing secret to an empty string rather than failing the step, so FromBase64String writes a zero-byte file and the first visible error arrives one step later from signtool. Three scoping rules produce an empty secret: an environment secret reaches only jobs that declare that environment:, an organization secret reaches only repositories on its access list, and a workflow triggered by a pull_request from a fork receives no secrets at all. GitHub documents the boundary in using secrets in GitHub Actions.
The password crosses the process command line. signtool sign /p <password> puts the value in the argument list of a process on the machine. On an ephemeral single-tenant runner the exposure window is the job itself, and each WarpBuild runner runs in its own virtual machine that is created on demand and destroyed after the build, as described in the runner security documentation. Certificate stores and hardware CSPs avoid the argument entirely through /csp and /kc, which matters more on any machine that outlives one job.
A missing timestamp is a failure that shows up months later. A signature with no countersignature stops verifying the day the certificate expires, and the binaries affected are the ones users already downloaded. The job that produced them is long green by then, which is why the verification step below asserts on the timestamp rather than on the exit code alone.
Key custody decides which delivery method is available. Publicly trusted code signing certificates issued since June 1, 2023 must have their private keys generated and stored in a hardware crypto module under the CA/Browser Forum code signing requirements, checked on 2026-08-13. A .pfx in a repository secret therefore applies to internal CAs and to certificates issued before that date. For a publicly trusted certificate the job calls a network signing service or a hardware token through signtool with /csp and /kc, and the secret it carries is the service credential rather than the key. The rest of the workflow shape is identical.
Fix
Five rules turn a signing job that works on a laptop into one that works on a runner.
Gate signing on the tag. Build on every push and pull request, sign in a separate job with if: startsWith(github.ref, 'refs/tags/v'). The certificate then reaches the small number of machines that produce releases, and fork pull requests never enter a job that expects a secret.
Deliver the certificate outside the workspace. Decode it into $env:RUNNER_TEMP. An upload glob rooted at the checkout directory cannot match a path outside it, so the certificate cannot ride along in an artifact by accident.
Resolve signtool explicitly. The Windows SDK installs it under C:\Program Files (x86)\Windows Kits\10\bin\<sdk-version>\x64\signtool.exe. Pick the newest version present at runtime instead of pinning a path that changes with the image, and read the option list in the SignTool reference.
Timestamp with a retry. Timestamp authorities rate limit and go down. Two retries with a short backoff turn a transient HTTP failure into a slower job rather than a failed release.
Verify before the upload step. signtool verify /pa /v runs the default Authenticode policy against the signed file, and Get-AuthenticodeSignature exposes the countersignature so the job can fail when the timestamp is absent. Both finish in seconds, ahead of a publish step that is expensive to undo.
Moving the job onto a WarpBuild runner is a label change. WarpBuild runners register with GitHub as self-hosted runners carrying warp- labels, so editing runs-on is the whole migration. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the Windows images carry the same Windows SDK tooling as the GitHub-hosted equivalents, so the signtool invocation stays as it is. When a signing step behaves differently on the runner than on a developer machine, the Action Debugger pauses the workflow and opens a session on the runner, which is the shortest route to inspecting a certificate store in place. The SOC 2 Type 2 report covering Security, Availability, and Confidentiality is available through the WarpBuild trust center.
Configuration
The workflow builds on every pull request and signs only when a tag is pushed.
name: windows-release
on:
pull_request:
push:
tags: ["v*"]
jobs:
build:
runs-on: warp-windows-2025-x64-8x
steps:
- uses: actions/checkout@v4
- name: Publish
run: dotnet publish src/App/App.csproj -c Release -r win-x64 -o dist
- uses: actions/upload-artifact@v4
with:
name: unsigned-win-x64
path: dist/**
sign:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: warp-windows-2025-x64-4x
environment: release
steps:
- uses: actions/download-artifact@v4
with:
name: unsigned-win-x64
path: dist
- name: Resolve signtool
shell: pwsh
run: |
$tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -like "*\x64\*" } |
Sort-Object FullName -Descending |
Select-Object -First 1
Add-Content -Path $env:GITHUB_ENV -Value "SIGNTOOL=$($tool.FullName)"
- name: Write the certificate to RUNNER_TEMP
shell: pwsh
env:
CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: |
$path = Join-Path $env:RUNNER_TEMP "signing.pfx"
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($env:CERT_PFX_BASE64))
$count = (Get-PfxData -FilePath $path -Password (
ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force
)).EndEntityCertificates.Count
if ($count -lt 1) { throw "no end entity certificate in the decoded pfx" }
Add-Content -Path $env:GITHUB_ENV -Value "CERT_PATH=$path"
- name: Sign with a timestamp
shell: pwsh
env:
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
TIMESTAMP_URL: ${{ vars.TIMESTAMP_URL }}
run: |
$files = (Get-ChildItem dist -Recurse -Include *.exe,*.dll).FullName
foreach ($attempt in 1..3) {
& $env:SIGNTOOL sign /fd SHA256 /td SHA256 `
/tr $env:TIMESTAMP_URL `
/f $env:CERT_PATH /p $env:CERT_PASSWORD `
@files
if ($LASTEXITCODE -eq 0) { break }
Start-Sleep -Seconds (15 * $attempt)
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify the signature and the countersignature
shell: pwsh
run: |
foreach ($file in (Get-ChildItem dist -Recurse -Include *.exe,*.dll)) {
& $env:SIGNTOOL verify /pa /v $file.FullName
if ($LASTEXITCODE -ne 0) { throw "$($file.Name): signtool verify failed" }
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") { throw "$($file.Name): $($sig.Status)" }
if ($null -eq $sig.TimeStamperCertificate) { throw "$($file.Name): no timestamp" }
}
- uses: actions/upload-artifact@v4
with:
name: signed-win-x64
path: dist/**
- name: Remove the certificate
if: always()
shell: pwsh
run: Remove-Item $env:CERT_PATH -Force -ErrorAction SilentlyContinueThree details in that file carry the secret handling rule.
- The secret expressions appear in
env:blocks. A${{ secrets.X }}expression written inside the script body is substituted into the script file on disk before the shell runs it, so the value lands in a file the job can read back. Mapping it to an environment variable keeps it in the process environment. - The certificate never enters the workspace.
dist/**is the only upload path, and$env:RUNNER_TEMPis outside the checkout, so the two cannot intersect. - The sign job declares
environment: release. Drop that line and environment secrets resolve to empty strings, which produces the zero-byte.pfxfrom the diagnosis table.
Every artifact the signing job touches, where it lives, and how long it survives:
| Artifact | Where it lives | How long it survives |
|---|---|---|
Base64 .pfx with the private key | Environment secret in GitHub | Until rotation or certificate expiry |
| Certificate password | Environment secret in GitHub | Until rotation |
Decoded signing.pfx | $env:RUNNER_TEMP on the runner's encrypted volume | Deleted by the teardown step, and the volume is destroyed after the build |
| Timestamp authority URL | Repository variable | Until edited |
| Unsigned build output | Workflow artifact | Repository artifact retention, 90 days by default |
Signed .exe and .dll | Workflow artifact and the release | Retention for the artifact, indefinitely for the release asset |
Each runner has its own encrypted storage volume, created on demand and destroyed after each build, and WarpBuild does not access or store build secrets: they stay in your source code repository and reach only the runner environment. Repository-level custody of the two secrets above is covered in managing GitHub Actions secrets across repositories.
Cost or Time Model
The signing commands are cheap. Hashing a handful of PE files and fetching one countersignature per file finishes in seconds, and the money sits in the job around them.
Windows labels and rates, from the WarpBuild cloud runners documentation and the pricing page, checked on 2026-08-13:
| Label | Image | vCPU | Memory | Storage | Rate per minute |
|---|---|---|---|---|---|
warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | $0.016 |
warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16GB | 256GB SSD | $0.016 |
warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32GB | 256GB SSD | $0.032 |
warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64GB | 256GB SSD | $0.064 |
warp-windows-2025-vs2026-x64-8x | Windows Server 2025 with Visual Studio 2026 | 8 | 32GB | 256GB SSD | $0.032 |
Assumptions for the model, all stated so you can substitute your own: one desktop application repository, 8 tagged releases per month, a 14 minute build job on warp-windows-2025-x64-8x, a sign job on warp-windows-2025-x64-4x, 6 signed files per release, and per-minute billing of job time.
| Step | Label | Time per release | Releases | Minutes | Cost |
|---|---|---|---|---|---|
| Build and publish | warp-windows-2025-x64-8x | 14:00 | 8 | 112.0 | $3.58 |
| Download artifact and resolve signtool | warp-windows-2025-x64-4x | 0:20 | 8 | 2.7 | $0.04 |
| Decode and check the certificate | warp-windows-2025-x64-4x | 0:04 | 8 | 0.5 | $0.01 |
| Sign 6 files with timestamps | warp-windows-2025-x64-4x | 0:24 | 8 | 3.2 | $0.05 |
| Verify signatures and countersignatures | warp-windows-2025-x64-4x | 0:08 | 8 | 1.1 | $0.02 |
| Total | 119.5 | $3.70 |
Signing accounts for $0.12 of that $3.70. Now price the two alternatives.
Signing on every push instead of on tags, at 300 pushes per month, adds 300 x 0:56 of sign job time, which is 280 minutes and $4.48 at $0.016 per minute. It also puts the certificate on 300 machines rather than 8, and the machine count is the number that matters in a key custody review.
Shipping a release without a timestamp costs the re-sign. Artifacts expire at the 90 day default, so the fix is a rebuild and a re-sign of every affected release: 8 releases at 14:00 of build plus 0:56 of signing is 119.5 minutes and $3.70 in runner time, plus a support thread with every user whose installer stopped verifying. The TimeStamperCertificate assertion in the verify step is what keeps that arithmetic hypothetical.
For the full label list and image details, see the Windows runner catalog. For the workflow that wraps this job, see release build workflows on GitHub Actions, and for what a signature proves once it is attached, see code signing.
FAQ
Why does signtool report that no certificates were found that met all the given criteria?
That message comes from certificate selection rather than from signing. With /f the file was written but holds no code signing certificate, usually because the secret decoded to zero bytes or the .pfx was exported without its private key. With store-based selection through /n or /sha1 the subject name or thumbprint matches nothing in the store the job user can read. Print the certificate count from the decoded file before signing, as the workflow above does, so the job fails on the delivery step instead of the signing step.
Does a Windows signature need a timestamp if the certificate is valid today?
Yes. Without a countersignature from an RFC 3161 timestamp authority, every binary signed with that certificate stops verifying on the day the certificate expires, including installers users already downloaded. Pass /tr with the authority URL and /td SHA256, then check that Get-AuthenticodeSignature returns a TimeStamperCertificate before the artifact is uploaded.
How do I keep the certificate out of artifacts and logs?
Write the decoded .pfx into $env:RUNNER_TEMP, which sits outside the workspace, so no upload glob rooted at the checkout can match it. Pass the password through env: rather than inlining the secret expression in the script body, delete the file in an if: always() step, and keep the signing job on tag pushes so fork pull requests never reach it.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.