Add historical release asset backfill workflow

This commit is contained in:
rcourtman 2026-04-22 17:25:58 +01:00
parent 3308bb733e
commit 16ad67a9b5
9 changed files with 875 additions and 180 deletions

View file

@ -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 }}"

View file

@ -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

View file

@ -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"
]

View file

@ -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 <tag> [options]
Options:
--repo <owner/repo> GitHub repository (default: rcourtman/Pulse)
--release-dir <path> 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}."

View file

@ -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!"

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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
)
}

View file

@ -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)