Split Windows signing submission from approval-bound collection

Production SignPath signing requests require manual approval in the
SignPath UI, so the previous single-job flow (submit with
wait-for-completion inside a 40-minute window) let approval latency fail
the Windows build, and any re-run rebuilt the binaries and submitted a
second request needing a second approval.

The Windows lane is now two jobs: sign-windows-agent builds the unsigned
executables, submits the SignPath request without waiting, and uploads a
7-day signing-request record; collect-windows-signing absorbs approval
latency by polling the recorded request, downloads the signed artifact
by request id, and keeps the existing verification and evidence steps.
If approval outlasts the 115-minute polling window, the collection job
fails with re-run guidance and "Re-run failed jobs" collects the same
recorded request - no rebuild, no resubmission. The legacy PFX
break-glass backend rides the same two-job shape via an artifact
hand-off. Workflow output wiring, artifact names, and evidence content
are unchanged for downstream consumers.

The shape test now pins the async invariants (no wait-for-completion:
true in the candidate workflow), and the code signing policy plus the
deployment-installability contract describe the two-phase flow.
This commit is contained in:
rcourtman 2026-08-11 15:50:25 +01:00
parent d2e25dcb63
commit 3826316eec
5 changed files with 168 additions and 21 deletions

View file

@ -31,7 +31,7 @@ on:
value: ${{ jobs.build.outputs.manifest_artifact_name }}
windows_signing_backend:
description: 'Windows signing backend used for the candidate'
value: ${{ jobs.sign-windows-agent.outputs.signing_backend }}
value: ${{ jobs.collect-windows-signing.outputs.signing_backend }}
permissions:
contents: read
@ -210,16 +210,14 @@ jobs:
compression-level: 0
sign-windows-agent:
name: Authenticode Sign Windows Agent
name: Build and Submit Windows Agent Signing
needs: signing-configuration
if: ${{ inputs.require_windows_signing && needs.signing-configuration.result == 'success' }}
runs-on: windows-2025
timeout-minutes: 50
timeout-minutes: 25
permissions:
actions: read
contents: read
outputs:
signing_backend: ${{ steps.evidence.outputs.signing_backend }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@ -272,6 +270,16 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $($output.Name)." }
}
- name: Upload legacy signed binaries
if: ${{ inputs.windows_signing_backend == 'legacy-pfx' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: legacy-signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: unsigned-native-agent-binaries/
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Upload unsigned SignPath input
if: ${{ inputs.windows_signing_backend == 'signpath' }}
id: upload-unsigned-windows
@ -283,6 +291,10 @@ jobs:
retention-days: 1
compression-level: 0
# Production release-signing requests need manual approval in the
# SignPath UI, so submission must not wait for completion here: the
# collect-windows-signing job absorbs the approval latency instead,
# and this build job finishes as soon as the request is on record.
- name: Submit SignPath Authenticode request
if: ${{ inputs.windows_signing_backend == 'signpath' }}
id: signpath
@ -295,20 +307,128 @@ jobs:
artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 2400
output-artifact-directory: signed-native-agent-binaries
wait-for-completion: false
parameters: |
version: ${{ toJSON(inputs.version) }}
- name: Record SignPath signing request
if: ${{ inputs.windows_signing_backend == 'signpath' }}
shell: pwsh
env:
SIGNPATH_SIGNING_REQUEST_ID: ${{ steps.signpath.outputs.signing-request-id }}
SIGNPATH_SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing-request-web-url }}
SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}
run: |
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($env:SIGNPATH_SIGNING_REQUEST_ID)) {
throw 'SignPath submission returned no signing request id.'
}
[ordered]@{
schemaVersion = 1
signingRequestId = $env:SIGNPATH_SIGNING_REQUEST_ID
signingRequestUrl = $env:SIGNPATH_SIGNING_REQUEST_URL
githubInputArtifactId = $env:SIGNPATH_INPUT_ARTIFACT_ID
} | ConvertTo-Json | Set-Content windows-signing-request.json -Encoding utf8NoBOM
Write-Host "::notice::SignPath signing request submitted and awaiting approval: $env:SIGNPATH_SIGNING_REQUEST_URL"
# 7-day retention (not 1) so a failed collection job can still be
# re-run against the recorded request days later without a rebuild.
- name: Upload SignPath signing request record
if: ${{ inputs.windows_signing_backend == 'signpath' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-signing-request-${{ github.sha }}-${{ inputs.version }}
path: windows-signing-request.json
if-no-files-found: error
retention-days: 7
# Production SignPath signing needs manual approval in the SignPath UI, so
# collection is decoupled from the build: if approval outlasts the polling
# window below, approve the request and use "Re-run failed jobs" - the
# recorded signing request is collected as-is, with no rebuild and no
# second submission or approval.
collect-windows-signing:
name: Collect and Verify Windows Authenticode
needs: sign-windows-agent
if: ${{ inputs.require_windows_signing && needs.sign-windows-agent.result == 'success' }}
runs-on: windows-2025
timeout-minutes: 120
outputs:
signing_backend: ${{ steps.evidence.outputs.signing_backend }}
steps:
- name: Download legacy signed binaries
if: ${{ inputs.windows_signing_backend == 'legacy-pfx' }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: legacy-signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: unsigned-native-agent-binaries
- name: Download SignPath signing request record
if: ${{ inputs.windows_signing_backend == 'signpath' }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-signing-request-${{ github.sha }}-${{ inputs.version }}
path: windows-signing-request
- name: Wait for SignPath approval and download signed binaries
if: ${{ inputs.windows_signing_backend == 'signpath' }}
id: signpath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
run: |
$ErrorActionPreference = 'Stop'
$record = Get-Content windows-signing-request/windows-signing-request.json -Raw | ConvertFrom-Json
$requestId = $record.signingRequestId
$requestUrl = $record.signingRequestUrl
if ([string]::IsNullOrWhiteSpace($requestId)) { throw 'Signing request record has no signing request id.' }
"signing_request_id=$requestId" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"signing_request_url=$requestUrl" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"github_input_artifact_id=$($record.githubInputArtifactId)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
$statusUri = "https://app.signpath.io/API/v1/$($env:SIGNPATH_ORGANIZATION_ID)/SigningRequests/$requestId"
$headers = @{ Authorization = "Bearer $($env:SIGNPATH_API_TOKEN)" }
$deadline = (Get-Date).AddMinutes(115)
$transientFailures = 0
while ($true) {
try {
$request = Invoke-RestMethod -Uri $statusUri -Headers $headers -TimeoutSec 60
$transientFailures = 0
} catch {
$transientFailures++
if ($transientFailures -ge 10) { throw }
Write-Host "Transient SignPath status error ($transientFailures/10): $($_.Exception.Message)"
Start-Sleep -Seconds 60
continue
}
$status = $request.status
Write-Host "SignPath signing request $requestId status: $status (workflow: $($request.workflowStatus))"
if ($status -eq 'Completed') { break }
if ($status -in @('Failed', 'Denied', 'Canceled')) {
throw "SignPath signing request $requestId finished as ${status}: $requestUrl"
}
if ((Get-Date) -gt $deadline) {
Write-Host "::error::SignPath signing request $requestId is still $status. Approve it in SignPath ($requestUrl), then use 'Re-run failed jobs' - the recorded request is collected without rebuilding or resubmitting."
exit 1
}
Start-Sleep -Seconds 60
}
$zipPath = Join-Path $env:RUNNER_TEMP 'signpath-signed-artifact.zip'
Invoke-WebRequest -Uri "$statusUri/SignedArtifact" -Headers $headers -OutFile $zipPath -TimeoutSec 300
New-Item -ItemType Directory -Path signed-native-agent-binaries -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath signed-native-agent-binaries
Get-ChildItem signed-native-agent-binaries
- name: Verify Authenticode signatures and write evidence
id: evidence
shell: pwsh
env:
WINDOWS_SIGNING_BACKEND: ${{ inputs.windows_signing_backend }}
SIGNPATH_SIGNING_REQUEST_ID: ${{ steps.signpath.outputs.signing-request-id }}
SIGNPATH_SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing-request-web-url }}
SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}
SIGNPATH_SIGNING_REQUEST_ID: ${{ steps.signpath.outputs.signing_request_id }}
SIGNPATH_SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing_request_url }}
SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.signpath.outputs.github_input_artifact_id }}
SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT: ${{ vars.SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
@ -366,8 +486,8 @@ jobs:
build:
name: Build and Validate Release Candidate
needs: [sign-macos-agent, sign-windows-agent]
if: ${{ always() && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.sign-windows-agent.result == 'success') }}
needs: [sign-macos-agent, collect-windows-signing]
if: ${{ always() && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.collect-windows-signing.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 60
outputs:

View file

@ -35,10 +35,16 @@ only a non-production JSON evidence record after verification. It never uploads
the test-signed binaries as a GitHub artifact or assembles a release candidate.
The canonical CI integration uses SignPath's GitHub trusted-build-system
action. GitHub Actions uploads the three unsigned Windows agent executables as
one immutable workflow artifact, submits it to SignPath, waits for approval and
completion, downloads the signed result, and verifies every file before
candidate assembly. A non-secret evidence artifact records the SignPath request
action, split into two phases because production signing requests require
manual approval in the SignPath UI. The Windows build job uploads the three
unsigned agent executables as one immutable workflow artifact, submits it to
SignPath without waiting, and records the signing request id. A separate
collection job absorbs the approval latency: it waits for the recorded request
to complete, downloads the signed result by request id, and verifies every
file before candidate assembly. If approval outlasts the collection job's
polling window, that job fails with re-run guidance, and re-running it
collects the same recorded request — no rebuild, no second submission, no
second approval. A non-secret evidence artifact records the SignPath request
URL, source SHA, signer identity, and signed-file SHA-256 values.
The artifact configuration accepts exactly these ZIP-root files and no others:

View file

@ -495,6 +495,14 @@ upgrade, update, release, or artifact-selection behavior.
by id, verify every returned executable, and retain evidence binding the
request, source SHA, signer identity, and file digests. A repository-secret
PFX backend is an explicitly selected break-glass fallback only.
Production SignPath signing requests require manual approval in the
SignPath UI, so the Windows signing lane must split submission from
collection: the build job submits the immutable artifact without waiting
and records the signing request id, and a separate collection job absorbs
approval latency, downloads the signed result for the recorded request,
verifies it, and writes evidence. A collection timeout must fail with
operator re-run guidance, and re-running the failed collection job must
reuse the recorded request rather than rebuilding or resubmitting.
macOS command-line agent notarization must fail closed unless
`notarytool --wait --output-format json` reports `Accepted`, then verify the
exact candidate bytes with strict `codesign`. Bare Mach-O command-line

View file

@ -35,10 +35,16 @@ only a non-production JSON evidence record after verification. It never uploads
the test-signed binaries as a GitHub artifact or assembles a release candidate.
The canonical CI integration uses SignPath's GitHub trusted-build-system
action. GitHub Actions uploads the three unsigned Windows agent executables as
one immutable workflow artifact, submits it to SignPath, waits for approval and
completion, downloads the signed result, and verifies every file before
candidate assembly. A non-secret evidence artifact records the SignPath request
action, split into two phases because production signing requests require
manual approval in the SignPath UI. The Windows build job uploads the three
unsigned agent executables as one immutable workflow artifact, submits it to
SignPath without waiting, and records the signing request id. A separate
collection job absorbs the approval latency: it waits for the recorded request
to complete, downloads the signed result by request id, and verifies every
file before candidate assembly. If approval outlasts the collection job's
polling window, that job fails with re-run guidance, and re-running it
collects the same recorded request — no rebuild, no second submission, no
second approval. A non-secret evidence artifact records the SignPath request
URL, source SHA, signer identity, and signed-file SHA-256 values.
The artifact configuration accepts exactly these ZIP-root files and no others:

View file

@ -1049,9 +1049,13 @@ func TestReleaseCandidateRequiresPlatformNativeAgentSigning(t *testing.T) {
`result.get('status') != 'Accepted'`,
`codesign --verify --deep --strict --verbose=2`,
`sign-windows-agent:`,
`collect-windows-signing:`,
`windows_signing_backend:`,
`signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2`,
`github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }}`,
`wait-for-completion: false`,
`windows-signing-request.json`,
`Re-run failed jobs`,
`SIGNPATH_API_TOKEN`,
`SIGNPATH_ORGANIZATION_ID`,
`SIGNPATH_PROJECT_SLUG`,
@ -1071,6 +1075,9 @@ func TestReleaseCandidateRequiresPlatformNativeAgentSigning(t *testing.T) {
if strings.Contains(string(candidateWorkflow), `spctl --assess --type execute`) {
t.Fatal("bare command-line Mach-O binaries must not use Gatekeeper app assessment after notarization")
}
if strings.Contains(string(candidateWorkflow), `wait-for-completion: true`) {
t.Fatal("SignPath submission must not block the Windows build job on manual approval; collection is a separate resumable job")
}
assertFileContainsAll(t, repoFile(".github", "workflows", "create-release.yml"),
`require_macos_signing: true`,
`require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}`,