Files
jasp-desktop-bin/build/build-jasp.sh
T
agw e009dc82d3 Retry the module build, count the bundles, and stop bundling the loader
The later bump worked - the build reached staging. Three new problems:

- renv restores concurrently and not always in dependency order: forecast
  was lazy-loaded while vctrs was still compiling ('there is no package
  called vctrs', six lines before 'vctrs 0.7.3 built from source in 52s').
  That aborts the whole sapply() loop, so one race cost 20 modules. Retry
  the pass up to three times; renv skips what is installed, so each pass
  converges.

- The guard only required a non-empty Modules/local, so a build with 2 of
  22 modules passed it and went on to stage and package. It now requires
  the full expected count and lists what it did get.

- bundle-libs.sh crashed on the dynamic loader: ldd prints it as
  '/lib64/ld-linux-x86-64.so.2 => ...', so the first column is a path and
  the ld-linux* denylist entry never matched. Basename the soname before
  the check.
2026-09-02 20:45:00 +02:00

395 lines
16 KiB
Bash
Executable File

#!/bin/bash
# Build JASP Desktop on Arch Linux and emit a relocatable binary tarball.
#
# Runs as root inside an `archlinux:base-devel` container. The resulting tarball
# is what the AUR `jasp-desktop-bin` package downloads and unpacks.
#
# JASP_TAG upstream tag to build, e.g. v0.98.1 (required)
# OUT_DIR where the tarball + metadata land (default /work/out)
# JOBS compile parallelism (default nproc)
# R_VERSION R to build and bundle (default 4.5.3)
# GITHUB_PAT token for R package downloads from GitHub (optional but
# strongly recommended — several JASP modules are installed from
# GitHub and anonymous API rate limits will fail the build)
#
# Why we build our own R instead of using Arch's:
#
# JASP pins its R package set with renv lockfiles against a frozen CRAN snapshot
# (Modules/Rcpp_RInside.lock points at the 2026-03-31 Posit snapshot for 0.98.1).
# That set targets R 4.5. Building against Arch's R 4.6 fails immediately —
# Rcpp 1.1.1 does not compile there (R_NamespaceRegistry is gone in R 4.6), and
# the pinned snapshot predates the Rcpp release that fixes it. Upstream's own CI
# installs a specific R and points R_HOME at it, which is what we do here.
#
# Bundling R also means the package does not depend on Arch's `r` at all, so an
# Arch R bump can no longer break an installed JASP.
set -euo pipefail
JASP_TAG="${JASP_TAG:?JASP_TAG is required, e.g. v0.98.1}"
OUT_DIR="${OUT_DIR:-/work/out}"
JOBS="${JOBS:-$(nproc)}"
R_VERSION="${R_VERSION:-4.5.3}"
SRC_DIR=/build/jasp-desktop
BUILD_DIR=/build/build
STAGE_DIR=/build/stage
PREFIX=/opt/jasp-desktop
R_HOME_DIR="${PREFIX}/R"
mkdir -p "$OUT_DIR" /build
log() { echo ":: $* "; }
# ---------------------------------------------------------------- system deps
log "installing Arch dependencies"
pacman -Syu --noconfirm --needed \
base-devel git cmake ninja gcc gcc-fortran make pkgconf patchelf \
autoconf automake libtool bison flex gettext blas lapack ccache \
qt6-base qt6-declarative qt6-svg qt6-positioning qt6-webchannel \
qt6-webengine qt6-5compat qt6-shadertools qt6-websockets qt6-tools \
qt6-httpserver vulkan-headers \
boost boost-libs glpk jsoncpp libarchive openssl curl sqlite zlib \
readstat netcdf mpfr cairo pango fontconfig harfbuzz fribidi icu pcre2 \
readline bzip2 xz libpng libtiff libjpeg-turbo minizip \
libxkbcommon libxkbcommon-x11 libxt wget unzip python
# Stock makepkg.conf leaves MAKEFLAGS at -j1, which makes the AUR dependencies
# (JAGS especially) crawl.
sed -i "s|^#\?MAKEFLAGS=.*|MAKEFLAGS=\"-j${JOBS}\"|" /etc/makepkg.conf
export MAKEFLAGS="-j${JOBS}"
# makepkg refuses to run as root.
if ! id builder &>/dev/null; then
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/builder
fi
# jags and librdata are AUR-only; build them the same way a user would.
build_aur() {
local pkg="$1"
log "building AUR package $pkg"
sudo -u builder bash -euc "
cd /home/builder
rm -rf '$pkg'
git clone --depth 1 'https://aur.archlinux.org/$pkg.git'
cd '$pkg'
makepkg -si --noconfirm --needed
"
}
build_aur jags
build_aur librdata-git
# JASP requires freexl >= 2.0.99; Arch ships 2.0.0, so use upstream's own fork
# exactly as the JASP CI does.
log "building jasp-stats/freexl"
rm -rf /build/freexl
git clone --depth 1 https://github.com/jasp-stats/freexl.git /build/freexl
cd /build/freexl
./configure --prefix=/usr
make -j"$JOBS"
make install
# ------------------------------------------------------- R that we ship along
log "building R ${R_VERSION} into ${R_HOME_DIR}"
rm -rf "/build/R-${R_VERSION}"
cd /build
wget -q "https://cran.r-project.org/src/base/R-4/R-${R_VERSION}.tar.gz"
tar -xzf "R-${R_VERSION}.tar.gz"
cd "R-${R_VERSION}"
# --enable-R-shlib is required: JASP embeds R through RInside/libR.
# --libdir is pinned so R lands in lib/R rather than the lib64/R that Arch's
# config.site would otherwise pick, which keeps the rpath below predictable.
# The install prefix equals the final install path, so the rpath stays valid
# once the tarball is unpacked on the user's machine.
#
# --without-blas/--without-lapack is deliberate and load-bearing. With
# --with-blas, R links Arch's external BLAS and does NOT build libRblas.so or
# libRlapack.so. The JASP module bundles expect those files to exist inside
# R_HOME (that is how a conventionally built R, and the Flatpak, ship), so
# linking externally leaves the modules unable to resolve them. Reference BLAS
# is slower than OpenBLAS, but a working install beats a fast broken one.
./configure \
--prefix="${R_HOME_DIR}" \
--libdir="${R_HOME_DIR}/lib" \
--enable-R-shlib \
--enable-memory-profiling \
--without-blas \
--without-lapack \
--with-x=no \
--with-tcltk=no \
LDFLAGS="-Wl,-rpath,${R_HOME_DIR}/lib/R/lib"
make -j"$JOBS"
make install
# Guard the above: a silently external-BLAS build produces a JASP whose modules
# fail at runtime, which is exactly the class of failure this package exists to
# avoid. Better to stop here than ship it.
for lib in libR.so libRblas.so libRlapack.so; do
if [[ ! -f "${R_HOME_DIR}/lib/R/lib/${lib}" ]]; then
echo "error: ${lib} missing from the bundled R — R linked an external BLAS?" >&2
ls -la "${R_HOME_DIR}/lib/R/lib/" >&2
exit 1
fi
done
log "bundled R provides its own libR/libRblas/libRlapack"
export PATH="${R_HOME_DIR}/bin:${PATH}"
# Ask R where it actually put itself rather than assuming lib vs lib64.
# Deliberately NOT exported: R.cmake notes that a set R_HOME makes R print
# "WARNING: ignoring environment value of R_HOME", which corrupts the output it
# parses. CUSTOM_R_PATH is the supported way to point JASP at a specific R.
JASP_R_HOME="$("${R_HOME_DIR}/bin/R" RHOME)"
log "bundled R_HOME is ${JASP_R_HOME}"
"${R_HOME_DIR}/bin/R" --version | sed -n 1p || true
# ------------------------------------------------------------------- checkout
log "cloning jasp-desktop $JASP_TAG"
rm -rf "$SRC_DIR"
git clone --depth 1 --branch "$JASP_TAG" --recurse-submodules --shallow-submodules \
https://github.com/jasp-stats/jasp-desktop.git "$SRC_DIR"
cd "$SRC_DIR"
JASP_COMMIT="$(git rev-parse HEAD)"
# ---------------------------------------------------------------------- build
log "configuring"
export CMAKE_C_COMPILER_LAUNCHER=ccache
export CMAKE_CXX_COMPILER_LAUNCHER=ccache
export CMAKE_Fortran_COMPILER_LAUNCHER=ccache
export CFLAGS="${CFLAGS:-} -fPIC"
export CXXFLAGS="${CXXFLAGS:-} -fPIC"
export FCFLAGS="${FCFLAGS:-} -fPIC"
export FFLAGS="${FFLAGS:-} -fPIC"
rm -rf "$BUILD_DIR"
cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DLINUX_LOCAL_BUILD=OFF \
-DCUSTOM_R_PATH="${JASP_R_HOME}" \
-Wno-dev
log "compiling JASP (-j$JOBS)"
ninja -C "$BUILD_DIR" -j"$JOBS"
# The R modules are the long pole: several hundred R packages compiled from
# source. buildAllDefaultJaspModules.R shells out to buildModuleBundlesLocally.R
# via system2(), which does NOT check exit status — so every failure below is
# silent and the script still exits 0. The guards after it are what actually
# catch a broken module build.
#
# GITHUB_PAT is required, not optional: buildModuleBundlesLocally.R does
# install_github('jasp-stats/jaspModuleTools'), and anonymous GitHub API rate
# limits make that fail. When it fails, no bundles are produced at all and the
# build otherwise looks successful.
if [[ -z "${GITHUB_PAT:-}" ]]; then
echo "error: GITHUB_PAT is required — jaspModuleTools is installed from GitHub" >&2
echo " without it the module build silently produces nothing" >&2
exit 1
fi
export GITHUB_PAT
export GITHUB_TOKEN="${GITHUB_PAT}"
log "building R modules — this is the slow part"
mkdir -p "$BUILD_DIR"/Modules/{binary_pkgs,manifests,module_libs,Tools}
cd "$SRC_DIR/Tools"
# buildModuleBundlesLocally.R calls devtools::install_github(), which since
# devtools 2.5.0 is deprecated and delegates to the `remotes` package — but
# remotes is no longer a hard dependency of devtools, so the call dies with
# "The package \"remotes\" is required." Pre-seed it into the same library the
# script uses, since that script does .libPaths(new=userLibrary) and would not
# see remotes anywhere else.
MODULE_LIBRARY="$SRC_DIR/Tools/ModuleBundleBuildDir/LibraryR"
MODULE_BUNDLES="$SRC_DIR/Modules/local"
log "pre-installing remotes into the module build library"
mkdir -p "$MODULE_LIBRARY"
Rscript -e "
lib <- '${MODULE_LIBRARY}'
.libPaths(new = lib)
install.packages('remotes', lib = lib, repos = 'https://cran.r-project.org')
if (!requireNamespace('remotes', lib.loc = lib, quietly = TRUE))
stop('failed to install remotes')
"
# Some of the versions JASP pins in its module renv.lock files predate the
# toolchain on a current Arch and cannot compile. Bump only those, by name, with
# the reason recorded — never a blanket "use latest", which would silently
# change the analysis code users get.
#
# later 1.4.6 bundles a tinycthread whose `#define once_flag pthread_once_t`
# collides with the C11 once_flag that glibc >= 2.43 now declares under
# C23, so it fails with "conflicting types for pthread_once_t".
# Fixed upstream in later 1.4.7 (r-lib/later#257). The 2026-04-22 RSPM
# snapshot the lockfiles already reference carries 1.4.8.
log "patching module lockfile pins that cannot build on this toolchain"
python3 - <<'PY'
import json, pathlib
OVERRIDES = {"later": "1.4.8"}
root = pathlib.Path("ModuleBundleBuildDir/Modules")
patched = 0
for lock in root.glob("*/renv.lock"):
data = json.loads(lock.read_text())
packages = data.get("Packages", {})
changed = False
for name, want in OVERRIDES.items():
pkg = packages.get(name)
if pkg and pkg.get("Version") != want:
print(f" {lock.parent.name}: {name} {pkg['Version']} -> {want}")
pkg["Version"] = want
# The recorded hash no longer matches the bumped version.
pkg.pop("Hash", None)
changed = True
if changed:
lock.write_text(json.dumps(data, indent=2) + "\n")
patched += 1
print(f"patched {patched} lockfiles")
PY
# renv restores packages concurrently and its ordering is not always
# dependency-correct: a module can be lazy-loaded while a package it needs is
# still being built, e.g. "forecast" failing with "there is no package called
# 'vctrs'" six log lines before vctrs finishes compiling. That aborts the whole
# sapply() loop, so a single race costs every remaining module.
#
# Retrying is the cheap fix: renv skips what is already installed, so each pass
# starts from more complete state and converges. Passes are capped so a genuine
# failure cannot loop forever.
EXPECTED_MODULES=22
for attempt in 1 2 3; do
log "module build pass ${attempt}/3"
Rscript buildAllDefaultJaspModules.R || true
built="$(find "$MODULE_BUNDLES" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)"
log "pass ${attempt} finished: ${built}/${EXPECTED_MODULES} bundles"
[[ "$built" -ge "$EXPECTED_MODULES" ]] && break
done
if [[ ! -d "$MODULE_LIBRARY/jaspModuleTools" ]]; then
echo "error: jaspModuleTools was not installed — install_github failed" >&2
echo " check the install_github output above: a missing 'remotes'," >&2
echo " a GitHub rate limit, or a PAT without read access all do this" >&2
exit 1
fi
# Guard on the expected count, not merely "non-empty". An earlier run shipped a
# package containing 2 of 22 modules because a non-empty check passed.
bundle_count="$(find "$MODULE_BUNDLES" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)"
if [[ "$bundle_count" -lt "$EXPECTED_MODULES" ]]; then
echo "error: only ${bundle_count}/${EXPECTED_MODULES} module bundles were built" >&2
echo " system2() in buildAllDefaultJaspModules.R hides the real error;" >&2
echo " search the log above for 'failed to install'" >&2
find "$MODULE_BUNDLES" -mindepth 1 -maxdepth 1 -printf ' have: %f\n' 2>/dev/null >&2
exit 1
fi
log "module build produced ${bundle_count} bundles"
# -------------------------------------------------------------------- staging
log "staging install tree"
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
DESTDIR="$STAGE_DIR" ninja -C "$BUILD_DIR" install
# The bundled R was installed straight to $PREFIX/R on the build host; copy it
# into the staged tree so it ends up in the tarball.
install -d "$STAGE_DIR$PREFIX"
cp -a "$R_HOME_DIR" "$STAGE_DIR$PREFIX/"
# Everything the module build produced has to travel with the tarball, or JASP
# falls back to downloading bundles at runtime — which is the failure mode this
# package is meant to remove.
#
# Modules/local the compiled module bundles themselves
# LibraryR the R library they were built with (jaspModuleTools, devtools
# and ~95 dependencies), compiled against the bundled R 4.5.3
#
# ModuleBundleBuildDir/Modules is deliberately NOT copied: it is the cloned
# module *sources* (no compiled objects at all), several hundred MB of git
# checkouts that would bloat the tarball without being usable at runtime.
install -d "$STAGE_DIR$PREFIX/Modules"
log "staging module bundles from ${MODULE_BUNDLES}"
cp -a "$MODULE_BUNDLES/." "$STAGE_DIR$PREFIX/Modules/"
log "staging module R library from ${MODULE_LIBRARY}"
install -d "$STAGE_DIR$PREFIX/ModuleLibrary"
cp -a "$MODULE_LIBRARY/." "$STAGE_DIR$PREFIX/ModuleLibrary/"
install -Dm644 /work/packaging/org.jaspstats.JASP.desktop \
"$STAGE_DIR/usr/share/applications/org.jaspstats.JASP.desktop"
if [[ -d "$STAGE_DIR$PREFIX/share/icons/hicolor" ]]; then
install -d "$STAGE_DIR/usr/share/icons/hicolor"
cp -a "$STAGE_DIR$PREFIX/share/icons/hicolor/." "$STAGE_DIR/usr/share/icons/hicolor/"
fi
install -d "$STAGE_DIR/usr/share/licenses/jasp-desktop-bin"
cd "$SRC_DIR"
find . -maxdepth 2 \( -name 'COPYING*' -o -name 'LICENSE*' \) -type f \
-exec install -m644 {} "$STAGE_DIR/usr/share/licenses/jasp-desktop-bin/" \;
find "$STAGE_DIR" -name '*.o' -delete 2>/dev/null || true
# Make the tree independent of Arch's rolling libraries (ICU, jsoncpp, glpk,
# JAGS, readstat, librdata ...). Qt6 stays a system dependency; BLAS/LAPACK now
# come from the bundled R itself.
log "bundling non-system shared libraries"
bash /work/build/bundle-libs.sh "$STAGE_DIR" "$PREFIX"
# Final sanity check on what is about to be shipped. Each of these has already
# been shipped missing once.
log "verifying the staged tree"
staged_r="$STAGE_DIR$PREFIX/R/lib/R/lib"
for lib in libR.so libRblas.so libRlapack.so; do
[[ -f "$staged_r/$lib" ]] || { echo "error: staged tree lacks $lib" >&2; exit 1; }
done
staged_bundles="$(find "$STAGE_DIR$PREFIX/Modules" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)"
[[ "$staged_bundles" -gt 0 ]] || { echo "error: staged tree has no module bundles" >&2; exit 1; }
staged_so="$(find "$STAGE_DIR$PREFIX" -name '*.so' 2>/dev/null | wc -l)"
log "staged: ${staged_bundles} module bundles, ${staged_so} shared objects"
du -sh "$STAGE_DIR$PREFIX"/* 2>/dev/null | sort -rh | head -8
# ------------------------------------------------------------------- metadata
pkgver_of() { pacman -Q "$1" 2>/dev/null | awk '{print $2}'; }
QT_FULL="$(pkgver_of qt6-base)"
QT_MINOR="$(echo "$QT_FULL" | grep -oE '^[0-9]+\.[0-9]+')"
QT_MAJOR="$(echo "$QT_FULL" | grep -oE '^[0-9]+')"
ICU_FULL="$(pkgver_of icu)"
ICU_MAJOR="$(echo "$ICU_FULL" | grep -oE '^[0-9]+')"
BOOST_FULL="$(pkgver_of boost-libs)"
JASP_VER="${JASP_TAG#v}"
TARBALL="jasp-desktop-${JASP_VER}-x86_64.tar.zst"
log "creating $TARBALL"
cd "$STAGE_DIR"
tar --zstd -cf "$OUT_DIR/$TARBALL" .
SHA256="$(sha256sum "$OUT_DIR/$TARBALL" | awk '{print $1}')"
SIZE="$(stat -c%s "$OUT_DIR/$TARBALL")"
cat >"$OUT_DIR/build-info.json" <<EOF
{
"jasp_version": "${JASP_VER}",
"jasp_tag": "${JASP_TAG}",
"jasp_commit": "${JASP_COMMIT}",
"tarball": "${TARBALL}",
"sha256": "${SHA256}",
"size_bytes": ${SIZE},
"bundled_r_version": "${R_VERSION}",
"qt6_version": "${QT_FULL}",
"qt6_minor": "${QT_MINOR}",
"qt6_major": "${QT_MAJOR}",
"icu_version": "${ICU_FULL}",
"icu_major": "${ICU_MAJOR}",
"boost_version": "${BOOST_FULL}"
}
EOF
log "done"
cat "$OUT_DIR/build-info.json"