Harden Windows signing verification in CI - #28
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the Windows signing verification flow in Buildkite CI by making the Windows build step fail closed (both for produced artifacts and the downloaded signing tools) and by ensuring the Node test suite actually runs in CI.
Changes:
- Wraps native command execution in the Windows Buildkite step to prevent non-zero exit codes from being masked.
- Moves Windows artifact signature verification into a dedicated PowerShell script that enforces presence of
dist\*.exeand verifies expected signer subject (and optional issuer). - Adds a Buildkite “Tests” step that runs
npm teston the default queue.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
.buildkite/pipeline.yml |
Hardens Windows step execution semantics and adds a CI test step. |
.buildkite/commands/verify_windows_signature.ps1 |
Adds fail-closed Authenticode verification for Windows artifacts, including signer identity checks. |
.buildkite/commands/setup_windows_code_signing.ps1 |
Verifies integrity of downloaded signing tools via SHA256 pinning or Microsoft Authenticode signature validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
6656710 to
6d66d8d
Compare
mokagio
left a comment
There was a problem hiding this comment.
Thanks for this PR as well.
As in #27, I'm cursious whether this is a refinement motivated by a failure that was encountered or by caution.
It seems that Buildkite is not reporting the build status in the commits and PR checks, which is a problem we'll need to address.
In the meantime, I can confirm the new tests step passes:
Unfortunately, the new code in the Windows flow fails to parse:
[2026-07-06T21:40:49Z] At line:5 char:27
[2026-07-06T21:40:49Z] + [Parameter(Mandatory = )]
[2026-07-06T21:40:49Z] + ~
[2026-07-06T21:40:49Z] Missing statement after '=' in named argument.
[2026-07-06T21:40:49Z] At line:5 char:27
[2026-07-06T21:40:49Z] + [Parameter(Mandatory = )]
[2026-07-06T21:40:49Z] + ~
[2026-07-06T21:40:49Z] Missing closing ')' in expression.
[2026-07-06T21:40:49Z] At line:6 char:13
[2026-07-06T21:40:49Z] + [string],
[2026-07-06T21:40:49Z] + ~
[2026-07-06T21:40:49Z] Parameter declarations are a comma-separated list of variable names with optional initializer expressions.
[2026-07-06T21:40:49Z] At line:6 char:13
[2026-07-06T21:40:49Z] + [string],
[2026-07-06T21:40:49Z] + ~
[2026-07-06T21:40:49Z] Missing ')' in function parameter list.
[2026-07-06T21:40:49Z] At line:5 char:27
[2026-07-06T21:40:49Z] + [Parameter(Mandatory = )]
[2026-07-06T21:40:49Z] + ~
[2026-07-06T21:40:49Z] Attribute argument must be a constant or a script block.
[2026-07-06T21:40:49Z] + CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
[2026-07-06T21:40:49Z] + FullyQualifiedErrorId : MissingExpressionInNamedArgument
|
@mokagio I don't have much experience with signing apps. I asked AI to review PR 21 at WordCamp Europe and its hardening notes seemed like a good addition to be cautious. So, I asked it to split them in smaller PRs. Feel free to make this PR your own as per your discretion :) |
|
@ashfame no worries! And thanks for the clarification. There's always room for improvements, and these are welcome indeed. |
| agents: { queue: windows } | ||
| plugins: [$CI_TOOLKIT, $NVM_PLUGIN] | ||
| command: | | ||
| $ErrorActionPreference = "Stop" |
There was a problem hiding this comment.
I'd suggest to move this whole command to an external script to avoid unexpected issues with the Buildkite variable interpolation (https://buildkite.com/docs/pipelines/configure/environment-variables#runtime-variable-interpolation). It also makes the code easier to read.
For an example of what might go wrong when using scripts directly in the .yml, I think $true here would need escaping so Buildkite doesn't attempt to replace it.
| $ErrorActionPreference = "Stop" | ||
| Set-StrictMode -Version Latest | ||
|
|
||
| & "setup_azure_trusted_signing.ps1" |
There was a problem hiding this comment.
So we run setup_azure_trusted_signing.ps1 from a8c-ci-toolkit-buildkite-plugin, which I think already executes signtool and loads the DLib? Then later in this script, we test the tool integrity after that.
I'd say installation, verification and execution need to be separate phases, so perhaps this should be in a8c-ci-toolkit, immediately after download and before the smoke test.
| } | ||
|
|
||
| $actualSubject = $signature.SignerCertificate.Subject | ||
| if ($actualSubject.IndexOf($env:WINDOWS_EXPECTED_SIGNER_SUBJECT.Trim(), [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { |
There was a problem hiding this comment.
I'm finding this check a bit loose (IndexOf on the subject) 🤔 Not sure if this can also change?
Maybe there's a better way, but I'm not sure how it would work (I guess we need'd to look into https://learn.microsoft.com/en-us/azure/artifact-signing/concept-certificate-management#subscriber-identity-validation-eku -- )
|
|
||
| foreach ($artifact in $artifacts) { | ||
| Write-Host "Verifying Authenticode signature for $($artifact.FullName)" | ||
| & $env:SIGNTOOL_PATH verify /pa /v $artifact.FullName |
There was a problem hiding this comment.
Looking at this, I wonder if we should consider /tw so that "a warning should be generated if the signature is not time stamped"?
Three conflicts, all resolved in trunk's favour: - `.buildkite/pipeline.yml`: this branch ran `npm run build:once` as its own Buildkite step before `npm run dist:win`. Trunk's `dist:win` now chains `build:once` itself (#122), so the explicit step is redundant. The hardening this branch adds — `Invoke-NativeCommand` around the native calls, and the extracted `verify_windows_signature.ps1` — is kept. - `scripts/azure-sign.cjs`: trunk landed the same `/debug` passthrough with an explanatory comment; kept the comment. - `test/azure-sign.test.cjs`: trunk moved the suite to `tests/unit/`, so the `require` of `scripts/azure-sign.cjs` gains a path segment. `npm test` (1046 passing) and `npm run lint` are green on the merge result. --- Generated with the help of Claude Code, https://claude.com/claude-code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added here on 2026-07-06, when `npm test` ran in no CI at all. #68 landed three weeks later and now runs the unit suite in GitHub Actions on macOS and Windows, on both the system Node and the one Electron bundles — so this step is a strict subset of that, on a self-hosted agent that holds the signing credentials. The suite needs no secrets, which is what makes Actions the right place for it: #65 sets the split as everything secretless on Actions, Buildkite only for the signed artifacts, and lists "Buildkite stays untouched and keeps producing the signed artifacts" as a constraint. --- Generated with the help of Claude Code, https://claude.com/claude-code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@ashfame you'll notice I merged I haven't had the time to look at how it behaves yet, not to address @iangmaia or Copilot's comment. |
Stacked on #27. Follow-up hardening from the PR 21 Windows signing review.
This PR keeps the Windows Azure Trusted Signing migration, but makes CI fail closed around the signed artifact and the downloaded signing tools:
CI setup note: before this lands, the Windows queue needs WINDOWS_EXPECTED_SIGNER_SUBJECT configured to a stable substring of the expected WordPress/Automattic signing certificate subject. The SHA256 env vars are optional but preferable if the downloaded tool versions are meant to be pinned exactly.