From 16ad67a9b54e68ca5b8fdced55a39a0a41a1db12 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 17:25:58 +0100 Subject: [PATCH] Add historical release asset backfill workflow --- .github/workflows/backfill-release-assets.yml | 60 +++++ .../subsystems/deployment-installability.md | 41 ++- .../v6/internal/subsystems/registry.json | 7 + scripts/backfill-release-assets.sh | 244 ++++++++++++++++++ scripts/build-release.sh | 172 ++---------- .../backfill_release_assets_test.go | 174 +++++++++++++ .../installtests/build_release_assets_test.go | 111 +++++++- scripts/release_asset_common.sh | 215 +++++++++++++++ .../release_promotion_policy_test.py | 31 ++- 9 files changed, 875 insertions(+), 180 deletions(-) create mode 100644 .github/workflows/backfill-release-assets.yml create mode 100755 scripts/backfill-release-assets.sh create mode 100644 scripts/installtests/backfill_release_assets_test.go create mode 100644 scripts/release_asset_common.sh diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml new file mode 100644 index 000000000..54ff257f6 --- /dev/null +++ b/.github/workflows/backfill-release-assets.yml @@ -0,0 +1,60 @@ +name: Backfill Release Assets + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published release tag to repair (for example v6.0.0-rc.2)' + required: true + type: string + +permissions: + contents: write + +concurrency: + group: backfill-release-assets-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + backfill: + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.25.9' + cache: true + + - 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: Backfill published release assets + env: + GH_TOKEN: ${{ github.token }} + PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + run: | + ./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}" + + - name: Validate published release packet + run: | + ./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1bb9eddc1..537cba7c0 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -89,6 +89,9 @@ server-side update execution surfaces. 67. `scripts/install-docker.sh` 68. `scripts/validate-published-release.sh` 69. `scripts/validate-release.sh` +70. `scripts/release_asset_common.sh` +71. `scripts/backfill-release-assets.sh` +72. `.github/workflows/backfill-release-assets.yml` ## Shared Boundaries @@ -100,7 +103,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`, `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` +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, historical published-release integrity backfill, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.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/backfill-release-assets.yml`, `.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` @@ -1049,20 +1052,30 @@ coverage: update transport changes must continue to carry the direct API-contract proof path. That same governed release-promotion boundary now also owns detached agent and installer signatures. `scripts/build-release.sh`, +`scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_update_key.go`, `scripts/render_installers.go`, -`scripts/release_ldflags.sh`, `Dockerfile`, `.github/workflows/create-release.yml`, -`.github/workflows/publish-docker.yml`, `scripts/validate-release.sh`, and -`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, 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. +`scripts/release_ldflags.sh`, `Dockerfile`, +`.github/workflows/backfill-release-assets.yml`, +`.github/workflows/create-release.yml`, `.github/workflows/publish-docker.yml`, +`scripts/validate-release.sh`, and `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, 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. +Historical published-release repair must flow through +`scripts/backfill-release-assets.sh` and +`.github/workflows/backfill-release-assets.yml`, which download the +already-published packet and regenerate only the derived integrity assets +(`checksums.txt`, `.sha256`, `.sig`, `.sshsig`, and the canonical +release-packet SBOM`) from those shipped bytes instead of rebuilding binaries +from the current branch tip. 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/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index a03d9271f..b4e47208a 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2429,6 +2429,7 @@ "owned_files": [ ".dockerignore", ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", + ".github/workflows/backfill-release-assets.yml", ".github/workflows/create-release.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", @@ -2458,6 +2459,7 @@ "Makefile", "package-lock.json", "package.json", + "scripts/backfill-release-assets.sh", "scripts/build-release.sh", "scripts/check-workflow-dispatch-inputs.py", "scripts/clean-mock-alerts.sh", @@ -2474,6 +2476,7 @@ "scripts/install.ps1", "scripts/install.sh", "scripts/pulse-auto-update.sh", + "scripts/release_asset_common.sh", "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", "scripts/release_control/record_rc_to_ga_rehearsal.py", "scripts/release_control/release_promotion_policy_support.py", @@ -2555,6 +2558,7 @@ ], "match_files": [ ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", + ".github/workflows/backfill-release-assets.yml", ".github/workflows/create-release.yml", ".github/workflows/helm-pages.yml", ".github/workflows/promote-floating-tags.yml", @@ -2595,12 +2599,15 @@ ".dockerignore", ".github/workflows/deploy-demo-server.yml", "Dockerfile", + "scripts/backfill-release-assets.sh", "scripts/build-release.sh", + "scripts/release_asset_common.sh", "scripts/release_ldflags.sh" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "scripts/installtests/backfill_release_assets_test.go", "scripts/installtests/build_release_assets_test.go", "scripts/installtests/release_ldflags_test.go" ] diff --git a/scripts/backfill-release-assets.sh b/scripts/backfill-release-assets.sh new file mode 100755 index 000000000..a5c91dfb2 --- /dev/null +++ b/scripts/backfill-release-assets.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash + +# Regenerate integrity metadata for an already-published Pulse release packet +# without rebuilding the underlying payload artifacts from the current branch. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PULSE_SCRIPTS_DIR="${SCRIPT_DIR}" +PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${PULSE_REPO_ROOT}" + +source "${SCRIPT_DIR}/release_asset_common.sh" + +usage() { + cat <<'EOF' >&2 +Usage: scripts/backfill-release-assets.sh --tag [options] + +Options: + --repo GitHub repository (default: rcourtman/Pulse) + --release-dir Reuse an existing local asset directory + --skip-download Skip gh release download and use --release-dir as-is + --skip-upload Skip gh release upload after regenerating metadata + +Examples: + scripts/backfill-release-assets.sh --tag v6.0.0-rc.2 + scripts/backfill-release-assets.sh --tag v6.0.0-rc.2 --release-dir /tmp/release --skip-download --skip-upload +EOF + exit 1 +} + +TAG="" +REPO="rcourtman/Pulse" +RELEASE_DIR="" +SKIP_DOWNLOAD="false" +SKIP_UPLOAD="false" +WORK_DIR="" +PAYLOAD_DIR="" +RELEASE_PACKET_SBOM="" +payload_files=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + TAG="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --release-dir) + RELEASE_DIR="${2:-}" + shift 2 + ;; + --skip-download) + SKIP_DOWNLOAD="true" + shift + ;; + --skip-upload) + SKIP_UPLOAD="true" + shift + ;; + *) + usage + ;; + esac +done + +[[ -n "${TAG}" ]] || usage + +RELEASE_PACKET_SBOM="pulse-${TAG}-release.sbom.spdx.json" + +cleanup() { + pulse_release_cleanup_signing_state + if [[ -n "${PAYLOAD_DIR}" && -d "${PAYLOAD_DIR}" ]]; then + rm -rf "${PAYLOAD_DIR}" + fi + if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then + rm -rf "${WORK_DIR}" + fi +} +trap cleanup EXIT + +ensure_release_is_published() { + local metadata="" + metadata="$(gh release view "${TAG}" -R "${REPO}" --json isDraft,tagName)" + if [[ "$(printf '%s' "${metadata}" | jq -r '.isDraft')" != "false" ]]; then + echo "Error: ${TAG} is still a draft release; use the normal release pipeline instead of historical backfill." >&2 + exit 1 + fi +} + +download_release_assets() { + if [[ -z "${RELEASE_DIR}" ]]; then + WORK_DIR="$(mktemp -d)" + RELEASE_DIR="${WORK_DIR}/release" + fi + mkdir -p "${RELEASE_DIR}" + echo "Downloading published assets for ${TAG} from ${REPO}..." + gh release download "${TAG}" -R "${REPO}" --dir "${RELEASE_DIR}" --clobber +} + +verify_existing_checksums() { + local checksums_path="${RELEASE_DIR}/checksums.txt" + local status=0 + + if [[ ! -f "${checksums_path}" ]]; then + echo "Error: ${checksums_path} is missing." >&2 + exit 1 + fi + + while read -r checksum filename _; do + [[ -n "${checksum:-}" ]] || continue + [[ "${checksum}" =~ ^# ]] && continue + + if [[ -z "${filename:-}" ]]; then + echo "Malformed checksums.txt line: ${checksum}" >&2 + status=1 + continue + fi + + if [[ ! -f "${RELEASE_DIR}/${filename}" ]]; then + echo "Missing published artifact ${filename}" >&2 + status=1 + continue + fi + + local actual_checksum="" + actual_checksum="$(sha256sum "${RELEASE_DIR}/${filename}" | awk '{print $1}')" + if [[ "${actual_checksum}" != "${checksum}" ]]; then + echo "Checksum mismatch for ${filename}: expected ${checksum}, got ${actual_checksum}" >&2 + status=1 + fi + + local sha_path="${RELEASE_DIR}/${filename}.sha256" + local expected_line="${checksum} ${filename}" + if [[ ! -f "${sha_path}" ]]; then + echo "Missing ${filename}.sha256" >&2 + status=1 + continue + fi + + local sha_content="" + sha_content="$(tr -d '\r' < "${sha_path}" | sed 's/[[:space:]]*$//')" + if [[ "${sha_content}" != "${expected_line}" ]]; then + echo "${filename}.sha256 content mismatch (expected '${expected_line}', got '${sha_content}')" >&2 + status=1 + fi + done < "${checksums_path}" + + if [[ "${status}" -ne 0 ]]; then + echo "Error: published release packet for ${TAG} failed integrity verification before backfill." >&2 + exit 1 + fi +} + +collect_payload_files() { + payload_files=() + + while read -r checksum filename _; do + [[ -n "${checksum:-}" ]] || continue + [[ "${checksum}" =~ ^# ]] && continue + [[ -n "${filename:-}" ]] || continue + + if [[ "${filename}" == "${RELEASE_PACKET_SBOM}" ]]; then + continue + fi + + payload_files+=( "${filename}" ) + done < "${RELEASE_DIR}/checksums.txt" + + if [[ ${#payload_files[@]} -eq 0 ]]; then + echo "Error: ${TAG} does not contain any payload artifacts in checksums.txt." >&2 + exit 1 + fi +} + +stage_payload_assets() { + PAYLOAD_DIR="$(mktemp -d)" + + local filename="" + for filename in "${payload_files[@]}"; do + mkdir -p "${PAYLOAD_DIR}/$(dirname "${filename}")" + cp "${RELEASE_DIR}/${filename}" "${PAYLOAD_DIR}/${filename}" + done +} + +rebuild_release_packet_integrity() { + rm -f \ + "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" \ + "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}.sig" \ + "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}.sshsig" + + pulse_release_generate_packet_sbom "${PAYLOAD_DIR}" "${RELEASE_PACKET_SBOM}" + cp "${PAYLOAD_DIR}/${RELEASE_PACKET_SBOM}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" + + local -a checksum_files=("${payload_files[@]}" "${RELEASE_PACKET_SBOM}") + pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}" +} + +upload_release_assets() { + echo "Uploading regenerated integrity assets for ${TAG}..." + gh release upload "${TAG}" "${RELEASE_DIR}/checksums.txt" --clobber + if compgen -G "${RELEASE_DIR}/*.sha256" > /dev/null; then + gh release upload "${TAG}" "${RELEASE_DIR}"/*.sha256 --clobber + fi + if compgen -G "${RELEASE_DIR}/*.sig" > /dev/null; then + gh release upload "${TAG}" "${RELEASE_DIR}"/*.sig --clobber + fi + if compgen -G "${RELEASE_DIR}/*.sshsig" > /dev/null; then + gh release upload "${TAG}" "${RELEASE_DIR}"/*.sshsig --clobber + fi + gh release upload "${TAG}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" --clobber +} + +if [[ "${SKIP_DOWNLOAD}" == "true" ]]; then + [[ -n "${RELEASE_DIR}" ]] || { + echo "Error: --skip-download requires --release-dir." >&2 + exit 1 + } +else + ensure_release_is_published + download_release_assets +fi + +[[ -d "${RELEASE_DIR}" ]] || { + echo "Error: release directory ${RELEASE_DIR} does not exist." >&2 + exit 1 +} + +pulse_release_prepare_signing_state "pulse-installer" "pulse-install" +verify_existing_checksums +collect_payload_files +stage_payload_assets +rebuild_release_packet_integrity + +if [[ "${SKIP_UPLOAD}" == "true" ]]; then + echo "Skipped GitHub upload; regenerated assets are in ${RELEASE_DIR}" +else + upload_release_assets +fi + +echo "Historical release asset backfill completed for ${TAG}." diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 5ce3fbad1..e0ef01221 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -5,6 +5,13 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PULSE_SCRIPTS_DIR="${SCRIPT_DIR}" +PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${PULSE_REPO_ROOT}" + +source "${SCRIPT_DIR}/release_asset_common.sh" + # Prefer the pinned toolchain from go.mod (toolchain directive). # If /usr/local/go exists (typical in CI images), prepend it to PATH. if [ -x /usr/local/go/bin/go ]; then @@ -30,8 +37,6 @@ VERSION=${1:-$(cat VERSION)} BUILD_DIR="build" 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}..." @@ -76,36 +81,10 @@ fi # Require update signing for release-grade agent and installer verification. # Explicitly opt out with PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true for local-only debugging. update_ldflags_args=() -update_ssh_public_key="" -update_ssh_private_key_file="" -if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then - if [[ "${PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY:-false}" == "true" ]]; then - echo "Warning: PULSE_UPDATE_SIGNING_KEY not set; continuing because PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true." - else - echo "Error: PULSE_UPDATE_SIGNING_KEY is required for release builds." >&2 - echo "Set PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true only for local non-release debugging." >&2 - exit 1 - fi -else - update_public_key=$(go run ./scripts/release_update_key.go public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}") - if [[ -z "${update_public_key}" ]]; then - echo "Error: failed to derive update signing public key." >&2 - exit 1 - fi - if ! command -v ssh-keygen >/dev/null 2>&1; then - echo "Error: ssh-keygen is required to sign installer release assets." >&2 - exit 1 - fi - update_ssh_public_key=$(go run ./scripts/release_update_key.go public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}" --comment "${INSTALLER_SSH_SIGNER_IDENTITY}") - if [[ -z "${update_ssh_public_key}" ]]; then - echo "Error: failed to derive installer SSH signing public key." >&2 - exit 1 - fi - update_ssh_private_key_file=$(mktemp) - trap 'rm -f "${update_ssh_private_key_file}"' EXIT - go run ./scripts/release_update_key.go openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}" --comment "${INSTALLER_SSH_SIGNER_IDENTITY}" > "${update_ssh_private_key_file}" - chmod 600 "${update_ssh_private_key_file}" - update_ldflags_args=(--update-public-keys "${update_public_key}") +pulse_release_prepare_signing_state "pulse-installer" "pulse-install" +trap 'pulse_release_cleanup_signing_state' EXIT +if [[ -n "${PULSE_RELEASE_UPDATE_PUBLIC_KEY:-}" ]]; then + update_ldflags_args=(--update-public-keys "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}") fi render_release_installers() { @@ -114,49 +93,7 @@ render_release_installers() { go run ./scripts/render_installers.go \ --source-dir ./scripts \ --output-dir "${output_dir}" \ - --installer-ssh-public-key "${update_ssh_public_key}" -} - -sign_release_file() { - local file="$1" - if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then - return 0 - fi - rm -f "${file}.sig" "${file}.sshsig" - ssh-keygen -q -Y sign \ - -f "${update_ssh_private_key_file}" \ - -n "${INSTALLER_SSH_SIGNER_NAMESPACE}" \ - "${file}" >/dev/null - mv "${file}.sig" "${file}.sshsig" - go run ./scripts/release_update_key.go sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${file}" > "${file}.sig" -} - -sign_directory_release_assets() { - local dir="$1" - if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then - return 0 - fi - while IFS= read -r -d '' file; do - sign_release_file "${file}" - 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}" + --installer-ssh-public-key "${PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY}" } # Clean previous builds @@ -280,9 +217,9 @@ for build_name in "${build_order[@]}"; do chmod 755 "$staging_dir/scripts/"*.sh chmod 755 "$staging_dir/scripts/"*.ps1 2>/dev/null || true echo "$VERSION" > "$staging_dir/VERSION" - sign_directory_release_assets "$staging_dir/bin" - sign_directory_release_assets "$staging_dir/scripts" - sign_release_file "$staging_dir/VERSION" + pulse_release_sign_directory_assets "$staging_dir/bin" + pulse_release_sign_directory_assets "$staging_dir/scripts" + pulse_release_sign_file "$staging_dir/VERSION" # Create tarball from staging directory cd "$staging_dir" @@ -364,9 +301,9 @@ chmod +x "$universal_dir/bin/pulse-agent" # Add VERSION file echo "$VERSION" > "$universal_dir/VERSION" -sign_directory_release_assets "$universal_dir/bin" -sign_directory_release_assets "$universal_dir/scripts" -sign_release_file "$universal_dir/VERSION" +pulse_release_sign_directory_assets "$universal_dir/bin" +pulse_release_sign_directory_assets "$universal_dir/scripts" +pulse_release_sign_file "$universal_dir/VERSION" # Package standalone unified agent binaries (all platforms) # Linux @@ -446,76 +383,9 @@ 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 -checksum_files=() -# Match all tarballs, zip files, exe files, and install scripts -if compgen -G "pulse-*.tar.gz" > /dev/null; then - checksum_files+=( pulse-*.tar.gz ) -fi -if compgen -G "pulse-*.tgz" > /dev/null; then - checksum_files+=( pulse-*.tgz ) -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 -if compgen -G "pulse-agent-freebsd-*" > /dev/null; then - checksum_files+=( pulse-agent-freebsd-* ) -fi -if compgen -G "pulse-*.exe" > /dev/null; then - checksum_files+=( pulse-*.exe ) -fi -if [ -f "install.sh" ]; then - checksum_files+=( install.sh ) -fi -if [ -f "install-docker.sh" ]; then - checksum_files+=( install-docker.sh ) -fi -if [ -f "pulse-auto-update.sh" ]; then - checksum_files+=( pulse-auto-update.sh ) -fi -if compgen -G "install*.ps1" > /dev/null; then - checksum_files+=( install*.ps1 ) -fi -if [ ${#checksum_files[@]} -eq 0 ]; then - echo "Warning: no release artifacts found to checksum." -else - # Generate checksums from a single sha256sum run for deterministic results (prevents #671 checksum mismatches) - checksum_output="$(sha256sum "${checksum_files[@]}" | sort -k 2)" - printf '%s\n' "$checksum_output" > checksums.txt - - # Emit per-file .sha256 artifacts for backward compatibility while legacy installers transition off them - while read -r checksum filename; do - printf '%s %s\n' "$checksum" "$filename" > "${filename}.sha256" - done <<< "$checksum_output" - - for artifact in "${checksum_files[@]}" checksums.txt; do - sign_release_file "${artifact}" - done - - if [ -n "${SIGNING_KEY_ID:-}" ]; then - if command -v gpg >/dev/null 2>&1; then - echo "Signing checksums with GPG key ${SIGNING_KEY_ID}..." - gpg --batch --yes --detach-sign --armor \ - --local-user "${SIGNING_KEY_ID}" \ - --output checksums.txt.asc \ - checksums.txt - else - echo "SIGNING_KEY_ID is set but gpg is not installed; skipping signature." - fi - fi -fi -shopt -u nullglob -cd .. +pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}" +mapfile -t checksum_files < <(pulse_release_collect_checksum_files "${RELEASE_DIR}") +pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}" echo echo "Release build complete!" diff --git a/scripts/installtests/backfill_release_assets_test.go b/scripts/installtests/backfill_release_assets_test.go new file mode 100644 index 000000000..ed1f5dd89 --- /dev/null +++ b/scripts/installtests/backfill_release_assets_test.go @@ -0,0 +1,174 @@ +package installtests + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestBackfillReleaseAssetsRewritesIntegrityMetadataLocally(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not installed") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not installed") + } + if _, err := exec.LookPath("ssh-keygen"); err != nil { + t.Skip("ssh-keygen not installed") + } + + tag := "v0.0.0-test" + releaseDir := t.TempDir() + + writeReleaseFile(t, releaseDir, "pulse-v0.0.0-test.tar.gz", "payload") + writeReleaseFile(t, releaseDir, "install.sh", "#!/bin/sh\necho test\n") + writeHistoricalChecksums(t, releaseDir, []string{ + "install.sh", + "pulse-v0.0.0-test.tar.gz", + }) + + syftStub := filepath.Join(t.TempDir(), "syft") + if err := os.WriteFile(syftStub, []byte(`#!/bin/sh +set -eu +out="" +for arg in "$@"; do + case "$arg" in + spdx-json=*) + out=${arg#spdx-json=} + ;; + esac +done +[ -n "$out" ] || exit 1 +printf '{"spdxVersion":"SPDX-2.3","files":[]}\n' > "$out" +`), 0o755); err != nil { + t.Fatalf("write syft stub: %v", err) + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate signing key: %v", err) + } + encodedPrivateKey := base64.StdEncoding.EncodeToString(privateKey) + + runBackfill := func() { + cmd := exec.Command("bash", "scripts/backfill-release-assets.sh", + "--tag", tag, + "--release-dir", releaseDir, + "--skip-download", + "--skip-upload", + ) + repoRootCmd := exec.Command("go", "env", "GOMOD") + gomodOutput, err := repoRootCmd.Output() + if err != nil { + t.Fatalf("resolve repo root via go env GOMOD: %v", err) + } + repoRoot := filepath.Dir(strings.TrimSpace(string(gomodOutput))) + repoRoot, err = filepath.Abs(repoRoot) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "PULSE_UPDATE_SIGNING_KEY="+encodedPrivateKey, + "PULSE_RELEASE_SBOM_TOOL="+syftStub, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("backfill-release-assets.sh failed: %v\n%s", err, output) + } + } + + runBackfill() + runBackfill() + + checksumsPath := filepath.Join(releaseDir, "checksums.txt") + checksumsBytes, err := os.ReadFile(checksumsPath) + if err != nil { + t.Fatalf("read checksums.txt: %v", err) + } + + releaseSBOM := "pulse-v0.0.0-test-release.sbom.spdx.json" + lines := strings.Split(strings.TrimSpace(string(checksumsBytes)), "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 checksummed artifacts after backfill, got %d:\n%s", len(lines), checksumsBytes) + } + + if _, err := os.Stat(filepath.Join(releaseDir, releaseSBOM)); err != nil { + t.Fatalf("missing generated release SBOM: %v", err) + } + if _, err := os.Stat(filepath.Join(releaseDir, "checksums.txt.sshsig")); err != nil { + t.Fatalf("missing checksums.txt.sshsig: %v", err) + } + if _, err := os.Stat(filepath.Join(releaseDir, "checksums.txt.sig")); err != nil { + t.Fatalf("missing checksums.txt.sig: %v", err) + } + + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) != 2 { + t.Fatalf("malformed checksums line %q", line) + } + + sum := fields[0] + filename := fields[1] + artifactPath := filepath.Join(releaseDir, filename) + data, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatalf("read %s: %v", filename, err) + } + actualSum := fmt.Sprintf("%x", sha256.Sum256(data)) + if actualSum != sum { + t.Fatalf("checksum mismatch for %s: got %s want %s", filename, actualSum, sum) + } + + shaFileBytes, err := os.ReadFile(artifactPath + ".sha256") + if err != nil { + t.Fatalf("read %s.sha256: %v", filename, err) + } + if strings.TrimSpace(string(shaFileBytes)) != line { + t.Fatalf("unexpected %s.sha256 contents: %q", filename, shaFileBytes) + } + + if _, err := os.Stat(artifactPath + ".sig"); err != nil { + t.Fatalf("missing %s.sig: %v", filename, err) + } + if _, err := os.Stat(artifactPath + ".sshsig"); err != nil { + t.Fatalf("missing %s.sshsig: %v", filename, err) + } + } +} + +func writeHistoricalChecksums(t *testing.T, releaseDir string, filenames []string) { + t.Helper() + + lines := make([]string, 0, len(filenames)) + for _, filename := range filenames { + data, err := os.ReadFile(filepath.Join(releaseDir, filename)) + if err != nil { + t.Fatalf("read %s: %v", filename, err) + } + line := fmt.Sprintf("%x %s", sha256.Sum256(data), filename) + lines = append(lines, line) + if err := os.WriteFile(filepath.Join(releaseDir, filename+".sha256"), []byte(line+"\n"), 0o644); err != nil { + t.Fatalf("write %s.sha256: %v", filename, err) + } + } + + if err := os.WriteFile(filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatalf("write checksums.txt: %v", err) + } +} + +func writeReleaseFile(t *testing.T, releaseDir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(releaseDir, name), []byte(content), 0o755); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index e23710d22..375bdffa1 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -15,6 +15,10 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { script := string(content) required := []string{ + `SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`, + `PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"`, + `cd "${PULSE_REPO_ROOT}"`, + `source "${SCRIPT_DIR}/release_asset_common.sh"`, `RENDERED_INSTALLERS_DIR="${BUILD_DIR}/rendered-installers"`, `go run ./scripts/render_installers.go \`, `cp "${RENDERED_INSTALLERS_DIR}/install.sh" "$RELEASE_DIR/install.sh"`, @@ -38,23 +42,43 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { requiredScriptWiring := []string{ `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}"`, - `ssh-keygen -q -Y sign`, - `sign_release_file "${artifact}"`, + `pulse_release_prepare_signing_state "pulse-installer" "pulse-install"`, + `trap 'pulse_release_cleanup_signing_state' EXIT`, + `--installer-ssh-public-key "${PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY}"`, + `pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}"`, + `mapfile -t checksum_files < <(pulse_release_collect_checksum_files "${RELEASE_DIR}")`, + `pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"`, } for _, needle := range requiredScriptWiring { if !strings.Contains(script, needle) { t.Fatalf("build-release.sh missing canonical ldflags wiring: %s", needle) } } + + helperBytes, err := os.ReadFile(repoFile("scripts", "release_asset_common.sh")) + if err != nil { + t.Fatalf("read release_asset_common.sh: %v", err) + } + helper := string(helperBytes) + helperRequired := []string{ + `: "${PULSE_SCRIPTS_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"`, + `: "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}"`, + `go -C "${PULSE_REPO_ROOT}" run "${PULSE_SCRIPTS_DIR}/release_update_key.go" "$@"`, + `pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, + `pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, + `pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, + `pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${absolute_file}"`, + `ssh-keygen -q -Y sign`, + `"${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}"`, + `if compgen -G "pulse-*.sbom.spdx.json" > /dev/null; then`, + `find . -maxdepth 1 -type f \( -name '*.sig' -o -name '*.sshsig' \) -delete`, + } + for _, needle := range helperRequired { + if !strings.Contains(helper, needle) { + t.Fatalf("release_asset_common.sh missing canonical release asset wiring: %s", needle) + } + } } func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { @@ -105,6 +129,61 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { } } +func TestBackfillReleaseWorkflowRepairsPublishedAssetsWithoutRebuilds(t *testing.T) { + scriptBytes, err := os.ReadFile(repoFile("scripts", "backfill-release-assets.sh")) + if err != nil { + t.Fatalf("read backfill-release-assets.sh: %v", err) + } + script := string(scriptBytes) + scriptRequired := []string{ + `SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`, + `PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"`, + `cd "${PULSE_REPO_ROOT}"`, + `source "${SCRIPT_DIR}/release_asset_common.sh"`, + `gh release view "${TAG}" -R "${REPO}" --json isDraft,tagName`, + `Error: ${TAG} is still a draft release; use the normal release pipeline instead of historical backfill.`, + `gh release download "${TAG}" -R "${REPO}" --dir "${RELEASE_DIR}" --clobber`, + `pulse_release_prepare_signing_state "pulse-installer" "pulse-install"`, + `pulse_release_generate_packet_sbom "${PAYLOAD_DIR}" "${RELEASE_PACKET_SBOM}"`, + `pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"`, + `gh release upload "${TAG}" "${RELEASE_DIR}/checksums.txt" --clobber`, + `gh release upload "${TAG}" "${RELEASE_DIR}"/*.sha256 --clobber`, + `gh release upload "${TAG}" "${RELEASE_DIR}"/*.sig --clobber`, + `gh release upload "${TAG}" "${RELEASE_DIR}"/*.sshsig --clobber`, + `gh release upload "${TAG}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" --clobber`, + } + for _, needle := range scriptRequired { + if !strings.Contains(script, needle) { + t.Fatalf("backfill-release-assets.sh missing required historical backfill step: %s", needle) + } + } + + workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "backfill-release-assets.yml")) + if err != nil { + t.Fatalf("read backfill-release-assets.yml: %v", err) + } + workflow := string(workflowBytes) + workflowRequired := []string{ + `name: Backfill Release Assets`, + `workflow_dispatch:`, + `contents: write`, + `runs-on: ubuntu-24.04`, + `uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4`, + `uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5`, + `SYFT_VERSION="1.42.4"`, + `SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`, + `SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`, + `./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"`, + `PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, + `./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"`, + } + for _, needle := range workflowRequired { + if !strings.Contains(workflow, needle) { + t.Fatalf("backfill-release-assets.yml missing required release-repair step: %s", needle) + } + } +} + func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { localValidatorBytes, err := os.ReadFile(repoFile("scripts", "validate-release.sh")) if err != nil { @@ -160,12 +239,18 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { contractRequired := []string{ "`scripts/validate-release.sh`", "`scripts/validate-published-release.sh`", + "`scripts/backfill-release-assets.sh`", + "`.github/workflows/backfill-release-assets.yml`", "`scripts/validate-release.sh`, and", - "`scripts/validate-published-release.sh` must derive the embedded update trust", + "`scripts/release_asset_common.sh`", + "must derive the embedded update trust root", "standalone SPDX JSON SBOM", + "already-published packet", + "derived integrity assets", "and fail validation if", - "published artifact or `checksums.txt` is missing its `.sshsig` sidecar", - "canonical release-packet SBOM is absent", + "published artifact or", + "`checksums.txt` is missing its `.sshsig` sidecar", + "release-packet SBOM is absent", } for _, needle := range contractRequired { if !strings.Contains(contract, needle) { diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh new file mode 100644 index 000000000..3338ebb3d --- /dev/null +++ b/scripts/release_asset_common.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash + +# Shared helpers for Pulse release asset assembly and integrity metadata. + +: "${PULSE_SCRIPTS_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +: "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}" + +pulse_release_go_run_update_key() { + go -C "${PULSE_REPO_ROOT}" run "${PULSE_SCRIPTS_DIR}/release_update_key.go" "$@" +} + +pulse_release_prepare_signing_state() { + local signer_identity="${1:-pulse-installer}" + local signer_namespace="${2:-pulse-install}" + + PULSE_RELEASE_SIGNER_IDENTITY="${signer_identity}" + PULSE_RELEASE_SIGNER_NAMESPACE="${signer_namespace}" + PULSE_RELEASE_UPDATE_PUBLIC_KEY="" + PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY="" + PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE="" + + if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then + if [[ "${PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY:-false}" == "true" ]]; then + echo "Warning: PULSE_UPDATE_SIGNING_KEY not set; continuing because PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true." + return 0 + fi + echo "Error: PULSE_UPDATE_SIGNING_KEY is required for release builds." >&2 + echo "Set PULSE_ALLOW_MISSING_UPDATE_SIGNING_KEY=true only for local non-release debugging." >&2 + exit 1 + fi + + PULSE_RELEASE_UPDATE_PUBLIC_KEY="$(pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}")" + if [[ -z "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}" ]]; then + echo "Error: failed to derive update signing public key." >&2 + exit 1 + fi + + if ! command -v ssh-keygen >/dev/null 2>&1; then + echo "Error: ssh-keygen is required to sign installer release assets." >&2 + exit 1 + fi + + PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY="$(pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}" --comment "${PULSE_RELEASE_SIGNER_IDENTITY}")" + if [[ -z "${PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY}" ]]; then + echo "Error: failed to derive installer SSH signing public key." >&2 + exit 1 + fi + + PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE="$(mktemp)" + pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}" --comment "${PULSE_RELEASE_SIGNER_IDENTITY}" > "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE}" + chmod 600 "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE}" +} + +pulse_release_cleanup_signing_state() { + if [[ -n "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE:-}" && -f "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE}" ]]; then + rm -f "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE}" + fi +} + +pulse_release_sign_file() { + local file="$1" + local absolute_file="${file}" + + if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then + return 0 + fi + + if [[ "${absolute_file}" != /* ]]; then + absolute_file="$(pwd)/${file}" + fi + + rm -f "${absolute_file}.sig" "${absolute_file}.sshsig" + ssh-keygen -q -Y sign \ + -f "${PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE}" \ + -n "${PULSE_RELEASE_SIGNER_NAMESPACE}" \ + "${absolute_file}" >/dev/null + mv "${absolute_file}.sig" "${absolute_file}.sshsig" + pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${absolute_file}" > "${absolute_file}.sig" +} + +pulse_release_sign_directory_assets() { + local dir="$1" + + if [[ -z "${PULSE_UPDATE_SIGNING_KEY:-}" ]]; then + return 0 + fi + + while IFS= read -r -d '' file; do + pulse_release_sign_file "${file}" + done < <(find "${dir}" -maxdepth 1 -type f ! -name '*.sig' ! -name '*.sshsig' -print0) +} + +pulse_release_generate_packet_sbom() { + local release_dir="$1" + local output_name="$2" + local sbom_tool="${PULSE_RELEASE_SBOM_TOOL:-syft}" + local tmp_base="${BUILD_DIR:-${release_dir}}" + local resolved_tool="" + local tmp_sbom="" + + if [[ "${sbom_tool}" == */* ]]; then + resolved_tool="${sbom_tool}" + else + resolved_tool="$(command -v "${sbom_tool}" || true)" + fi + + if [[ -z "${resolved_tool}" || ! -x "${resolved_tool}" ]]; 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 + + mkdir -p "${tmp_base}" + tmp_sbom="$(mktemp "${tmp_base}/release-packet-sbom.XXXXXX")" + echo "Generating release-packet SBOM ${output_name}..." + "${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}" + mv "${tmp_sbom}" "${release_dir}/${output_name}" +} + +pulse_release_collect_checksum_files() { + local release_dir="$1" + + ( + cd "${release_dir}" + shopt -s nullglob extglob + local -a checksum_files=() + + if compgen -G "pulse-*.tar.gz" > /dev/null; then + checksum_files+=( pulse-*.tar.gz ) + fi + if compgen -G "pulse-*.tgz" > /dev/null; then + checksum_files+=( pulse-*.tgz ) + 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 + if compgen -G "pulse-agent-freebsd-*" > /dev/null; then + checksum_files+=( pulse-agent-freebsd-* ) + fi + if compgen -G "pulse-*.exe" > /dev/null; then + checksum_files+=( pulse-*.exe ) + fi + if [[ -f "install.sh" ]]; then + checksum_files+=( install.sh ) + fi + if [[ -f "install-docker.sh" ]]; then + checksum_files+=( install-docker.sh ) + fi + if [[ -f "pulse-auto-update.sh" ]]; then + checksum_files+=( pulse-auto-update.sh ) + fi + if compgen -G "install*.ps1" > /dev/null; then + checksum_files+=( install*.ps1 ) + fi + + if [[ ${#checksum_files[@]} -gt 0 ]]; then + printf '%s\n' "${checksum_files[@]}" | sort -u + fi + ) +} + +pulse_release_write_checksums_and_signatures() { + local release_dir="$1" + shift + local -a checksum_files=("$@") + + ( + cd "${release_dir}" + + rm -f checksums.txt checksums.txt.sig checksums.txt.sshsig + find . -maxdepth 1 -type f -name '*.sha256' -delete + find . -maxdepth 1 -type f \( -name '*.sig' -o -name '*.sshsig' \) -delete + + if [[ ${#checksum_files[@]} -eq 0 ]]; then + echo "Warning: no release artifacts found to checksum." + exit 0 + fi + + local checksum_output="" + checksum_output="$(sha256sum "${checksum_files[@]}" | sort -k 2)" + printf '%s\n' "${checksum_output}" > checksums.txt + + while read -r checksum filename; do + [[ -n "${checksum:-}" && -n "${filename:-}" ]] || continue + printf '%s %s\n' "${checksum}" "${filename}" > "${filename}.sha256" + done <<< "${checksum_output}" + + local artifact="" + for artifact in "${checksum_files[@]}" checksums.txt; do + pulse_release_sign_file "${artifact}" + done + + if [[ -n "${SIGNING_KEY_ID:-}" ]]; then + if command -v gpg >/dev/null 2>&1; then + echo "Signing checksums with GPG key ${SIGNING_KEY_ID}..." + gpg --batch --yes --detach-sign --armor \ + --local-user "${SIGNING_KEY_ID}" \ + --output checksums.txt.asc \ + checksums.txt + else + echo "SIGNING_KEY_ID is set but gpg is not installed; skipping signature." + fi + fi + ) +} diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 9c3af765d..85735ab77 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -283,9 +283,36 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", content) self.assertIn("subject-path: release/*", content) build_script = read("scripts/build-release.sh") + release_asset_helper = read("scripts/release_asset_common.sh") + backfill_script = read("scripts/backfill-release-assets.sh") + backfill_workflow = read(".github/workflows/backfill-release-assets.yml") 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('SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', build_script) + self.assertIn('PULSE_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"', build_script) + self.assertIn('cd "${PULSE_REPO_ROOT}"', build_script) + self.assertIn('source "${SCRIPT_DIR}/release_asset_common.sh"', build_script) + self.assertIn('pulse_release_prepare_signing_state "pulse-installer" "pulse-install"', build_script) + self.assertIn('pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}"', build_script) + self.assertIn('pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"', build_script) + self.assertIn(': "${PULSE_SCRIPTS_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"', release_asset_helper) + self.assertIn(': "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}"', release_asset_helper) + self.assertIn('go -C "${PULSE_REPO_ROOT}" run "${PULSE_SCRIPTS_DIR}/release_update_key.go" "$@"', release_asset_helper) + self.assertIn('pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) + self.assertIn('pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) + self.assertIn('pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) + self.assertIn('pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${file}"', release_asset_helper) + self.assertIn('"${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}"', release_asset_helper) + self.assertIn('find . -maxdepth 1 -type f \\( -name \'*.sig\' -o -name \'*.sshsig\' \\) -delete', release_asset_helper) + self.assertIn('source "${SCRIPT_DIR}/release_asset_common.sh"', backfill_script) + self.assertIn('gh release download "${TAG}" -R "${REPO}" --dir "${RELEASE_DIR}" --clobber', backfill_script) + self.assertIn('pulse_release_generate_packet_sbom "${PAYLOAD_DIR}" "${RELEASE_PACKET_SBOM}"', backfill_script) + self.assertIn('pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}"', backfill_script) + self.assertIn('gh release upload "${TAG}" "${RELEASE_DIR}/${RELEASE_PACKET_SBOM}" --clobber', backfill_script) + self.assertIn("name: Backfill Release Assets", backfill_workflow) + self.assertIn("workflow_dispatch:", backfill_workflow) + self.assertIn('SYFT_VERSION="1.42.4"', backfill_workflow) + self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"', backfill_workflow) + self.assertIn('./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"', backfill_workflow) 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)