diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 0b07af8..3fac416 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 4c6e7fb..0f8e46a 100644 --- a/README.md +++ b/README.md @@ -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--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 diff --git a/build/build-jasp.sh b/build/build-jasp.sh index 0bfd71d..91fe7c1 100755 --- a/build/build-jasp.sh +++ b/build/build-jasp.sh @@ -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" <=@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@") diff --git a/scripts/check-upstream.py b/scripts/check-upstream.py index 07fafde..cf6aab7 100755 --- a/scripts/check-upstream.py +++ b/scripts/check-upstream.py @@ -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 diff --git a/scripts/gen-pkgbuild.py b/scripts/gen-pkgbuild.py index 8f53bda..63cb2c5 100755 --- a/scripts/gen-pkgbuild.py +++ b/scripts/gen-pkgbuild.py @@ -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 diff --git a/scripts/record-state.sh b/scripts/record-state.sh index 44612e1..7c752a4 100755 --- a/scripts/record-state.sh +++ b/scripts/record-state.sh @@ -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"], }