mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
ad1cfd33c3
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
1026 lines
51 KiB
YAML
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 }}
|