From 3826316eec488cef2561eb64397f225979a0adc1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 11 Aug 2026 15:50:25 +0100 Subject: [PATCH] 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. --- .github/workflows/build-release-candidate.yml | 146 ++++++++++++++++-- docs/CODE_SIGNING_POLICY.md | 14 +- .../subsystems/deployment-installability.md | 8 + .../public/docs/CODE_SIGNING_POLICY.md | 14 +- .../installtests/build_release_assets_test.go | 7 + 5 files changed, 168 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 4cdd238b3e..1a6735a865 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -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: diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index 3eb1c710e8..b0f0c8336c 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -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: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 85c869d3aa..89820bdeeb 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md index 3eb1c710e8..b0f0c8336c 100644 --- a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md +++ b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md @@ -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: diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 638546eec2..3469bca056 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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' }}`,