Files
pulse/scripts/build-release-binaries.sh
T
2026-08-22 11:07:26 +01:00

294 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build the credential-free release payload that can run in parallel with
# platform-native signing. Packaging and release signing happen separately on
# a hosted runner after this payload's exact-SHA manifest is verified.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
source "${SCRIPT_DIR}/release_build_targets.sh"
VERSION="${1:-$(tr -d '\n\r[:space:]' < VERSION)}"
OUTPUT_ROOT="${2:-}"
PROFILE="full"
if [[ "${3:-}" == "--profile" ]]; then
PROFILE="${4:-}"
if [[ -z "${PROFILE}" || -n "${5:-}" ]]; then
echo "Usage: $0 <version> <output-directory> [--profile full|pro-packaging]" >&2
exit 2
fi
elif [[ -n "${3:-}" ]]; then
echo "Usage: $0 <version> <output-directory> [--profile full|pro-packaging]" >&2
exit 2
fi
if [[ -z "${OUTPUT_ROOT}" ]]; then
echo "Usage: $0 <version> <output-directory> [--profile full|pro-packaging]" >&2
exit 2
fi
case "${PROFILE}" in
full|pro-packaging) ;;
*)
echo "Error: unsupported release compilation profile: ${PROFILE}" >&2
exit 2
;;
esac
OUTPUT_ROOT="$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "${OUTPUT_ROOT}")"
case "${OUTPUT_ROOT}" in
/|"${REPO_ROOT}"|"${REPO_ROOT}/scripts")
echo "Error: refusing unsafe release compilation output: ${OUTPUT_ROOT}" >&2
exit 2
;;
esac
required_go="go1.26.5"
current_go="$(go env GOVERSION 2>/dev/null || true)"
if [[ "${current_go}" != "${required_go}" ]]; then
echo "Error: Go toolchain must be ${required_go} (got ${current_go:-unknown})." >&2
exit 3
fi
for command_name in go npm python3 openssl getconf pgrep; do
command -v "${command_name}" >/dev/null 2>&1 || {
echo "Error: required release compilation command is missing: ${command_name}" >&2
exit 3
}
done
for variable_name in PULSE_LICENSE_PUBLIC_KEY PULSE_UPDATE_SIGNING_PUBLIC_KEY; do
if [[ -z "${!variable_name:-}" ]]; then
echo "Error: ${variable_name} is required for release compilation." >&2
exit 3
fi
done
decoded_key_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | openssl base64 -d -A 2>/dev/null | wc -c | tr -d ' ')"
if [[ "${decoded_key_len}" != "32" ]]; then
echo "Error: PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2
exit 3
fi
SOURCE_SHA="$(git rev-parse HEAD)"
if [[ ! "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Error: release compilation requires an exact Git commit." >&2
exit 3
fi
if [[ "$(tr -d '\n\r[:space:]' < VERSION)" != "${VERSION}" ]]; then
echo "Error: requested version ${VERSION} does not match VERSION." >&2
exit 3
fi
PAYLOAD_DIR="${OUTPUT_ROOT}/payload"
BINARIES_DIR="${PAYLOAD_DIR}/binaries"
FRONTEND_DIR="${PAYLOAD_DIR}/frontend-dist"
MANIFEST_DIR="${OUTPUT_ROOT}/manifest"
if [[ -e "${OUTPUT_ROOT}" ]] && find "${OUTPUT_ROOT}" -mindepth 1 -print -quit | grep -q .; then
echo "Error: release compilation output must be absent or empty: ${OUTPUT_ROOT}" >&2
exit 2
fi
mkdir -p "${BINARIES_DIR}" "${MANIFEST_DIR}"
build_frontend() {
echo "Building exact-SHA frontend embed prerequisite..."
npm --prefix frontend-modern ci
npm --prefix frontend-modern run build
if [[ "${PROFILE}" == "full" ]]; then
mkdir -p "${FRONTEND_DIR}"
cp -a frontend-modern/dist/. "${FRONTEND_DIR}/"
fi
}
frontend_log="${OUTPUT_ROOT}/frontend.log"
build_frontend >"${frontend_log}" 2>&1 &
frontend_pid=$!
if [[ "${PROFILE}" == "full" ]]; then
echo "Building frontend bundle concurrently with the release binary matrix."
else
echo "Using Pro packaging profile: build the required frontend embed locally; transfer public Unified Agent binaries only."
fi
export CGO_ENABLED=0
release_go_build_args=(-buildvcs=false -trimpath)
agent_ldflags="$(./scripts/release_ldflags.sh agent \
--version "v${VERSION}" \
--update-public-keys "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}")"
build_time="$(date -u '+%Y-%m-%d_%H:%M:%S')"
git_commit="$(git rev-parse --short HEAD)"
server_ldflags="$(./scripts/release_ldflags.sh server \
--version "v${VERSION}" \
--build-time "${build_time}" \
--git-commit "${git_commit}" \
--license-public-key "${PULSE_LICENSE_PUBLIC_KEY}" \
--update-public-keys "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}")"
vcpus="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')"
build_jobs="${PULSE_RELEASE_BUILD_JOBS:-$((vcpus / 2))}"
if (( build_jobs < 1 )); then build_jobs=1; fi
if (( build_jobs > 4 )); then build_jobs=4; fi
go_procs="${PULSE_RELEASE_GO_PROCS:-$((vcpus / build_jobs))}"
if (( go_procs < 1 )); then go_procs=1; fi
echo "Compiling release matrix with ${build_jobs} workers and GOMAXPROCS=${go_procs} per worker..."
declare -a task_components=()
declare -a task_targets=()
for target in "${PULSE_RELEASE_AGENT_TARGETS[@]}"; do
task_components+=(agent)
task_targets+=("${target}")
if [[ "${PROFILE}" == "full" ]]; then
task_components+=(mcp)
task_targets+=("${target}")
fi
done
if [[ "${PROFILE}" == "full" ]]; then
for target in "${PULSE_RELEASE_SERVER_TARGETS[@]}"; do
task_components+=(server)
task_targets+=("${target}")
done
for target in "${PULSE_RELEASE_CONTROL_PLANE_TARGETS[@]}"; do
task_components+=(control-plane)
task_targets+=("${target}")
done
fi
build_one() {
local component="$1"
local target="$2"
local target_env output package ldflags
local -a target_env_parts command
target_env="$(pulse_release_target_env "${target}")"
output="${BINARIES_DIR}/$(pulse_release_binary_filename "${component}" "${target}")"
case "${component}" in
agent)
package=./cmd/pulse-agent
ldflags="${agent_ldflags}"
;;
mcp)
package=./cmd/pulse-mcp
ldflags=""
;;
server)
package=./cmd/pulse
ldflags="${server_ldflags}"
;;
control-plane)
package=./cmd/pulse-control-plane
ldflags="${server_ldflags}"
;;
esac
read -r -a target_env_parts <<<"${target_env}"
command=(go build "${release_go_build_args[@]}")
if [[ "${component}" == server || "${component}" == control-plane ]]; then command+=(-tags release); fi
if [[ -n "${ldflags}" ]]; then command+=("-ldflags=${ldflags}"); fi
command+=(-o "${output}" "${package}")
env "${target_env_parts[@]}" GOMAXPROCS="${go_procs}" "${command[@]}"
}
declare -a active_pids=()
declare -A task_by_pid=()
terminate_tree() {
local parent_pid="$1"
local child_pid
while IFS= read -r child_pid; do
[[ -n "${child_pid}" ]] && terminate_tree "${child_pid}"
done < <(pgrep -P "${parent_pid}" || true)
kill -TERM "${parent_pid}" >/dev/null 2>&1 || true
}
terminate_active() {
local pid
for pid in "${active_pids[@]:-}"; do
[[ -n "${pid}" ]] && terminate_tree "${pid}"
done
wait "${active_pids[@]:-}" >/dev/null 2>&1 || true
if [[ -n "${frontend_pid:-}" ]]; then
terminate_tree "${frontend_pid}"
wait "${frontend_pid}" >/dev/null 2>&1 || true
frontend_pid=""
fi
}
trap terminate_active INT TERM
finish_frontend() {
local pid status
if [[ -z "${frontend_pid:-}" ]]; then
return
fi
pid="${frontend_pid}"
frontend_pid=""
if wait "${pid}"; then
status=0
else
status=$?
echo "Error: frontend embed prerequisite failed." >&2
cat "${frontend_log}" >&2
terminate_active
exit "${status}"
fi
rm -f "${frontend_log}"
echo "Built frontend embed prerequisite."
}
next_task=0
completed_tasks=0
total_tasks="${#task_components[@]}"
while (( completed_tasks < total_tasks )); do
while (( next_task < total_tasks && ${#active_pids[@]} < build_jobs )); do
component="${task_components[next_task]}"
target="${task_targets[next_task]}"
if [[ "${component}" == server || "${component}" == control-plane ]]; then
finish_frontend
fi
log_path="${OUTPUT_ROOT}/${component}-${target}.log"
build_one "${component}" "${target}" >"${log_path}" 2>&1 &
pid=$!
active_pids+=("${pid}")
task_by_pid["${pid}"]="${component}-${target}:${log_path}"
next_task=$((next_task + 1))
done
completed_pid=""
# Restrict wait -n to compilation children. The frontend build is also a
# child of this shell, and an unrestricted wait can reap it as if it were
# one of the matrix tasks. That advances completed_tasks early, leaves one
# binary build unjoined, and can publish an incomplete compiled manifest.
if wait -n -p completed_pid "${active_pids[@]}"; then
status=0
else
status=$?
fi
task_record="${task_by_pid[${completed_pid}]:-}"
if [[ -z "${task_record}" ]]; then
echo "Error: completed release compilation child is not in the active task set: ${completed_pid:-unknown}." >&2
terminate_active
exit 4
fi
task_name="${task_record%%:*}"
log_path="${task_record#*:}"
remaining_pids=()
for pid in "${active_pids[@]}"; do
if [[ "${pid}" != "${completed_pid}" ]]; then remaining_pids+=("${pid}"); fi
done
active_pids=("${remaining_pids[@]}")
unset "task_by_pid[${completed_pid}]"
if (( status != 0 )); then
echo "Error: release compilation task failed: ${task_name}" >&2
[[ -f "${log_path}" ]] && cat "${log_path}" >&2
terminate_active
exit "${status}"
fi
rm -f "${log_path}"
completed_tasks=$((completed_tasks + 1))
echo "Compiled ${task_name} (${completed_tasks}/${total_tasks})."
done
finish_frontend
trap - INT TERM
python3 scripts/release_candidate_manifest.py create \
--release-dir "${PAYLOAD_DIR}" \
--version "${VERSION}" \
--source-sha "${SOURCE_SHA}" \
--output "${MANIFEST_DIR}/release-compiled.json"
echo "Credential-free release compilation complete: ${OUTPUT_ROOT}"