Files
pulse/.github/workflows/build-release-candidate.yml
pulse-triage[bot] ad1cfd33c3 fix(ci): qualify grouped release action pin consumers
The grouped action upgrade leaves signing, network and publication consumer assertions on superseded pins. Align those contracts and check every consumer against reviewed immutable upstream manifests, retaining exact dispatch and release trust boundaries without claiming hosted execution.

Change-source: pulse-maintainer
2026-09-10 00:35:47 +01:00

1026 lines
51 KiB
YAML

name: Build Release Candidate
on:
workflow_dispatch:
inputs:
version:
description: 'Version number without the leading v'
required: true
type: string
qualify_containers:
description: 'Run exact-candidate container and Helm qualification'
required: false
default: true
type: boolean
require_macos_signing:
description: 'Require Developer ID signed and notarized macOS agent binaries'
required: false
default: false
type: boolean
require_windows_signing:
description: 'Require Authenticode-signed Windows agent binaries'
required: false
default: false
type: boolean
windows_signing_backend:
description: 'Windows signing backend: signpath (canonical) or legacy-pfx (break-glass fallback)'
required: false
default: signpath
type: choice
options:
- signpath
- legacy-pfx
workflow_call:
inputs:
version:
description: 'Version number without the leading v'
required: true
type: string
qualify_containers:
description: 'Run exact-candidate container and Helm qualification'
required: false
default: true
type: boolean
require_macos_signing:
description: 'Require Developer ID signed and notarized macOS agent binaries'
required: false
default: false
type: boolean
require_windows_signing:
description: 'Require Authenticode-signed Windows agent binaries'
required: false
default: false
type: boolean
windows_signing_backend:
description: 'Windows signing backend: signpath (canonical) or legacy-pfx (break-glass fallback)'
required: false
default: signpath
type: string
outputs:
artifact_name:
description: 'Immutable release candidate artifact name'
value: ${{ jobs.build.outputs.artifact_name }}
manifest_artifact_name:
description: 'Release candidate manifest artifact name'
value: ${{ jobs.build.outputs.manifest_artifact_name }}
container_artifact_name:
description: 'Exact-candidate container payload artifact name'
value: ${{ jobs.build.outputs.container_artifact_name }}
windows_signing_backend:
description: 'Windows signing backend used for the candidate'
value: ${{ jobs.collect-windows-signing.outputs.signing_backend }}
permissions:
contents: read
jobs:
obtain-release-payload:
name: Obtain Isolated Hosted Release Payload
# Compilation runs in a separate GitHub-hosted workflow so the expensive
# payload is built once on a fresh VM, then consumed by artifact id and
# digest without joining signing credentials to the compilation job.
runs-on: ubuntu-24.04
timeout-minutes: 40
permissions:
actions: write
contents: read
outputs:
artifact_id: ${{ steps.wait.outputs.artifact_id }}
artifact_digest: ${{ steps.wait.outputs.artifact_digest }}
artifact_name: ${{ steps.wait.outputs.artifact_name }}
compiler_run_id: ${{ steps.dispatch.outputs.compiler_run_id }}
secure_runtime_artifact_id: ${{ steps.wait.outputs.secure_runtime_artifact_id }}
secure_runtime_artifact_digest: ${{ steps.wait.outputs.secure_runtime_artifact_digest }}
secure_runtime_artifact_name: ${{ steps.wait.outputs.secure_runtime_artifact_name }}
steps:
- name: Checkout payload coordination control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Dispatch exact-SHA compiler workflow
id: dispatch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref_name }}
REQUEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail
[[ "${GITHUB_REF}" == refs/heads/* ]]
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
echo "::error::Release candidate version is not an exact supported version."
exit 1
fi
dispatch_payload="$(jq -nc \
--arg ref "${SOURCE_REF}" \
--arg version "${VERSION}" \
--arg source_sha "${SOURCE_SHA}" \
--arg request_id "${REQUEST_ID}" \
'{ref: $ref, return_run_details: true, inputs: {version: $version, source_sha: $source_sha, request_id: $request_id}}')"
dispatch_json="$(gh api \
--method POST \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/${GITHUB_REPOSITORY}/actions/workflows/compile-release-payload.yml/dispatches" \
--input - <<<"${dispatch_payload}")"
compiler_run_id="$(jq -er '.workflow_run_id | select(type == "number")' <<<"${dispatch_json}")"
compiler_run_url="$(jq -er '.html_url | select(type == "string" and length > 0)' <<<"${dispatch_json}")"
echo "compiler_run_id=${compiler_run_id}" >> "$GITHUB_OUTPUT"
echo "Compiler workflow: ${compiler_run_url}"
- name: Wait for immutable compiler artifact
id: wait
env:
GH_TOKEN: ${{ github.token }}
COMPILER_RUN_ID: ${{ steps.dispatch.outputs.compiler_run_id }}
VERSION: ${{ inputs.version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref_name }}
REQUEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail
deadline="$((SECONDS + 35 * 60))"
while true; do
run_json="$(gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/${GITHUB_REPOSITORY}/actions/runs/${COMPILER_RUN_ID}")"
status="$(jq -er '.status' <<<"${run_json}")"
if [[ "${status}" == "completed" ]]; then
break
fi
if (( SECONDS >= deadline )); then
echo "::error::Compiler workflow ${COMPILER_RUN_ID} did not complete within 35 minutes."
exit 1
fi
echo "Compiler workflow ${COMPILER_RUN_ID} is ${status}."
sleep 10
done
if ! jq -e \
--argjson run_id "${COMPILER_RUN_ID}" \
--arg source_ref "${SOURCE_REF}" \
--arg source_sha "${SOURCE_SHA}" \
'.id == $run_id and .event == "workflow_dispatch" and .path == ".github/workflows/compile-release-payload.yml" and .head_branch == $source_ref and .head_sha == $source_sha and .conclusion == "success"' \
<<<"${run_json}" >/dev/null; then
run_summary="$(jq -c '{id, event, path, head_branch, head_sha, status, conclusion, html_url}' <<<"${run_json}")"
echo "::error::Compiler workflow identity or result verification failed: ${run_summary}"
exit 1
fi
artifact_name="release-compiled-${SOURCE_SHA}-${VERSION}-${REQUEST_ID}"
artifacts_json="$(gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/${GITHUB_REPOSITORY}/actions/runs/${COMPILER_RUN_ID}/artifacts?per_page=100")"
artifact_json="$(jq -ce \
--arg artifact_name "${artifact_name}" \
'[.artifacts[] | select(.name == $artifact_name and .expired == false and .size_in_bytes > 0)] | if length == 1 then .[0] else error("expected exactly one compiler artifact") end' \
<<<"${artifacts_json}")"
artifact_id="$(jq -er '.id | select(type == "number")' <<<"${artifact_json}")"
artifact_digest="$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$")) | sub("^sha256:"; "")' <<<"${artifact_json}")"
echo "artifact_id=${artifact_id}" >> "$GITHUB_OUTPUT"
echo "artifact_digest=${artifact_digest}" >> "$GITHUB_OUTPUT"
python3 scripts/write_github_output.py artifact_name "${artifact_name}"
secure_runtime_artifact_name="secure-runtime-qualification-${SOURCE_SHA}-${VERSION}-${REQUEST_ID}"
secure_runtime_artifact_json="$(jq -ce \
--arg artifact_name "${secure_runtime_artifact_name}" \
'[.artifacts[] | select(.name == $artifact_name and .expired == false and .size_in_bytes > 0)] | if length == 1 then .[0] else error("expected exactly one secure-runtime compiler artifact") end' \
<<<"${artifacts_json}")"
secure_runtime_artifact_id="$(jq -er '.id | select(type == "number")' <<<"${secure_runtime_artifact_json}")"
secure_runtime_artifact_digest="$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$")) | sub("^sha256:"; "")' <<<"${secure_runtime_artifact_json}")"
echo "secure_runtime_artifact_id=${secure_runtime_artifact_id}" >> "$GITHUB_OUTPUT"
echo "secure_runtime_artifact_digest=${secure_runtime_artifact_digest}" >> "$GITHUB_OUTPUT"
python3 scripts/write_github_output.py secure_runtime_artifact_name "${secure_runtime_artifact_name}"
signing-configuration:
name: Verify Native Signing Configuration
if: ${{ inputs.require_macos_signing || inputs.require_windows_signing }}
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- name: Report missing signing secrets
env:
APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64 }}
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_DEVELOPER_ID_APPLICATION_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_IDENTITY }}
APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64 }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
SIGNPATH_SIGNING_POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT: ${{ vars.SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT }}
REQUIRE_MACOS_SIGNING: ${{ inputs.require_macos_signing }}
REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }}
WINDOWS_SIGNING_BACKEND: ${{ inputs.windows_signing_backend }}
run: |
set -euo pipefail
missing=0
required=()
if [[ "$REQUIRE_MACOS_SIGNING" == "true" ]]; then
required+=(
APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD
APPLE_DEVELOPER_ID_APPLICATION_IDENTITY
APPLE_NOTARY_KEY_P8_BASE64
APPLE_NOTARY_KEY_ID
APPLE_NOTARY_ISSUER_ID
)
fi
if [[ "$REQUIRE_WINDOWS_SIGNING" == "true" ]]; then
case "$WINDOWS_SIGNING_BACKEND" in
signpath)
required+=(
SIGNPATH_API_TOKEN
SIGNPATH_ORGANIZATION_ID
SIGNPATH_PROJECT_SLUG
SIGNPATH_SIGNING_POLICY_SLUG
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG
SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT
)
;;
legacy-pfx)
required+=(
WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD
)
echo "::warning::Using the break-glass legacy PFX Windows signing backend."
;;
*)
echo "::error::Unsupported Windows signing backend '${WINDOWS_SIGNING_BACKEND}'. Expected signpath or legacy-pfx."
missing=1
;;
esac
else
echo "::notice::Windows Authenticode is not required for this candidate."
fi
for name in "${required[@]}"; do
if [ -z "${!name:-}" ]; then
echo "::error::Missing required Actions secret ${name}."
missing=1
fi
done
exit "$missing"
sign-macos-agent:
name: Sign and Notarize macOS Agent
needs: signing-configuration
if: ${{ inputs.require_macos_signing && needs.signing-configuration.result == 'success' }}
runs-on: macos-15
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- name: Build, sign, and notarize agent binaries
shell: bash
env:
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_IDENTITY }}
APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
for name in \
APPLE_CERTIFICATE_P12_BASE64 \
APPLE_CERTIFICATE_PASSWORD \
APPLE_SIGNING_IDENTITY \
APPLE_NOTARY_KEY_P8_BASE64 \
APPLE_NOTARY_KEY_ID \
APPLE_NOTARY_ISSUER_ID \
PULSE_UPDATE_SIGNING_PUBLIC_KEY; do
test -n "${!name:-}" || { echo "::error::Missing required ${name}."; exit 1; }
done
mkdir -p native-agent-binaries
python3 - <<'PY'
import base64, os
from pathlib import Path
Path('developer-id.p12').write_bytes(base64.b64decode(os.environ['APPLE_CERTIFICATE_P12_BASE64']))
Path('AuthKey.p8').write_bytes(base64.b64decode(os.environ['APPLE_NOTARY_KEY_P8_BASE64']))
PY
keychain="$RUNNER_TEMP/pulse-signing.keychain-db"
keychain_password="$(openssl rand -hex 24)"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import developer-id.p12 -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
security list-keychains -d user -s "$keychain"
ldflags="$(./scripts/release_ldflags.sh agent \
--version "v${VERSION}" \
--update-public-keys "$PULSE_UPDATE_SIGNING_PUBLIC_KEY")"
for arch in amd64 arm64; do
output="native-agent-binaries/pulse-agent-darwin-${arch}"
GOOS=darwin GOARCH="$arch" CGO_ENABLED=0 \
go build -buildvcs=false -trimpath -ldflags="$ldflags" -o "$output" ./cmd/pulse-agent
codesign --force --timestamp --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$output"
codesign --verify --deep --strict --verbose=2 "$output"
done
ditto -c -k --keepParent native-agent-binaries pulse-agent-macos-notarization.zip
xcrun notarytool submit pulse-agent-macos-notarization.zip \
--key AuthKey.p8 \
--key-id "$APPLE_NOTARY_KEY_ID" \
--issuer "$APPLE_NOTARY_ISSUER_ID" \
--wait \
--output-format json > notarization-result.json
python3 - <<'PY'
import json
from pathlib import Path
result = json.loads(Path('notarization-result.json').read_text())
if result.get('status') != 'Accepted':
raise SystemExit(f"Apple notarization was not accepted: {result.get('status', 'unknown')}")
PY
for binary in native-agent-binaries/pulse-agent-darwin-*; do
# Gatekeeper's spctl app assessment rejects bare command-line
# Mach-O binaries even when the notary service accepted them.
# Verify the signed bytes that will be packaged instead.
codesign --verify --deep --strict --verbose=2 "$binary"
done
- name: Upload signed macOS binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-macos-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries/
if-no-files-found: error
retention-days: 1
compression-level: 0
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: 25
permissions:
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- name: Build unsigned agent binaries
shell: pwsh
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
VERSION: ${{ inputs.version }}
run: |
$ErrorActionPreference = 'Stop'
foreach ($name in @('PULSE_UPDATE_SIGNING_PUBLIC_KEY')) {
if ([string]::IsNullOrWhiteSpace((Get-Item "Env:$name").Value)) {
throw "Missing required $name."
}
}
New-Item -ItemType Directory -Path unsigned-native-agent-binaries -Force | Out-Null
$ldflags = & bash ./scripts/release_ldflags.sh agent --version "v$env:VERSION" --update-public-keys $env:PULSE_UPDATE_SIGNING_PUBLIC_KEY
foreach ($arch in @('amd64', 'arm64', '386')) {
$env:GOOS = 'windows'
$env:GOARCH = $arch
$env:CGO_ENABLED = '0'
$output = "unsigned-native-agent-binaries/pulse-agent-windows-$arch.exe"
go build -buildvcs=false -trimpath -ldflags="$ldflags" -o $output ./cmd/pulse-agent
if ($LASTEXITCODE -ne 0) { throw "Go build failed for Windows $arch." }
}
- name: Sign with legacy PFX fallback
if: ${{ inputs.windows_signing_backend == 'legacy-pfx' }}
shell: pwsh
env:
WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PFX_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
$ErrorActionPreference = 'Stop'
$pfxPath = Join-Path $env:RUNNER_TEMP 'pulse-code-signing.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_PFX_BASE64))
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$certificate = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $password -Exportable:$false
if ($null -eq $certificate) { throw 'Failed to import Windows code-signing certificate.' }
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
foreach ($output in (Get-ChildItem unsigned-native-agent-binaries\*.exe)) {
& $signtool sign /sha1 $certificate.Thumbprint /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com $output.FullName
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
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unsigned-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: unsigned-native-agent-binaries/
if-no-files-found: error
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
uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
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: 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: Checkout signing collection control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download legacy signed binaries
if: ${{ inputs.windows_signing_backend == 'legacy-pfx' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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_url }}
SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.signpath.outputs.github_input_artifact_id }}
SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT: ${{ vars.SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT }}
VERSION: ${{ inputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$sourceDir = if ($env:WINDOWS_SIGNING_BACKEND -eq 'signpath') { 'signed-native-agent-binaries' } else { 'unsigned-native-agent-binaries' }
New-Item -ItemType Directory -Path native-agent-binaries -Force | Out-Null
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
$files = @()
foreach ($arch in @('amd64', 'arm64', '386')) {
$name = "pulse-agent-windows-$arch.exe"
$source = Join-Path $sourceDir $name
if (-not (Test-Path $source -PathType Leaf)) { throw "Signed output is missing $name." }
& $signtool verify /pa /v $source
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for $name." }
$signature = Get-AuthenticodeSignature $source
if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { throw "Invalid Authenticode status for $name: $($signature.Status)." }
if ($env:WINDOWS_SIGNING_BACKEND -eq 'signpath' -and $signature.SignerCertificate.Subject -ne $env:SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT) { throw "Unexpected Authenticode signer subject for ${name}: $($signature.SignerCertificate.Subject)." }
Copy-Item $source (Join-Path native-agent-binaries $name)
$files += [ordered]@{
name = $name
sha256 = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant()
signerSubject = $signature.SignerCertificate.Subject
signerThumbprint = $signature.SignerCertificate.Thumbprint
}
}
$evidence = [ordered]@{
schemaVersion = 1
backend = $env:WINDOWS_SIGNING_BACKEND
version = $env:VERSION
sourceSha = $env:GITHUB_SHA
workflowRunUrl = "https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID"
signPathSigningRequestId = $env:SIGNPATH_SIGNING_REQUEST_ID
signPathSigningRequestUrl = $env:SIGNPATH_SIGNING_REQUEST_URL
githubInputArtifactId = $env:SIGNPATH_INPUT_ARTIFACT_ID
files = $files
}
$evidence | ConvertTo-Json -Depth 6 | Set-Content windows-signing-evidence.json -Encoding utf8NoBOM
if ($env:WINDOWS_SIGNING_BACKEND -notin @('signpath', 'legacy-pfx')) { throw "Invalid Windows signing backend." }
$validatedBackend = $env:WINDOWS_SIGNING_BACKEND
python scripts/write_github_output.py signing_backend $validatedBackend
- name: Upload signed Windows binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries/
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Upload Windows signing evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-signing-evidence-${{ github.sha }}-${{ inputs.version }}
path: windows-signing-evidence.json
if-no-files-found: error
retention-days: 30
build:
name: Build and Validate Release Candidate
needs: [obtain-release-payload, sign-macos-agent, collect-windows-signing]
if: ${{ always() && needs.obtain-release-payload.result == 'success' && (!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
permissions:
actions: read
attestations: write
contents: read
id-token: write
outputs:
artifact_name: ${{ steps.identity.outputs.artifact_name }}
manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }}
container_artifact_name: ${{ steps.identity.outputs.container_artifact_name }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Resolve candidate identity
id: identity
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
echo "::error::Release candidate version is not an exact supported version."
exit 1
fi
validated_version="${VERSION}"
python3 scripts/write_github_output.py artifact_name "release-candidate-${GITHUB_SHA}-${validated_version}"
python3 scripts/write_github_output.py manifest_artifact_name "release-candidate-manifest-${GITHUB_SHA}-${validated_version}"
python3 scripts/write_github_output.py container_artifact_name "release-container-payload-${GITHUB_SHA}-${validated_version}"
- name: Validate candidate identity
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
test "$(tr -d '\n' < VERSION)" = "${VERSION}"
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Install release prerequisites
run: |
sudo apt-get update
sudo apt-get install -y zip unzip
- name: Set up Helm
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
- name: Verify and download exact compiled artifact
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }}
EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }}
EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.artifact_name }}
EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "${EXPECTED_ARTIFACT_ID}" =~ ^[0-9]+$ ]]
[[ "${EXPECTED_ARTIFACT_DIGEST}" =~ ^[0-9a-f]{64}$ ]]
artifact_json="$RUNNER_TEMP/release-compiled-artifact.json"
artifact_zip="$RUNNER_TEMP/release-compiled-artifact.zip"
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}" > "${artifact_json}"
jq -e \
--argjson artifact_id "${EXPECTED_ARTIFACT_ID}" \
--arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \
--arg artifact_digest "sha256:${EXPECTED_ARTIFACT_DIGEST}" \
--argjson run_id "${EXPECTED_COMPILER_RUN_ID}" \
--arg source_sha "${GITHUB_SHA}" \
'.id == $artifact_id and .name == $artifact_name and .expired == false and .size_in_bytes > 0 and .digest == $artifact_digest and .workflow_run.id == $run_id and .workflow_run.head_sha == $source_sha' \
"${artifact_json}" >/dev/null
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}/zip" > "${artifact_zip}"
printf '%s %s\n' "${EXPECTED_ARTIFACT_DIGEST}" "${artifact_zip}" | sha256sum --check --
mkdir -p release-compiled
unzip -q "${artifact_zip}" -d release-compiled
- name: Verify hosted secure-runtime compiler packet
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_id }}
EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_digest }}
EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_name }}
EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "${EXPECTED_ARTIFACT_ID}" =~ ^[0-9]+$ ]]
[[ "${EXPECTED_ARTIFACT_DIGEST}" =~ ^[0-9a-f]{64}$ ]]
artifact_json="$RUNNER_TEMP/secure-runtime-compiled-artifact.json"
artifact_zip="$RUNNER_TEMP/secure-runtime-compiled-artifact.zip"
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}" > "${artifact_json}"
jq -e \
--argjson artifact_id "${EXPECTED_ARTIFACT_ID}" \
--arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \
--arg artifact_digest "sha256:${EXPECTED_ARTIFACT_DIGEST}" \
--argjson run_id "${EXPECTED_COMPILER_RUN_ID}" \
--arg source_sha "${GITHUB_SHA}" \
'.id == $artifact_id and .name == $artifact_name and .expired == false and .size_in_bytes > 0 and .digest == $artifact_digest and .workflow_run.id == $run_id and .workflow_run.head_sha == $source_sha' \
"${artifact_json}" >/dev/null
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}/zip" > "${artifact_zip}"
printf '%s %s\n' "${EXPECTED_ARTIFACT_DIGEST}" "${artifact_zip}" | sha256sum --check --
mkdir -p secure-runtime-qualification
unzip -q "${artifact_zip}" -d secure-runtime-qualification
jq -e \
--arg version "${VERSION}" \
--arg tag "v${VERSION}" \
--arg source_sha "${GITHUB_SHA}" \
'.schema_version == 1 and .version == $version and .tag == $tag and .source_sha == $source_sha and .compiler_runner_trust == "github-hosted-deny-self-hosted" and (.artifacts | keys | sort) == ["collector_v1", "collector_v2", "collector_v3", "collector_v4", "helper", "runner"]' \
secure-runtime-qualification/secure-runtime-build-contract-v1.json >/dev/null
(
cd secure-runtime-qualification
sha256sum --check secure-runtime-compiler-subjects.sha256
)
for subject in \
pulse-secure-runtime-collector-v1-linux-amd64 \
pulse-secure-runtime-collector-v2-linux-amd64 \
pulse-secure-runtime-collector-v3-linux-amd64 \
pulse-agent-linux-amd64 \
pulse-agent-helper-linux-amd64 \
pulse-agent-runner-linux-amd64 \
secure-runtime-build-contract-v1.json; do
gh attestation verify \
"secure-runtime-qualification/${subject}" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml" \
--source-digest "${GITHUB_SHA}" \
--deny-self-hosted-runners \
--predicate-type "https://slsa.dev/provenance/v1" \
--bundle secure-runtime-qualification/secure-runtime-compiler-provenance.sigstore.json
done
cmp secure-runtime-qualification/pulse-agent-linux-amd64 release-compiled/payload/binaries/pulse-agent-linux-amd64
cmp secure-runtime-qualification/pulse-agent-helper-linux-amd64 release-compiled/payload/binaries/pulse-agent-helper-linux-amd64
cmp secure-runtime-qualification/pulse-agent-runner-linux-amd64 release-compiled/payload/binaries/pulse-agent-runner-linux-amd64
- name: Verify exact-SHA compiled payload
env:
EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }}
EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }}
EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.artifact_name }}
EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
python3 scripts/release_candidate_manifest.py verify-local \
--release-dir release-compiled/payload \
--manifest release-compiled/manifest/release-compiled.json \
--version "${VERSION}" \
--source-sha "${GITHUB_SHA}"
mkdir -p release-candidate-manifest
payload_manifest_sha256="$(sha256sum release-compiled/manifest/release-compiled.json | awk '{print $1}')"
jq -n \
--argjson artifact_id "${EXPECTED_ARTIFACT_ID}" \
--arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \
--arg artifact_sha256 "${EXPECTED_ARTIFACT_DIGEST}" \
--arg payload_manifest_sha256 "${payload_manifest_sha256}" \
--argjson compiler_workflow_run_id "${EXPECTED_COMPILER_RUN_ID}" \
--argjson release_workflow_run_id "${GITHUB_RUN_ID}" \
--arg version "${VERSION}" \
--arg source_sha "${GITHUB_SHA}" \
'{schema_version: 2, trust_boundary: "separate-ephemeral-github-hosted-compiler-workflow", verified_on: "github-hosted", artifact_id: $artifact_id, artifact_name: $artifact_name, artifact_sha256: $artifact_sha256, payload_manifest_sha256: $payload_manifest_sha256, compiler_workflow_run_id: $compiler_workflow_run_id, release_workflow_run_id: $release_workflow_run_id, version: $version, source_sha: $source_sha}' \
> release-candidate-manifest/compiled-payload-verification.json
- name: Download signed macOS binaries
if: ${{ inputs.require_macos_signing }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-macos-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries
- name: Download signed Windows binaries
if: ${{ inputs.require_windows_signing }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-windows-agent-${{ github.sha }}-${{ inputs.version }}
path: native-agent-binaries
- name: Download Windows signing evidence
if: ${{ inputs.require_windows_signing }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-signing-evidence-${{ github.sha }}-${{ inputs.version }}
path: release-candidate-manifest
- name: Build release candidate
run: ./scripts/build-release.sh "${VERSION}"
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
PULSE_REQUIRE_MACOS_SIGNING: ${{ inputs.require_macos_signing }}
PULSE_REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }}
PULSE_AGENT_NATIVE_BINARIES_DIR: ${{ (inputs.require_macos_signing || inputs.require_windows_signing) && format('{0}/native-agent-binaries', github.workspace) || '' }}
PULSE_RELEASE_COMPILED_PAYLOAD_DIR: ${{ github.workspace }}/release-compiled/payload
PULSE_SECURE_RUNTIME_QUALIFICATION_DIR: ${{ github.workspace }}/secure-runtime-qualification
PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: "true"
VERSION: ${{ inputs.version }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh scripts/install-mcp.sh release/install-mcp.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
for installer in scripts/install-mcp.ps1 release/install-mcp.ps1; do
grep -F "\$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Validate complete candidate locally
env:
PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: "true"
VERSION: ${{ inputs.version }}
run: ./scripts/validate-release.sh "${VERSION}" --skip-docker
- name: Create candidate subject manifest
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
python3 scripts/release_candidate_manifest.py create \
--release-dir release \
--version "${VERSION}" \
--source-sha "${GITHUB_SHA}" \
--output release-candidate-manifest/release-candidate.json
jq -r \
'.assets[] | "\(.sha256) release/\(.name)"' \
release-candidate-manifest/release-candidate.json \
> "$RUNNER_TEMP/release-candidate-subjects.sha256"
# Generate provenance at the hosted boundary that assembled and validated
# the complete candidate, rather than later in the publication job. Keep
# the Sigstore bundle with the candidate so customers can verify an exact
# downloaded asset without access to GitHub's attestation API.
- name: Attest complete release candidate
id: attest_release_candidate
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ${{ runner.temp }}/release-candidate-subjects.sha256
- name: Preserve portable build provenance
env:
PROVENANCE_BUNDLE: ${{ steps.attest_release_candidate.outputs.bundle-path }}
run: |
set -euo pipefail
test -s "${PROVENANCE_BUNDLE}"
jq -e 'type == "object"' "${PROVENANCE_BUNDLE}" >/dev/null
install -m 0644 \
"${PROVENANCE_BUNDLE}" \
release/release-build-provenance.sigstore.json
- name: Seal immutable candidate manifest
env:
VERSION: ${{ inputs.version }}
run: |
python3 scripts/release_candidate_manifest.py create \
--release-dir release \
--version "${VERSION}" \
--source-sha "${GITHUB_SHA}" \
--output release-candidate-manifest/release-candidate.json
- name: Create exact-candidate container payload
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
payload_root="$RUNNER_TEMP/release-container-payload"
./scripts/prepare-release-container-context.sh \
release \
"${VERSION}" \
"${payload_root}/payload/release"
mkdir -p "${payload_root}/payload/compiled/binaries"
for arch in amd64 arm64; do
install -m 0755 \
"release-compiled/payload/binaries/pulse-control-plane-linux-${arch}" \
"${payload_root}/payload/compiled/binaries/pulse-control-plane-linux-${arch}"
done
python3 scripts/release_candidate_manifest.py create \
--release-dir "${payload_root}/payload" \
--version "${VERSION}" \
--source-sha "${GITHUB_SHA}" \
--output "${payload_root}/release-container-payload.json"
- name: Upload immutable release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.artifact_name }}
path: release/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
- name: Upload candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.manifest_artifact_name }}
path: release-candidate-manifest/
if-no-files-found: error
retention-days: 1
overwrite: true
- name: Upload exact-candidate container payload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.container_artifact_name }}
path: ${{ runner.temp }}/release-container-payload/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
qualify-release-containers:
name: Qualify Exact-Candidate Containers
needs: build
if: ${{ always() && inputs.qualify_containers && needs.build.result == 'success' }}
permissions:
contents: read
uses: ./.github/workflows/qualify-release-containers.yml
with:
version: ${{ inputs.version }}
container_artifact: ${{ needs.build.outputs.container_artifact_name }}