Bundle our own R 4.5.3 instead of building against Arch's R

Arch ships R 4.6.1, but JASP pins its R packages against a frozen CRAN
snapshot (2026-03-31 for 0.98.1) that targets R 4.5. Configuring against
Arch's R fails at Rcpp 1.1.1, which cannot compile on R 4.6 since
R_NamespaceRegistry was removed; the Rcpp release that fixes it is newer
than the pinned snapshot, so the lockfile cannot reach it either.

Build R 4.5.3 into /opt/jasp-desktop/R and point R_HOME at it, matching
what upstream CI does. This drops 'r' from depends entirely, so an Arch R
upgrade can no longer break an installed JASP, and the rebuild triggers
become Qt6 and ICU - the libraries actually still linked against.
This commit is contained in:
agw
2026-09-01 21:40:00 +02:00
parent 3ad88f9b0d
commit d880e6748c
7 changed files with 130 additions and 64 deletions
+3
View File
@@ -70,6 +70,8 @@ jobs:
df -h /
- name: Build JASP in an Arch container
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
docker rm -f jasp-build 2>/dev/null || true
docker run --rm --name jasp-build \
@@ -77,6 +79,7 @@ jobs:
-e JASP_TAG="${{ needs.check.outputs.jasp_tag }}" \
-e OUT_DIR=/work/out \
-e JOBS=5 \
-e GITHUB_PAT="${GH_TOKEN}" \
archlinux:base-devel \
bash /work/build/build-jasp.sh
+27 -14
View File
@@ -14,19 +14,30 @@ users download the result.
## The constraint that shapes everything
JASP does **not** bundle R or Qt6 on Linux. `Tools/CMake/R.cmake` only fetches
`R.framework` on macOS; on Linux JASP links against the distribution's R and Qt6,
and the R module library it ships is compiled against one specific R minor version.
JASP pins its R package set with renv lockfiles against a **frozen CRAN snapshot**
— for 0.98.1, `Modules/Rcpp_RInside.lock` points at the Posit snapshot of
2026-03-31. That set targets R 4.5.
A binary is therefore only valid for the R minor and Qt6 minor it was built
against. Two consequences:
Building against Arch's current R (4.6.1) fails immediately: Rcpp 1.1.1 does not
compile there, because `R_NamespaceRegistry` is gone in R 4.6. The Rcpp release
that fixes this (1.1.2, 2026-07-05) is *newer than the pinned snapshot*, so the
lockfile cannot resolve it either. This is precisely the "old libraries with
issues" problem that makes the source AUR package painful.
1. A rebuild is triggered by a new JASP release **or** by Arch bumping `r` or
`qt6-base`. `scripts/check-upstream.py` checks all three.
2. The generated PKGBUILD pins `depends` to those versions
(`r>=4.6 r<4.7`, `qt6-base>=6.11 qt6-base<6.12`). If Arch moves ahead before a
rebuild lands, pacman refuses the combination rather than leaving users with a
JASP that fails to start.
So this build **compiles and bundles its own R 4.5.3** into
`/opt/jasp-desktop/R` and points `R_HOME` at it — which is what upstream's own CI
does (`.github/workflows/ubuntu.yml` sets `JASP_R_VERSION: 4.5.3` and an explicit
`R_HOME`). `Tools/CMake/R.cmake` reads `R_HOME` from the environment on Linux, so
this is a supported configuration rather than a hack.
Consequences:
1. `r` is **not** a dependency of the package. An Arch R upgrade cannot break an
installed JASP.
2. What the binary is still linked against is Qt6 and ICU, both of which break ABI
across releases. The generated PKGBUILD pins those
(`qt6-base>=6.11 <6.12`, `icu>=78 <79`), and `scripts/check-upstream.py`
triggers a rebuild when Arch moves either — or when JASP releases.
`jags` and `librdata-git` are AUR-only, and JASP needs `freexl >= 2.0.99` while Arch
ships 2.0.0 — so the build compiles those itself, matching what upstream CI does.
@@ -36,7 +47,7 @@ ships 2.0.0 — so the build compiles those itself, matching what upstream CI do
| Path | Purpose |
| --- | --- |
| `build/build-jasp.sh` | The actual build. Runs in an `archlinux:base-devel` container, emits the tarball plus `build-info.json`. |
| `scripts/check-upstream.py` | Compares upstream JASP / Arch R / Arch Qt6 against `state.json`; decides whether to build. |
| `scripts/check-upstream.py` | Compares upstream JASP / Arch Qt6 / Arch ICU against `state.json`; decides whether to build. |
| `scripts/gen-pkgbuild.py` | Fills `packaging/PKGBUILD.in` from `build-info.json`; derives `pkgrel` from what is on the AUR. |
| `scripts/publish-github-release.sh` | Creates the GitHub release and uploads the tarball. |
| `scripts/push-aur.sh` | Generates `.SRCINFO` and pushes to `aur.archlinux.org`. |
@@ -48,9 +59,10 @@ ships 2.0.0 — so the build compiles those itself, matching what upstream CI do
```
nightly (03:00) ─► check-upstream.py
│ new JASP tag, or Arch bumped r / qt6-base?
│ new JASP tag, or Arch bumped qt6-base / icu?
▼
build-jasp.sh (Arch container, hours)
│ builds R 4.5.3, JASP, then all R modules
│ jasp-desktop-<ver>-x86_64.tar.zst + build-info.json
▼
GitHub release ──► gen-pkgbuild.py ──► push to AUR
@@ -90,7 +102,8 @@ The build runs for hours and needs disk. On the Gitea runner host:
time (set to `16h`).
- Gitea's own job timeout (`[actions] ENDLESS_TASK_TIMEOUT` in `app.ini`, default
3h) must match, otherwise Gitea kills the job at 3h regardless of the runner.
- Roughly 40–60 GB of free disk for the build tree and staged install.
- Roughly 40–60 GB of free disk for the build tree, the bundled R and the
staged install.
## Running it by hand
+69 -28
View File
@@ -2,28 +2,40 @@
# 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, so this
# script also records the exact versions of the Arch libraries JASP ends up
# linked against — the PKGBUILD pins `depends` to them.
# 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)
#
# JASP links against the distro's R and Qt6 rather than bundling them
# (Tools/CMake/R.cmake only fetches R.framework on macOS), so a build is only
# valid for the R minor and Qt6 minor present at build time.
# 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
@@ -37,13 +49,13 @@ pacman -Syu --noconfirm --needed \
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 r glpk jsoncpp libarchive openssl curl sqlite zlib \
readstat netcdf mpfr cairo fontconfig harfbuzz fribidi \
libpng libtiff libjpeg-turbo minizip libxkbcommon libxkbcommon-x11 \
wget unzip
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
# Stock makepkg.conf leaves MAKEFLAGS at -j1, which makes the AUR dependencies
# (JAGS especially) crawl. R's Makeconf picks this up for module builds too.
# (JAGS especially) crawl.
sed -i "s|^#\?MAKEFLAGS=.*|MAKEFLAGS=\"-j${JOBS}\"|" /etc/makepkg.conf
export MAKEFLAGS="-j${JOBS}"
@@ -78,6 +90,32 @@ cd /build/freexl
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.
# rpath is set to $ORIGIN-relative so the tree stays relocatable after install.
./configure \
--prefix="${R_HOME_DIR}" \
--enable-R-shlib \
--enable-memory-profiling \
--with-blas \
--with-lapack \
--with-x=no \
--with-tcltk=no \
LDFLAGS="-Wl,-rpath,${R_HOME_DIR}/lib/R/lib"
make -j"$JOBS"
make install
export R_HOME="${R_HOME_DIR}/lib/R"
export PATH="${R_HOME_DIR}/bin:${PATH}"
R --version | head -1
# ------------------------------------------------------------------- checkout
log "cloning jasp-desktop $JASP_TAG"
rm -rf "$SRC_DIR"
@@ -101,17 +139,21 @@ rm -rf "$BUILD_DIR"
cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DLINUX_LOCAL_BUILD=ON \
-DLINUX_LOCAL_BUILD=OFF \
-DCUSTOM_R_PATH="${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 expects these directories to exist.
# source, installed into the bundled R's library.
log "building R modules — this is the slow part"
mkdir -p "$BUILD_DIR"/Modules/{binary_pkgs,manifests,module_libs,Tools}
cd "$SRC_DIR/Tools"
if [[ -z "${GITHUB_PAT:-}" ]]; then
echo "warning: GITHUB_PAT is unset — R module downloads from GitHub may hit anonymous rate limits" >&2
fi
Rscript buildAllDefaultJaspModules.R
# -------------------------------------------------------------------- staging
@@ -120,15 +162,16 @@ rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
DESTDIR="$STAGE_DIR" ninja -C "$BUILD_DIR" install
install -d "$STAGE_DIR$PREFIX/Modules"
cp -a "$SRC_DIR/Tools/ModuleBundleBuildDir/Modules/." "$STAGE_DIR$PREFIX/Modules/"
# 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/"
if [[ -d "$BUILD_DIR/R/library" ]]; then
install -d "$STAGE_DIR$PREFIX/R/library"
cp -a "$BUILD_DIR/R/library/." "$STAGE_DIR$PREFIX/R/library/"
install -d "$STAGE_DIR$PREFIX/Modules"
if [[ -d "$SRC_DIR/Tools/ModuleBundleBuildDir/Modules" ]]; then
cp -a "$SRC_DIR/Tools/ModuleBundleBuildDir/Modules/." "$STAGE_DIR$PREFIX/Modules/"
fi
# Desktop entry + icons, so the tarball is a complete install tree.
install -Dm644 /work/packaging/org.jaspstats.JASP.desktop \
"$STAGE_DIR/usr/share/applications/org.jaspstats.JASP.desktop"
@@ -139,21 +182,18 @@ fi
install -d "$STAGE_DIR/usr/share/licenses/jasp-desktop-bin"
cd "$SRC_DIR"
find . -maxdepth 2 -name 'COPYING*' -o -maxdepth 2 -name 'LICENSE*' -type f \
| while read -r f; do install -m644 "$f" "$STAGE_DIR/usr/share/licenses/jasp-desktop-bin/"; done
find . -maxdepth 2 \( -name 'COPYING*' -o -name 'LICENSE*' \) -type f \
-exec install -m644 {} "$STAGE_DIR/usr/share/licenses/jasp-desktop-bin/" \;
# Strip build-host paths out of the staged tree so the tarball is relocatable.
find "$STAGE_DIR" -name '*.o' -delete 2>/dev/null || true
# ------------------------------------------------------------------- metadata
# Pin data: the PKGBUILD turns these into versioned `depends` entries, so an
# Arch R or Qt6 bump makes the package rebuild rather than silently break.
pkgver_of() { pacman -Q "$1" 2>/dev/null | awk '{print $2}'; }
R_FULL="$(pkgver_of r)"
R_MINOR="$(R --version | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1)"
QT_FULL="$(pkgver_of qt6-base)"
QT_MINOR="$(echo "$QT_FULL" | grep -oE '^[0-9]+\.[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}"
@@ -173,10 +213,11 @@ cat >"$OUT_DIR/build-info.json" <<EOF
"tarball": "${TARBALL}",
"sha256": "${SHA256}",
"size_bytes": ${SIZE},
"r_version": "${R_FULL}",
"r_minor": "${R_MINOR}",
"bundled_r_version": "${R_VERSION}",
"qt6_version": "${QT_FULL}",
"qt6_minor": "${QT_MINOR}",
"icu_version": "${ICU_FULL}",
"icu_major": "${ICU_MAJOR}",
"boost_version": "${BOOST_FULL}"
}
EOF
+13 -8
View File
@@ -16,22 +16,27 @@ license=('AGPL-3.0-or-later')
provides=("jasp-desktop=${pkgver}")
conflicts=('jasp-desktop')
# JASP links against the system R and Qt6 rather than bundling them, and the
# bundled R module library is compiled against one specific R minor. These are
# pinned to what this binary was actually built against — an Arch R or Qt6 bump
# makes pacman refuse the combination instead of leaving JASP silently broken.
# JASP's R packages are pinned by upstream against a frozen CRAN snapshot that
# targets R @BUNDLED_R@, so this package ships its own R at
# /opt/jasp-desktop/R rather than using Arch's. That deliberately keeps `r` out
# of depends: an Arch R upgrade cannot break an installed JASP.
#
# Qt6 and ICU are still system libraries, and both break ABI across releases, so
# they are pinned to the versions this binary was linked against.
depends=(
"r>=@R_MINOR@" "r<@R_NEXT_MINOR@"
"qt6-base>=@QT_MINOR@" "qt6-base<@QT_NEXT_MINOR@"
'qt6-declarative' 'qt6-svg' 'qt6-positioning' 'qt6-webchannel'
'qt6-webengine' 'qt6-5compat' 'qt6-httpserver'
'boost-libs' 'glpk' 'jsoncpp' 'libarchive' 'openssl' 'curl' 'sqlite'
'zlib' 'readstat' 'blas' 'lapack'
"icu>=@ICU_MAJOR@" "icu<@ICU_NEXT_MAJOR@"
'boost-libs' 'glpk' 'jsoncpp' 'libarchive' 'openssl' 'curl' 'sqlite' 'zlib'
'readstat' 'blas' 'lapack' 'pcre2' 'readline' 'bzip2' 'xz'
'libpng' 'libtiff' 'libjpeg-turbo' 'cairo' 'pango' 'fontconfig'
'jags' 'librdata-git'
)
# The tarball is a complete /opt + /usr install tree; there is nothing to
# compile or strip here.
# compile, and stripping a bundled R plus several hundred R modules is both slow
# and a good way to break them.
options=('!strip' '!debug')
source=("jasp-desktop-${pkgver}-${pkgrel}-x86_64.tar.zst::@DOWNLOAD_URL@")
+9 -8
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Decide whether a rebuild is needed.
A JASP binary is only valid for the R minor and Qt6 minor it was linked against,
so a rebuild is triggered by an upstream JASP release *or* by Arch moving R or
Qt6 — not just the former.
The build bundles its own R, so Arch's `r` is irrelevant here. What the binary
*is* still linked against is Qt6 and ICU, both of which break ABI across
releases — so a rebuild is triggered by an upstream JASP release or by Arch
moving either of those.
Writes GITHUB_OUTPUT-style key=value pairs to stdout (and to $GITHUB_OUTPUT).
"""
@@ -40,8 +41,8 @@ def main() -> int:
pinned_tag = os.environ.get("JASP_TAG", "").strip()
jasp_tag = pinned_tag or fetch_json(GH_LATEST)["tag_name"]
r_minor = minor(arch_version("r"))
qt_minor = minor(arch_version("qt6-base"))
icu_major = arch_version("icu").split(".")[0]
try:
state = json.loads(open(STATE_FILE).read())
@@ -53,16 +54,16 @@ def main() -> int:
reasons.append("manually forced")
if state.get("jasp_tag") != jasp_tag:
reasons.append(f"JASP {state.get('jasp_tag', 'none')} -> {jasp_tag}")
if state.get("r_minor") != r_minor:
reasons.append(f"R {state.get('r_minor', 'none')} -> {r_minor}")
if state.get("qt6_minor") != qt_minor:
reasons.append(f"Qt6 {state.get('qt6_minor', 'none')} -> {qt_minor}")
if state.get("icu_major") != icu_major:
reasons.append(f"ICU {state.get('icu_major', 'none')} -> {icu_major}")
needs_build = bool(reasons)
print(f"jasp_tag = {jasp_tag}")
print(f"r_minor = {r_minor}")
print(f"qt6_minor = {qt_minor}")
print(f"icu_major = {icu_major}")
print(f"decision = {'BUILD' if needs_build else 'up to date'}")
for reason in reasons:
print(f" reason: {reason}")
@@ -72,8 +73,8 @@ def main() -> int:
with open(out, "a") as fh:
fh.write(f"needs_build={'true' if needs_build else 'false'}\n")
fh.write(f"jasp_tag={jasp_tag}\n")
fh.write(f"r_minor={r_minor}\n")
fh.write(f"qt6_minor={qt_minor}\n")
fh.write(f"icu_major={icu_major}\n")
fh.write(f"reasons={'; '.join(reasons)}\n")
return 0
+7 -5
View File
@@ -63,17 +63,18 @@ def main() -> int:
f"/releases/download/{tag}/{info['tarball']}"
)
r_minor = info["r_minor"]
qt_minor = info["qt6_minor"]
icu_major = info["icu_major"]
substitutions = {
"@MAINTAINER@": args.maintainer,
"@JASP_VERSION@": jasp_version,
"@PKGREL@": str(pkgrel),
"@R_MINOR@": r_minor,
"@R_NEXT_MINOR@": next_minor(r_minor),
"@BUNDLED_R@": info["bundled_r_version"],
"@QT_MINOR@": qt_minor,
"@QT_NEXT_MINOR@": next_minor(qt_minor),
"@ICU_MAJOR@": icu_major,
"@ICU_NEXT_MAJOR@": str(int(icu_major) + 1),
"@DOWNLOAD_URL@": download_url,
"@SHA256@": info["sha256"],
}
@@ -95,8 +96,9 @@ def main() -> int:
json.dump({"tag": tag, "pkgrel": pkgrel, "download_url": download_url, **info}, fh, indent=2)
print(f"wrote {args.out} ({args.pkgname} {jasp_version}-{pkgrel})")
print(f" R pinned to >={r_minor} <{next_minor(r_minor)}")
print(f" Qt pinned to >={qt_minor} <{next_minor(qt_minor)}")
print(f" R bundled at {info['bundled_r_version']} (not an Arch dependency)")
print(f" Qt pinned to >={qt_minor} <{next_minor(qt_minor)}")
print(f" ICU pinned to >={icu_major} <{int(icu_major) + 1}")
return 0
+2 -1
View File
@@ -16,8 +16,9 @@ state = {
"jasp_tag": meta["jasp_tag"],
"jasp_version": meta["jasp_version"],
"pkgrel": meta["pkgrel"],
"r_minor": meta["r_minor"],
"bundled_r_version": meta["bundled_r_version"],
"qt6_minor": meta["qt6_minor"],
"icu_major": meta["icu_major"],
"sha256": meta["sha256"],
"download_url": meta["download_url"],
}