#!/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: # /[@][:] # Dirname defaults to 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 # @ 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-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 ":" then an optional "@". # Git branch names can't contain ':' so the dirname split is safe # to do first; what's left is "/" or "/@". 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/" }