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.

StageWhat the job doesFailure it throws
Certificate deliveryDecodes a base64 secret into a .pfx under $env:RUNNER_TEMPThe input is not a valid Base-64 string, or a zero-byte file when the secret resolved empty
Certificate loadsigntool sign /f <pfx> /p <password>, or /n and /sha1 against a certificate storeSignTool Error: The specified PFX password is not correct, SignTool Error: No certificates were found that met all the given criteria
SigningHashes the file and encrypts the digest with the private keySignerSign() failed (-2147024891/0x80070005) when the key sits in a store the job user cannot read
Timestamping/tr <url> /td SHA256 fetches a countersignatureThe specified timestamp server either could not be reached or returned an invalid response
Verificationsigntool verify /pa /vA 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 SilentlyContinue

Three details in that file carry the secret handling rule.

  1. 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.
  2. The certificate never enters the workspace. dist/** is the only upload path, and $env:RUNNER_TEMP is outside the checkout, so the two cannot intersect.
  3. The sign job declares environment: release. Drop that line and environment secrets resolve to empty strings, which produces the zero-byte .pfx from the diagnosis table.

Every artifact the signing job touches, where it lives, and how long it survives:

ArtifactWhere it livesHow long it survives
Base64 .pfx with the private keyEnvironment secret in GitHubUntil rotation or certificate expiry
Certificate passwordEnvironment secret in GitHubUntil rotation
Decoded signing.pfx$env:RUNNER_TEMP on the runner's encrypted volumeDeleted by the teardown step, and the volume is destroyed after the build
Timestamp authority URLRepository variableUntil edited
Unsigned build outputWorkflow artifactRepository artifact retention, 90 days by default
Signed .exe and .dllWorkflow artifact and the releaseRetention 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:

LabelImagevCPUMemoryStorageRate per minute
warp-windows-latest-x64-4xWindows Server 2022416GB256GB SSD$0.016
warp-windows-2025-x64-4xWindows Server 2025416GB256GB SSD$0.016
warp-windows-2025-x64-8xWindows Server 2025832GB256GB SSD$0.032
warp-windows-2025-x64-16xWindows Server 20251664GB256GB SSD$0.064
warp-windows-2025-vs2026-x64-8xWindows Server 2025 with Visual Studio 2026832GB256GB 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.

StepLabelTime per releaseReleasesMinutesCost
Build and publishwarp-windows-2025-x64-8x14:008112.0$3.58
Download artifact and resolve signtoolwarp-windows-2025-x64-4x0:2082.7$0.04
Decode and check the certificatewarp-windows-2025-x64-4x0:0480.5$0.01
Sign 6 files with timestampswarp-windows-2025-x64-4x0:2483.2$0.05
Verify signatures and countersignatureswarp-windows-2025-x64-4x0:0881.1$0.02
Total119.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.