mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
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:
parent
d2e25dcb63
commit
3826316eec
5 changed files with 168 additions and 21 deletions
146
.github/workflows/build-release-candidate.yml
vendored
146
.github/workflows/build-release-candidate.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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' }}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue