From f96abc5ee0eefa5b055f4a3fdfc2053eba1d86a0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 16:49:29 +0100 Subject: [PATCH] Publish signed release-packet SBOM assets --- .github/workflows/create-release.yml | 20 ++++++++++++++++ .../subsystems/deployment-installability.md | 17 ++++++++----- scripts/build-release.sh | 24 +++++++++++++++++++ .../installtests/build_release_assets_test.go | 23 +++++++++++++++++- .../release_promotion_policy_test.py | 9 +++++++ scripts/validate-published-release.sh | 17 +++++++++++++ scripts/validate-release.sh | 8 +++++++ 7 files changed, 111 insertions(+), 7 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ce3117d35..abf68c19f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6a6d5ff79..1bb9eddc1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 4a71e15b1..5ce3fbad1 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -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 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 30c901194..e23710d22 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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) { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 15cb7ba97..9c3af765d 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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) diff --git a/scripts/validate-published-release.sh b/scripts/validate-published-release.sh index b97ac041d..bf6d9638e 100755 --- a/scripts/validate-published-release.sh +++ b/scripts/validate-published-release.sh @@ -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 diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 7615ae587..6c7c858e2 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -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