#!/bin/bash set -euo pipefail ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) VERSION=1.2.19 OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}" WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd) DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}" NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}" NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}" DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}" BUILD_TMP_ROOT="${LIVERECORDER_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}" if [ -n "${FNPACK:-}" ]; then FNPACK_BIN="$FNPACK" elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then FNPACK_BIN="$ROOT_DIR/.tools/fnpack" else FNPACK_BIN=fnpack fi FNPACK_BIN=$(command -v "$FNPACK_BIN") || { printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2 exit 1 } for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar node; do command -v "$command_name" >/dev/null 2>&1 || { printf 'required build command is missing: %s\n' "$command_name" >&2 exit 1 } done test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; } test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; } mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")" WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX") trap 'rm -rf -- "$WORK_DIR"' EXIT STAGE="$WORK_DIR/stage" PACKED_ROOT="$WORK_DIR/packed" FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp" EXTRACT_ROOT="$WORK_DIR/debian-root" RUNTIME_ROOT="$STAGE/app/runtime" mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \ "$RUNTIME_ROOT/etc/ssl/certs" \ "$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT" cp -a "$ROOT_DIR/fnos/." "$STAGE/" printf 'Building frontend...\n' npm run build --prefix "$ROOT_DIR/frontend" printf 'Publishing self-contained .NET application...\n' export NUGET_PACKAGES DOTNET_CLI_HOME "$DOTNET_BIN" restore "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \ -r linux-x64 \ --source "$NUGET_FEED" \ --source "https://api.nuget.org/v3/index.json" \ --disable-parallel "$DOTNET_BIN" publish "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \ -c Release \ -r linux-x64 \ --self-contained true \ --no-restore \ -p:DebugType=None \ -p:DebugSymbols=false \ -p:PublishSingleFile=false \ -p:PublishReadyToRun=false \ -o "$STAGE/app/server" \ /maxcpucount:1 rm -f "$STAGE/app/server/"*.pdb mkdir -p "$STAGE/app/server/wwwroot" cp -a "$ROOT_DIR/frontend/dist/." "$STAGE/app/server/wwwroot/" printf 'Downloading pinned Debian Bookworm native runtime packages...\n' APT_ROOT="$WORK_DIR/apt" mkdir -p \ "$APT_ROOT/etc/apt" \ "$APT_ROOT/var/lib/apt/lists/partial" \ "$APT_ROOT/var/lib/dpkg" \ "$APT_ROOT/var/cache/apt/archives/partial" cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list" touch "$APT_ROOT/var/lib/dpkg/status" APT_OPTIONS=( -o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list" -o "Dir::Etc::sourceparts=-" -o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status" -o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists" -o "Dir::Cache=$APT_ROOT/var/cache/apt" -o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives" -o "Debug::NoLocking=1" -o "APT::Architecture=amd64" -o "Acquire::Languages=none" ) apt-get "${APT_OPTIONS[@]}" update apt-get "${APT_OPTIONS[@]}" \ --download-only \ --no-install-recommends \ --yes \ install \ curl \ ca-certificates shopt -s nullglob packages=("$APT_ROOT"/var/cache/apt/archives/*.deb) test "${#packages[@]}" -gt 0 || { printf 'APT did not download runtime packages\n' >&2; exit 1; } for package_file in "${packages[@]}"; do dpkg-deb -x "$package_file" "$EXTRACT_ROOT" done shopt -u nullglob resolve_extracted_file() { local current=$1 target normalized depth=0 while [ -L "$current" ]; do depth=$((depth + 1)) [ "$depth" -le 32 ] || { printf 'too many symlink levels: %s\n' "$1" >&2; return 1; } target=$(readlink -- "$current") case "$target" in /*) current="$EXTRACT_ROOT$target" ;; *) current="$(dirname -- "$current")/$target" ;; esac normalized=$(realpath -m -- "$current") case "$normalized" in "$EXTRACT_ROOT"/*) current="$normalized" ;; *) printf 'Debian package link escapes extraction root: %s\n' "$1" >&2; return 1 ;; esac done [ -f "$current" ] || { printf 'missing extracted runtime file: %s\n' "$1" >&2; return 1; } printf '%s\n' "$current" } copy_extracted_file() { local source resolved destination=$2 mode=${3:-0644} source=$1 resolved=$(resolve_extracted_file "$source") mkdir -p "$(dirname -- "$destination")" install -m "$mode" "$resolved" "$destination" } printf 'Assembling minimal relocatable runtime...\n' copy_extracted_file "$EXTRACT_ROOT/usr/bin/curl" "$RUNTIME_ROOT/bin/curl" 0755 CA_CONFIG="$EXTRACT_ROOT/etc/ca-certificates.conf" CA_SOURCE_ROOT="$EXTRACT_ROOT/usr/share/ca-certificates" CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt" : >"$CA_BUNDLE" if [ -s "$CA_CONFIG" ]; then while IFS= read -r certificate; do case "$certificate" in ''|'#'*|'!'*) continue ;; esac [ -f "$CA_SOURCE_ROOT/$certificate" ] || continue sed -e '$a\' "$CA_SOURCE_ROOT/$certificate" >>"$CA_BUNDLE" done <"$CA_CONFIG" else while IFS= read -r -d '' certificate; do sed -e '$a\' "$certificate" >>"$CA_BUNDLE" done < <(find "$CA_SOURCE_ROOT" -type f -name '*.crt' -print0 | sort -z) fi [ -s "$CA_BUNDLE" ] || { printf 'unable to assemble CA certificate bundle\n' >&2; exit 1; } chmod 0644 "$CA_BUNDLE" declare -A COPIED_LIBRARIES=() ELF_QUEUE=() while IFS= read -r -d '' elf_file; do if readelf -h "$elf_file" >/dev/null 2>&1; then ELF_QUEUE+=("$elf_file") fi done < <(find "$RUNTIME_ROOT/bin" -type f -print0) is_system_glibc_library() { case "$1" in ld-linux-*.so.*|libc.so.*|libBrokenLocale.so.*|libanl.so.*|libdl.so.*|libm.so.*|libmvec.so.*|libnss_compat.so.*|libnss_dns.so.*|libnss_files.so.*|libnss_hesiod.so.*|libpthread.so.*|libresolv.so.*|librt.so.*|libthread_db.so.*|libutil.so.*) return 0 ;; *) return 1 ;; esac } queue_index=0 while [ "$queue_index" -lt "${#ELF_QUEUE[@]}" ]; do elf_file=${ELF_QUEUE[$queue_index]} queue_index=$((queue_index + 1)) while IFS= read -r needed; do [ -n "$needed" ] || continue is_system_glibc_library "$needed" && continue [ -z "${COPIED_LIBRARIES[$needed]:-}" ] || continue source=$(find "$EXTRACT_ROOT" \( -type f -o -type l \) -name "$needed" -print -quit) [ -n "$source" ] || { printf 'unable to resolve native dependency %s required by %s\n' "$needed" "$elf_file" >&2 exit 1 } destination="$RUNTIME_ROOT/lib/$needed" copy_extracted_file "$source" "$destination" 0755 COPIED_LIBRARIES[$needed]=1 ELF_QUEUE+=("$destination") done < <(readelf -d "$elf_file" 2>/dev/null | sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p') done for required_file in \ "$RUNTIME_ROOT/bin/curl"; do test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; } done node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images" chmod 0755 "$STAGE/cmd/"* printf 'Packing with official fnOS fnpack...\n' ( cd "$PACKED_ROOT" TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE" ) mv "$PACKED_ROOT/liverecorder.fpk" "$OUTPUT" ( cd "$(dirname -- "$OUTPUT")" sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256" ) LIVERECORDER_VERIFY_TMPDIR="${LIVERECORDER_VERIFY_TMPDIR:-$BUILD_TMP_ROOT}" \ "$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT" printf 'Built %s\n' "$OUTPUT"