feedBack-desktop/scripts/build-common.sh
OmikronApex fcce4672d9 feat(build): bundle bongocat plugin
Add got-feedback/feedback-plugin-bongocat to the clone_slopsmith plugin
list so CI/release builds ship Bongo Cat's Rhythm Trainer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:01:52 +02:00

610 lines
20 KiB
Bash

#!/bin/bash
# Common build logic for all platforms.
# Platform scripts source this file and implement four functions:
# install_system_deps() — install OS packages (apt / brew / winget)
# bundle_python_impl() — bundle Python runtime
# bundle_binaries_impl() — bundle system binaries (ffmpeg etc.)
# get_expected_artifacts() — globs verify_artifacts checks at the end
set -euo pipefail
# Check if this is being sourced by a platform script
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "Error: build-common.sh should not be run directly" >&2
echo "Run ./build-release.sh instead" >&2
exit 1
fi
# Script directory must be set by sourcing script
if [[ -z "${SCRIPT_DIR:-}" ]]; then
echo "Error: SCRIPT_DIR not set by sourcing script" >&2
exit 1
fi
# is_skipped_lib() — glibc/loader skip list, shared verbatim with
# bundle-binaries.sh so the bundler and the audit never disagree.
source "$SCRIPT_DIR/bundled-lib-skiplist.sh"
# Colors
if [[ -z "${RED:-}" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
fi
# Check that required variables are set
if [[ -z "${PROJECT_DIR:-}" ]]; then
echo "Error: PROJECT_DIR not set by sourcing script" >&2
exit 1
fi
if [[ -z "${PLATFORM:-}" ]]; then
echo "Error: PLATFORM not set by sourcing script" >&2
exit 1
fi
# Ensure platform is lowercase
PLATFORM="$(echo "$PLATFORM" | tr '[:upper:]' '[:lower:]')"
# Validate platform
if [[ ! "$PLATFORM" =~ ^(linux|macos|windows)$ ]]; then
echo -e "${RED}Error: Invalid platform: $PLATFORM${NC}" >&2
exit 1
fi
# Configuration file
CONFIG="$PROJECT_DIR/.build-config.json"
PARSE_CONFIG="$SCRIPT_DIR/parse-build-config.py"
# Check config file
if [[ ! -f "$CONFIG" ]]; then
echo -e "${RED}Error: $CONFIG not found${NC}" >&2
exit 1
fi
if ! python3 "$PARSE_CONFIG" "$CONFIG" >/dev/null; then
echo -e "${RED}Error: $CONFIG is not valid JSON${NC}" >&2
exit 1
fi
get_cfg() { python3 "$PARSE_CONFIG" "$CONFIG" "$1"; }
# --- Platform functions (to be implemented by platform scripts) ---
# Platform scripts MUST implement these three functions:
# install_system_deps()
# bundle_python_impl()
# bundle_binaries_impl()
# --- Common Build Steps ---
# Clone Slopsmith and plugins (shared across all platforms)
clone_slopsmith() {
# RUNNER_TEMP on Windows runners is a native Windows path
# (e.g. `D:\a\_temp`) that Git Bash / MSYS tools don't reliably
# treat as a filesystem path. POSIX `/tmp/slopsmith` is the
# default; if a non-POSIX environment really wants RUNNER_TEMP, it
# can pass an explicit clone_dir argument (resolved via cygpath -u
# on Windows if needed).
local clone_dir="${1:-/tmp/slopsmith}"
# Skip if already set for local development
if [[ -n "${SLOPSMITH_DIR:-}" ]] && [[ -d "$SLOPSMITH_DIR" ]]; then
echo "Using existing SLOPSMITH_DIR: $SLOPSMITH_DIR"
return 0
fi
# Make the clone re-runnable: a leftover dir from a previous failed
# build would otherwise abort `git clone`. CI runners start fresh so
# this is purely a quality-of-life fix for local re-runs.
if [[ -d "$clone_dir" ]]; then
rm -rf "$clone_dir"
fi
# SLOPSMITH_REF selects the core branch/tag to bundle (set by the
# Build workflow's slopsmith_ref input). Defaults to main so local
# builds and the push/tag CI paths behave exactly as before.
# --branch accepts either a branch or a tag, both shallow-cloneable.
local slopsmith_ref="${SLOPSMITH_REF:-main}"
local _auth=""
[[ -n "${GH_CLONE_TOKEN:-}" ]] && _auth="x-access-token:${GH_CLONE_TOKEN}@"
echo "Cloning Slopsmith repository (ref: ${slopsmith_ref})..."
git clone --depth 1 --branch "$slopsmith_ref" "https://${_auth}github.com/got-feedback/feedback.git" "$clone_dir"
# Remove broken symlinks from plugins dir
find "$clone_dir/plugins" -maxdepth 1 -type l -delete 2>/dev/null || true
# Clone bundled plugins. Format per entry:
# <owner>/<repo>[@<branch>][:<dirname>]
# Dirname defaults to <repo> minus the "feedback-plugin-" prefix
# with hyphens replaced by underscores (slopsmith treats plugin
# directories as Python module names, which can't contain dashes).
# Provide an explicit dirname after a colon for repos that don't
# follow the feedback-plugin-* naming convention. An optional
# @<branch> clones a non-default branch (used to ship in-review
# plugin work in a feature-branch test build).
cd "$clone_dir/plugins"
local plugins=(
# Bundled plugins — all under the got-feedback org after the migration.
got-feedback/feedback-plugin-bongocat
got-feedback/feedback-plugin-drums
got-feedback/feedback-plugin-editor
got-feedback/feedback-plugin-flappy-bend
got-feedback/feedback-plugin-fretboard
got-feedback/feedback-plugin-guitar-theory
got-feedback/feedback-plugin-invert-highway
got-feedback/feedback-plugin-jumpingtab
got-feedback/feedback-plugin-loosefolder:loose_folder
got-feedback/feedback-plugin-lyrics-karaoke
got-feedback/feedback-plugin-metronome
got-feedback/feedback-plugin-midi
got-feedback/feedback-plugin-multiplayer
got-feedback/feedback-plugin-musicxml-import
got-feedback/feedback-plugin-nam-tone
got-feedback/feedback-plugin-notedetect
got-feedback/feedback-plugin-piano
got-feedback/feedback-plugin-practice
got-feedback/feedback-plugin-sectionmap
got-feedback/feedback-plugin-song-preview
got-feedback/feedback-plugin-splitscreen
got-feedback/feedback-plugin-staffview
got-feedback/feedback-plugin-stem-mixer
got-feedback/feedback-plugin-stems
got-feedback/feedback-plugin-stepmode
got-feedback/feedback-plugin-strum-fighter
got-feedback/feedback-plugin-studio
got-feedback/feedback-plugin-tabview
got-feedback/feedback-plugin-themes
got-feedback/feedback-plugin-transpose-chords
got-feedback/feedback-plugin-tutorials
got-feedback/feedback-plugin-virtuoso
# Rig Builder (NAM tone builder) — the repo was renamed
# feedBack-plugin-rig-builder (capital B, so the lowercase prefix
# strip doesn't apply; explicit dirname instead). The old
# `got-feedback/rig_builder` name only worked via a GitHub rename
# redirect, which silently breaks if a new repo ever takes that name.
got-feedback/feedBack-plugin-rig-builder:rig_builder
# Camera Director (3D-highway free camera) — our fork of nimuart's plugin
# (v3 popover launcher + drum/piano support; PR upstream at
# nimuart/cameradirector_feedback#3). Repo isn't under the
# feedback-plugin-* prefix, so give an explicit dirname matching the id.
got-feedback/cameradirector_feedback:camera_director
)
local total=0
local cloned=0
for entry in "${plugins[@]}"; do
total=$((total + 1))
# Split off an optional ":<dirname>" then an optional "@<branch>".
# Git branch names can't contain ':' so the dirname split is safe
# to do first; what's left is "<owner>/<repo>" or "<owner>/<repo>@<branch>".
local spec="$entry" dirname="" branch=""
if [[ "$spec" == *:* ]]; then
dirname="${spec##*:}"
spec="${spec%%:*}"
fi
local owner_repo="$spec"
if [[ "$spec" == *@* ]]; then
branch="${spec##*@}"
owner_repo="${spec%%@*}"
fi
if [[ -z "$dirname" ]]; then
dirname="${owner_repo##*/}"
dirname="${dirname#feedback-plugin-}"
dirname="${dirname//-/_}"
fi
local clone_args=(--depth 1)
[[ -n "$branch" ]] && clone_args+=(--branch "$branch")
if git clone "${clone_args[@]}" "https://${_auth}github.com/${owner_repo}.git" "$dirname" 2>/dev/null; then
cloned=$((cloned + 1))
else
echo " skipped ${owner_repo}${branch:+@$branch}"
fi
done
# Strip dangling symlinks from the bundled tree. Some plugins ship
# build-time symlinks into sources that aren't present at runtime (e.g.
# rig_builder's vst/src/racks/DPF -> DISTRHO framework). A broken symlink
# is harmless on Linux/squashfs, but on macOS it BREAKS codesign ("a
# sealed resource is missing or invalid" -> the app reads as "damaged")
# AND breaks `xattr -dr com.apple.quarantine` (it aborts on the dangling
# link, so the quarantine-removal workaround can't complete). Remove them.
local _dangling
_dangling=$(find "$clone_dir" -type l ! -exec test -e {} \; -print 2>/dev/null | wc -l | tr -d ' ')
if [[ "${_dangling:-0}" != "0" ]]; then
find "$clone_dir" -type l ! -exec test -e {} \; -delete 2>/dev/null || true
echo "Stripped ${_dangling} dangling symlink(s) from the bundled tree (macOS codesign safety)"
fi
export SLOPSMITH_DIR="$clone_dir"
echo "Cloned ${cloned} of ${total} plugins"
cd - >/dev/null
}
step=1
echo_validate_env() {
echo -e "${BLUE}Step $step: Validating environment${NC}"
step=$((step + 1))
}
echo_step() {
echo -e "${BLUE}Step $step: $1${NC}"
step=$((step + 1))
}
echo_summary() {
echo -e "${GREEN}${NC} $1"
}
echo_warning() {
echo -e "${YELLOW}!${NC} $1"
}
echo_error() {
echo -e "${RED}${NC} $1"
}
validate_environment() {
echo_validate_env
NODE_VERSION=$(get_cfg .versions.node)
PYTHON_VERSION=$(get_cfg .versions.python)
echo "Platform: $PLATFORM"
echo "Node: $NODE_VERSION"
echo "Python: $PYTHON_VERSION"
echo ""
# Check Node.js
if command -v node &>/dev/null; then
INSTALLED_NODE=$(node -p "process.version.replace('v', '')")
echo_summary "Found Node.js $INSTALLED_NODE"
else
echo_error "Node.js not found"
exit 1
fi
# Check Python 3
if command -v python3 &>/dev/null; then
INSTALLED_PYTHON=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
echo_summary "Found Python $INSTALLED_PYTHON"
else
echo_error "Python 3 not found"
exit 1
fi
echo ""
}
install_npm_deps() {
echo_step "Installing npm dependencies"
npm install
echo_summary "npm dependencies installed"
echo ""
}
build_native_addons() {
echo_step "Building native addons (audio engine)"
npm run build:native
echo_summary "Native addons built"
echo ""
}
bundle_slopsmith() {
echo_step "Bundling Slopsmith and plugins"
npm run bundle:slopsmith
echo_summary "Slopsmith bundled"
echo ""
}
bundle_python() {
mkdir -p "$PROJECT_DIR/resources"
bundle_python_impl
echo_summary "Python runtime bundled"
echo ""
}
bundle_binaries() {
mkdir -p "$PROJECT_DIR/resources/bin"
bundle_binaries_impl
echo_summary "System binaries bundled"
echo ""
}
verify_bundled_binaries() {
# Smoke test: verify bundled binaries are executable and can run
local bin_dir="$PROJECT_DIR/resources/bin"
local ext=""
if [[ "$PLATFORM" == "windows" ]]; then
ext=".exe"
fi
echo_step "Verifying bundled binaries"
# Verify fluidsynth: supports --version
local fs_path="$bin_dir/fluidsynth${ext}"
if [[ ! -f "$fs_path" ]]; then
echo_error "Missing bundled binary: $fs_path"
exit 1
fi
if ! "$fs_path" --version >/dev/null 2>&1; then
echo_error "Binary fluidsynth failed to execute"
exit 1
fi
echo " ✓ fluidsynth"
# Verify ffmpeg: supports -version
local ff_path="$bin_dir/ffmpeg${ext}"
if [[ ! -f "$ff_path" ]]; then
echo_error "Missing bundled binary: $ff_path"
exit 1
fi
if ! "$ff_path" -version >/dev/null 2>&1; then
echo_error "Binary ffmpeg failed to execute"
exit 1
fi
echo " ✓ ffmpeg"
# Verify ffprobe: demucs spawns it before ffmpeg to read stream
# metadata. Required on every platform because falling through to a
# host-installed ffprobe makes stem splitting work on the build host
# and silently fail on user machines without it.
local ffp_path="$bin_dir/ffprobe${ext}"
if [[ ! -f "$ffp_path" ]]; then
echo_error "Missing bundled binary: $ffp_path"
exit 1
fi
if ! "$ffp_path" -version >/dev/null 2>&1; then
echo_error "Binary ffprobe failed to execute"
exit 1
fi
echo " ✓ ffprobe"
# Verify vgmstream-cli: doesn't have --version, check it produces output with version
local vgm_path="$bin_dir/vgmstream-cli${ext}"
echo " Checking vgmstream-cli at: $vgm_path"
if [[ ! -f "$vgm_path" ]]; then
echo_error "Missing bundled binary: $vgm_path"
exit 1
fi
echo " File exists, checking permissions:"
ls -la "$vgm_path"
# `file` is a diagnostic-only call — keep it best-effort so a minimal
# base image (e.g. the Linux Docker builder, which doesn't ship the
# libmagic-backed `file` binary) doesn't fail the build over a debug
# line. The actual smoke test is the run-and-grep below.
if command -v file >/dev/null 2>&1; then
echo " File type:"
file "$vgm_path"
fi
echo " Attempting to run vgmstream-cli..."
local vgm_output
local vgm_exit_code
# vgmstream-cli with no args prints its version header then exits 1.
# Capture the exit code via the if-branch so `set -e` doesn't trip
# AND vgm_exit_code reflects the binary's real status (not the `|| true`
# short-circuit that the previous form ended up reporting).
if vgm_output=$("$vgm_path" 2>&1); then
vgm_exit_code=0
else
vgm_exit_code=$?
fi
echo " Exit code: $vgm_exit_code"
echo " Raw output:"
echo "$vgm_output" | head -20
echo " Checking if output matches expected pattern..."
if [[ -z "$vgm_output" ]]; then
echo_error "Binary vgmstream-cli produced no output"
exit 1
fi
if [[ ! "$vgm_output" =~ vgmstream.*CLI.*decoder ]]; then
echo_error "Binary vgmstream-cli produced unexpected output"
echo " Expected pattern: vgmstream.*CLI.*decoder"
echo " Actual output (first 500 chars):"
echo "${vgm_output:0:500}"
exit 1
fi
echo " ✓ vgmstream-cli"
# On Linux, audit each bundled ELF binary's NEEDED entries: every
# SONAME must either be in the glibc/loader skip list (delegated to
# the user's libc) or sit next to the binary in resources/bin/.
# The `-version` smoke tests above can't catch this on the build
# host because /usr/lib happens to satisfy the deps — but on a user
# machine with a different ffmpeg ABI the load fails at runtime
# (issue #68 on Fedora 44 / Arch).
if [[ "$PLATFORM" == "linux" ]]; then
if ! command -v readelf >/dev/null 2>&1; then
echo_error "readelf not found (apt: binutils) - required to audit bundled binaries' shared library deps"
exit 1
fi
for bin in fluidsynth ffmpeg ffprobe vgmstream-cli; do
audit_bundled_deps "$bin_dir/$bin" "$bin_dir" || exit 1
done
# Also audit each bundled .so's own NEEDED entries. ldd-on-the-top-
# level-binary usually resolves the full transitive closure, but
# dlopen-resolved deps and interposer libs can slip through that
# traversal. Auditing every bundled .so closes the gap.
for so in "$bin_dir"/*.so*; do
[ -f "$so" ] || continue
audit_bundled_deps "$so" "$bin_dir" || exit 1
done
echo " ✓ shared-library audit"
fi
echo_summary "All bundled binaries verified"
echo ""
}
# Asserts every NEEDED SONAME in $1 is either in the glibc skip list
# or present as a file in $2 (the bundle directory). Returns non-zero
# with a specific error if any SONAME is unsatisfied.
audit_bundled_deps() {
local bin_path="$1"
local bundle_dir="$2"
local missing=()
local soname
while IFS= read -r soname; do
[ -n "$soname" ] || continue
is_skipped_lib "$soname" && continue
[ -f "$bundle_dir/$soname" ] && continue
missing+=("$soname")
done < <(readelf -d "$bin_path" 2>/dev/null | awk -F'[][]' '/\(NEEDED\)/ {print $2}')
if [ ${#missing[@]} -gt 0 ]; then
echo_error "Bundled $(basename "$bin_path") needs shared libs that are not in resources/bin/:"
for soname in "${missing[@]}"; do
echo " - $soname"
done
echo " Fix: extend scripts/bundle-binaries.sh so these SONAMEs are bundled (or add them to the glibc skip list if they MUST come from the host libc)."
return 1
fi
}
bundle_soundfont() {
echo_step "Bundling default soundfont"
bash "$SCRIPT_DIR/bundle-soundfont.sh"
echo_summary "Soundfont bundled"
echo ""
}
build_typescript() {
echo_step "Building TypeScript"
npm run build:ts
echo_summary "TypeScript built"
echo ""
}
package_application() {
echo_step "Packaging application"
# Call electron-builder directly. The package.json `dist:*` scripts
# chain `build:native && bundle && build:ts && electron-builder`,
# but build-common.sh's main() has already run all three of those
# explicitly. Going through `npm run dist:*` would re-run them, which
# on macOS is wasteful: build:native rebuilds the native audio addon
# that build-common.sh already produced.
#
# `--publish never` is required: on a tag push electron-builder
# defaults to auto-publishing to GitHub Releases and then errors out
# without GH_TOKEN. The workflow has a dedicated `release` job that
# publishes via softprops/action-gh-release after artifact upload —
# the build job just needs to produce artifacts, not publish them.
local builder_platform
case "$PLATFORM" in
linux) builder_platform="--linux" ;;
macos) builder_platform="--mac" ;;
windows) builder_platform="--win" ;;
*)
echo_error "Unsupported packaging platform: $PLATFORM"
exit 1
;;
esac
npx electron-builder "$builder_platform" --publish never
echo_summary "Application packaged"
echo ""
}
verify_artifacts() {
echo_step "Verifying artifacts"
ARTIFACTS_FOUND=0
# Read patterns into array (avoid process substitution for CI compatibility)
patterns=()
tempfile=$(mktemp)
get_expected_artifacts > "$tempfile"
cat "$tempfile" >&2
while IFS= read -r line; do
patterns+=("$line")
done < "$tempfile"
rm -f "$tempfile"
for pattern in "${patterns[@]}"; do
shopt -s nullglob
files=($pattern)
shopt -u nullglob
if [ ${#files[@]} -gt 0 ]; then
ARTIFACTS_FOUND=1
break
fi
done
if [[ $ARTIFACTS_FOUND -eq 1 ]]; then
echo_summary "Build successful!"
echo ""
ls -lh "$PROJECT_DIR/release/" 2>/dev/null | grep -v "^total" | awk 'NR > 1' | head -10 || true
else
echo_error "No artifacts found"
if [[ -d "$PROJECT_DIR/release" ]]; then
echo "Contents of release/:"
ls -la "$PROJECT_DIR/release/" 2>&1 || echo "(directory empty)"
else
echo "release/ directory doesn't exist"
fi
exit 1
fi
echo ""
}
# Main entry point - platform scripts call this
main() {
local start_time=$(date +%s)
case "$PLATFORM" in
linux|macos|windows)
;;
*)
echo_error "Unsupported platform: $PLATFORM"
exit 1
;;
esac
# Verify that all required functions are defined by the sourcing platform script
local missing_functions=()
local required_funcs=(
install_system_deps
bundle_python_impl
bundle_binaries_impl
get_expected_artifacts
)
for func in "${required_funcs[@]}"; do
if ! type "$func" &>/dev/null; then
missing_functions+=("$func")
fi
done
if [[ ${#missing_functions[@]} -gt 0 ]]; then
echo_error "Required functions not defined by platform script:"
for func in "${missing_functions[@]}"; do
echo " - $func"
done
exit 1
fi
validate_environment
install_system_deps
install_npm_deps
# clone_slopsmith provides the slopsmith core + plugins that
# bundle_slopsmith packages into the app; run it before the build steps
# that consume $SLOPSMITH_DIR.
clone_slopsmith
build_native_addons
bundle_slopsmith
bundle_python
bundle_binaries
verify_bundled_binaries
bundle_soundfont
build_typescript
package_application
verify_artifacts
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo -e "${GREEN}${NC} Build complete for $PLATFORM in ${duration}s"
echo "Output: $PROJECT_DIR/release/"
}