Publish signed release-packet SBOM assets

This commit is contained in:
rcourtman 2026-04-22 16:49:29 +01:00
parent 6224066b00
commit f96abc5ee0
7 changed files with 111 additions and 7 deletions

View file

@ -567,6 +567,23 @@ jobs:
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
syft version
- name: Build release artifacts
run: |
echo "Building release ${{ needs.prepare.outputs.tag }}..."
@ -744,6 +761,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.prepare.outputs.tag }}"
if ls release/*.sbom.spdx.json 1> /dev/null 2>&1; then
gh release upload "${TAG}" release/*.sbom.spdx.json --clobber
fi
gh release upload "${TAG}" release/*.tar.gz --clobber
gh release upload "${TAG}" release/*.zip --clobber
if ls release/*.tgz 1> /dev/null 2>&1; then

View file

@ -87,6 +87,8 @@ server-side update execution surfaces.
65. `tests/integration/tests/runtime-defaults.ts`
66. `docker-compose.yml`
67. `scripts/install-docker.sh`
68. `scripts/validate-published-release.sh`
69. `scripts/validate-release.sh`
## Shared Boundaries
@ -98,7 +100,7 @@ server-side update execution surfaces.
## Extension Points
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml`
3. Add or change shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh`
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
@ -1053,11 +1055,14 @@ installer signatures. `scripts/build-release.sh`,
`scripts/validate-published-release.sh` must derive the embedded update trust
root and installer SSH trust root from the governed release signing key,
render release installers with that pinned SSH verifier, emit both `.sig` and
`.sshsig` sidecars for shipped agent binaries and installer assets, upload
those signatures with the matching release packet, and fail validation if any
published artifact or `checksums.txt` is missing its `.sshsig` sidecar so
published RC/stable downloads can keep the updater and installer trust chain
fail-closed instead of downgrading to checksum-only trust.
`.sshsig` sidecars for shipped agent binaries and installer assets, emit a
standalone SPDX JSON SBOM for the assembled release packet, upload those
security artifacts with the matching release packet, and fail validation if
any published artifact or `checksums.txt` is missing its `.sshsig` sidecar or
if the canonical release-packet SBOM is absent so published RC/stable
downloads can keep the updater and installer trust chain fail-closed instead
of downgrading to checksum-only trust and can publish a shareable non-image
software inventory alongside the signed binaries.
The shell-installer boundary now also owns the QNAP boot bootstrap and
teardown contract end to end: `scripts/install.sh` must persist the wrapper on
the writable data volume, write a flash-backed `autorun.sh` block that waits

View file

@ -32,6 +32,7 @@ RELEASE_DIR="release"
RENDERED_INSTALLERS_DIR="${BUILD_DIR}/rendered-installers"
INSTALLER_SSH_SIGNER_IDENTITY="pulse-installer"
INSTALLER_SSH_SIGNER_NAMESPACE="pulse-install"
RELEASE_PACKET_SBOM="pulse-v${VERSION}-release.sbom.spdx.json"
echo "Building Pulse v${VERSION}..."
@ -140,6 +141,24 @@ sign_directory_release_assets() {
done < <(find "${dir}" -maxdepth 1 -type f ! -name '*.sig' ! -name '*.sshsig' -print0)
}
generate_release_packet_sbom() {
local tmp_sbom=""
if ! command -v syft >/dev/null 2>&1; then
if [[ "${PULSE_ALLOW_MISSING_RELEASE_SBOM_TOOL:-false}" == "true" ]]; then
echo "Warning: syft not installed; skipping release-packet SBOM because PULSE_ALLOW_MISSING_RELEASE_SBOM_TOOL=true."
return 0
fi
echo "Error: syft is required to generate the release-packet SBOM." >&2
echo "Install syft or set PULSE_ALLOW_MISSING_RELEASE_SBOM_TOOL=true only for local non-release debugging." >&2
exit 1
fi
tmp_sbom="$(mktemp "${BUILD_DIR}/release-packet-sbom.XXXXXX")"
echo "Generating release-packet SBOM ${RELEASE_PACKET_SBOM}..."
syft "dir:${RELEASE_DIR}" -o "spdx-json=${tmp_sbom}"
mv "${tmp_sbom}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}"
}
# Clean previous builds
rm -rf $BUILD_DIR $RELEASE_DIR
mkdir -p $BUILD_DIR $RELEASE_DIR
@ -427,6 +446,8 @@ cp "${RENDERED_INSTALLERS_DIR}/install.sh" "$RELEASE_DIR/install.sh"
cp scripts/install-docker.sh "$RELEASE_DIR/"
cp scripts/pulse-auto-update.sh "$RELEASE_DIR/"
generate_release_packet_sbom
# Generate checksums (include tarballs, zip files, helm chart, and install.sh)
cd "$RELEASE_DIR"
shopt -s nullglob extglob
@ -441,6 +462,9 @@ fi
if compgen -G "pulse-*.zip" > /dev/null; then
checksum_files+=( pulse-*.zip )
fi
if compgen -G "pulse-*.sbom.spdx.json" > /dev/null; then
checksum_files+=( pulse-*.sbom.spdx.json )
fi
if compgen -G "pulse-agent-linux-*" > /dev/null; then
checksum_files+=( pulse-agent-linux-* )
fi

View file

@ -39,6 +39,11 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) {
`agent_ldflags="$(./scripts/release_ldflags.sh agent --version "v${VERSION}" "${update_ldflags_args[@]}")"`,
`server_ldflags="$(./scripts/release_ldflags.sh server --version "v${VERSION}" --build-time "${build_time}" --git-commit "${git_commit}" "${license_ldflags_args[@]}" "${update_ldflags_args[@]}")"`,
`PULSE_UPDATE_SIGNING_KEY`,
`RELEASE_PACKET_SBOM="pulse-v${VERSION}-release.sbom.spdx.json"`,
`generate_release_packet_sbom() {`,
`syft "dir:${RELEASE_DIR}" -o "spdx-json=${tmp_sbom}"`,
`generate_release_packet_sbom`,
`pulse-*.sbom.spdx.json`,
`go run ./scripts/release_update_key.go public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
`go run ./scripts/release_update_key.go public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
`go run ./scripts/release_update_key.go openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`,
@ -60,6 +65,11 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
workflow := string(content)
required := []string{
`SYFT_VERSION="1.42.4"`,
`SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`,
`SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`,
`install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft`,
`gh release upload "${TAG}" release/*.sbom.spdx.json --clobber`,
`release/pulse-agent-linux-amd64`,
`release/pulse-agent-linux-arm64`,
`release/pulse-agent-linux-armv7`,
@ -102,6 +112,10 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) {
}
localValidator := string(localValidatorBytes)
localRequired := []string{
`"pulse-v${PULSE_VERSION}-release.sbom.spdx.json"`,
`release_sbom="pulse-${PULSE_TAG}-release.sbom.spdx.json"`,
`error "checksums.txt is missing ${release_sbom}"`,
`success "Release SBOM is listed in checksums.txt"`,
`info "Validating SSH signature sidecars..."`,
`if [ ! -s "checksums.txt.sshsig" ]; then`,
`error "Missing or empty checksums.txt.sshsig"`,
@ -121,6 +135,9 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) {
}
publishedValidator := string(publishedValidatorBytes)
publishedRequired := []string{
`RELEASE_SBOM="pulse-${TAG}-release.sbom.spdx.json"`,
`echo "Failed to download ${RELEASE_SBOM} for ${TAG}" >&2`,
`echo "${RELEASE_SBOM} is empty for ${TAG}" >&2`,
`CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig"`,
`"${BASE_URL}/checksums.txt.sshsig"`,
`echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2`,
@ -141,10 +158,14 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) {
}
contract := string(contractBytes)
contractRequired := []string{
"`scripts/validate-release.sh`",
"`scripts/validate-published-release.sh`",
"`scripts/validate-release.sh`, and",
"`scripts/validate-published-release.sh` must derive the embedded update trust",
"fail validation if any",
"standalone SPDX JSON SBOM",
"and fail validation if",
"published artifact or `checksums.txt` is missing its `.sshsig` sidecar",
"canonical release-packet SBOM is absent",
}
for _, needle := range contractRequired {
if !strings.Contains(contract, needle) {

View file

@ -269,14 +269,23 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn('Retargeting existing draft tag ${TAG}', content)
self.assertIn('-F target_commitish="${HEAD_SHA}"', content)
self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content)
self.assertIn('SYFT_VERSION="1.42.4"', content)
self.assertIn('SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"', content)
self.assertIn('SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"', content)
self.assertIn('install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft', content)
self.assertIn('gh release upload "${TAG}" release/*.sig --clobber', content)
self.assertIn('gh release upload "${TAG}" release/*.sshsig --clobber', content)
self.assertIn('gh release upload "${TAG}" release/*.sbom.spdx.json --clobber', content)
self.assertIn("provenance: mode=max", content)
self.assertIn("sbom: true", content)
self.assertIn("id-token: write", content)
self.assertIn("attestations: write", content)
self.assertIn("uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", content)
self.assertIn("subject-path: release/*", content)
build_script = read("scripts/build-release.sh")
self.assertIn('RELEASE_PACKET_SBOM="pulse-v${VERSION}-release.sbom.spdx.json"', build_script)
self.assertIn('syft "dir:${RELEASE_DIR}" -o "spdx-json=${tmp_sbom}"', build_script)
self.assertIn('pulse-*.sbom.spdx.json', build_script)
self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", content)
self.assertIn("pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content)
self.assertIn("--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY", content)

View file

@ -30,6 +30,23 @@ if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt" >"$CHECKSUMS_PATH"; then
exit 1
fi
RELEASE_SBOM="pulse-${TAG}-release.sbom.spdx.json"
if ! awk '{print $2}' "$CHECKSUMS_PATH" | grep -Fx "$RELEASE_SBOM" >/dev/null 2>&1; then
echo "checksums.txt does not list ${RELEASE_SBOM} for ${TAG}" >&2
exit 1
fi
RELEASE_SBOM_PATH="${TMP_DIR}/${RELEASE_SBOM}"
echo "Downloading ${BASE_URL}/${RELEASE_SBOM}"
if ! "${curl_args[@]}" "${BASE_URL}/${RELEASE_SBOM}" >"$RELEASE_SBOM_PATH"; then
echo "Failed to download ${RELEASE_SBOM} for ${TAG}" >&2
exit 1
fi
if [[ ! -s "$RELEASE_SBOM_PATH" ]]; then
echo "${RELEASE_SBOM} is empty for ${TAG}" >&2
exit 1
fi
CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig"
echo "Downloading ${BASE_URL}/checksums.txt.sshsig"
if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt.sshsig" >"$CHECKSUMS_SIG_PATH"; then

View file

@ -278,6 +278,7 @@ info "Checking required release assets..."
required_assets=(
"install.sh"
"checksums.txt"
"pulse-v${PULSE_VERSION}-release.sbom.spdx.json"
"pulse-v${PULSE_VERSION}.tar.gz"
"pulse-v${PULSE_VERSION}-linux-amd64.tar.gz"
"pulse-v${PULSE_VERSION}-linux-arm64.tar.gz"
@ -364,6 +365,13 @@ info "Validating checksums..."
sha256sum -c checksums.txt >/dev/null 2>&1 || { error "checksums.txt validation failed"; exit 1; }
success "checksums.txt validated"
release_sbom="pulse-${PULSE_TAG}-release.sbom.spdx.json"
if ! grep -F " ${release_sbom}" checksums.txt >/dev/null 2>&1; then
error "checksums.txt is missing ${release_sbom}"
exit 1
fi
success "Release SBOM is listed in checksums.txt"
# Validate release signature sidecars
info "Validating SSH signature sidecars..."
if [ ! -s "checksums.txt.sshsig" ]; then